mirror of
https://github.com/grdl/git-get.git
synced 2026-02-04 18:34:51 +00:00
* Fix typo in readme * Reimplement all git methods without go-git * Rename repo pkg to git, add gitconfig methods * Improve tests for configuration reading * Rename package file to io and move RepoFinder there * Refactor printers - Remove smart printer - Decouple printers from git repos with interfaces - Update printer functions - Remove unnecessary flags - Add better remote URL detection * Update readme and go.mod * Add author to git commit in tests Otherwise tests will fail in CI. * Install git before running tests and don't use cgo * Add better error message, revert installing git * Ensure commit message is in quotes * Set up git config before running tests
246 lines
5.7 KiB
Go
246 lines
5.7 KiB
Go
package git
|
|
|
|
import (
|
|
"fmt"
|
|
"git-get/pkg/io"
|
|
"net/url"
|
|
"os"
|
|
"os/exec"
|
|
"path"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/pkg/errors"
|
|
)
|
|
|
|
const (
|
|
dotgit = ".git"
|
|
untracked = "??" // Untracked files are marked as "??" in git status output.
|
|
master = "master"
|
|
head = "HEAD"
|
|
)
|
|
|
|
// Repo represents a git repository on disk.
|
|
type Repo struct {
|
|
path string
|
|
}
|
|
|
|
// CloneOpts specify detail about repository to clone.
|
|
type CloneOpts struct {
|
|
URL *url.URL
|
|
Path string // TODO: should Path be a part of clone opts?
|
|
Branch string
|
|
Quiet bool
|
|
IgnoreExisting bool
|
|
}
|
|
|
|
// Open checks if given path can be accessed and returns a Repo instance pointing to it.
|
|
func Open(path string) (*Repo, error) {
|
|
_, err := io.Exists(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &Repo{
|
|
path: path,
|
|
}, nil
|
|
}
|
|
|
|
// Clone clones repository specified with CloneOpts.
|
|
func Clone(opts *CloneOpts) (*Repo, error) {
|
|
// TODO: not sure if this check should be here
|
|
if opts.IgnoreExisting {
|
|
return nil, nil
|
|
}
|
|
|
|
args := []string{"clone", "--progress", "-v"}
|
|
|
|
if opts.Branch != "" {
|
|
args = append(args, "--branch", opts.Branch, "--single-branch")
|
|
}
|
|
|
|
if opts.Quiet {
|
|
args = append(args, "--quiet")
|
|
}
|
|
|
|
args = append(args, opts.URL.String())
|
|
args = append(args, opts.Path)
|
|
|
|
cmd := exec.Command("git", args...)
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
|
|
err := cmd.Run()
|
|
if err != nil {
|
|
return nil, errors.Wrapf(err, "git clone failed")
|
|
}
|
|
|
|
repo, err := Open(opts.Path)
|
|
return repo, err
|
|
}
|
|
|
|
// Fetch preforms a git fetch on all remotes
|
|
func (r *Repo) Fetch() error {
|
|
cmd := gitCmd(r.path, "fetch", "--all", "--quiet")
|
|
return cmd.Run()
|
|
}
|
|
|
|
// Uncommitted returns the number of uncommitted files in the repository.
|
|
// Only tracked files are not counted.
|
|
func (r *Repo) Uncommitted() (int, error) {
|
|
cmd := gitCmd(r.path, "status", "--ignore-submodules", "--porcelain")
|
|
|
|
out, err := cmd.Output()
|
|
if err != nil {
|
|
return 0, cmdError(cmd, err)
|
|
}
|
|
|
|
lines := lines(out)
|
|
count := 0
|
|
for _, line := range lines {
|
|
// Don't count lines with untracked files and empty lines.
|
|
if !strings.HasPrefix(line, untracked) && strings.TrimSpace(line) != "" {
|
|
count++
|
|
}
|
|
}
|
|
|
|
return count, nil
|
|
}
|
|
|
|
// Untracked returns the number of untracked files in the repository.
|
|
func (r *Repo) Untracked() (int, error) {
|
|
cmd := gitCmd(r.path, "status", "--ignore-submodules", "--untracked-files=all", "--porcelain")
|
|
|
|
out, err := cmd.Output()
|
|
if err != nil {
|
|
return 0, cmdError(cmd, err)
|
|
}
|
|
|
|
lines := lines(out)
|
|
count := 0
|
|
for _, line := range lines {
|
|
if strings.HasPrefix(line, untracked) {
|
|
count++
|
|
}
|
|
}
|
|
|
|
return count, nil
|
|
}
|
|
|
|
// CurrentBranch returns the short name currently checked-out branch for the repository.
|
|
// If repo is in a detached head state, it will return "HEAD".
|
|
func (r *Repo) CurrentBranch() (string, error) {
|
|
cmd := gitCmd(r.path, "rev-parse", "--symbolic-full-name", "--abbrev-ref", "HEAD")
|
|
|
|
out, err := cmd.Output()
|
|
if err != nil {
|
|
return "", cmdError(cmd, err)
|
|
}
|
|
|
|
lines := lines(out)
|
|
return lines[0], nil
|
|
}
|
|
|
|
// Branches returns a list of local branches in the repository.
|
|
func (r *Repo) Branches() ([]string, error) {
|
|
cmd := gitCmd(r.path, "branch", "--format=%(refname:short)")
|
|
|
|
out, err := cmd.Output()
|
|
if err != nil {
|
|
return nil, cmdError(cmd, err)
|
|
}
|
|
|
|
lines := lines(out)
|
|
|
|
// TODO: Is detached head shown always on the first line? Maybe we don't need to iterate over everything.
|
|
// Remove the line containing detached head.
|
|
for i, line := range lines {
|
|
if strings.Contains(line, "HEAD detached") {
|
|
lines = append(lines[:i], lines[i+1:]...)
|
|
}
|
|
}
|
|
|
|
return lines, nil
|
|
}
|
|
|
|
// Upstream returns the name of an upstream branch if a given branch is tracking one.
|
|
// Otherwise it returns an empty string.
|
|
func (r *Repo) Upstream(branch string) (string, error) {
|
|
cmd := gitCmd(r.path, "rev-parse", "--abbrev-ref", "--symbolic-full-name", fmt.Sprintf("%s@{upstream}", branch))
|
|
|
|
out, err := cmd.Output()
|
|
if err != nil {
|
|
|
|
// TODO: no upstream will also throw an error.
|
|
return "", nil //cmdError(cmd, err)
|
|
}
|
|
|
|
lines := lines(out)
|
|
return lines[0], nil
|
|
}
|
|
|
|
// AheadBehind returns the number of commits a given branch is ahead and/or behind the upstream.
|
|
func (r *Repo) AheadBehind(branch string, upstream string) (int, int, error) {
|
|
cmd := gitCmd(r.path, "rev-list", "--left-right", "--count", fmt.Sprintf("%s...%s", branch, upstream))
|
|
|
|
out, err := cmd.Output()
|
|
if err != nil {
|
|
return 0, 0, cmdError(cmd, err)
|
|
}
|
|
|
|
lines := lines(out)
|
|
|
|
// rev-list --left-right --count output is separated by a tab
|
|
lr := strings.Split(lines[0], "\t")
|
|
|
|
ahead, err := strconv.Atoi(lr[0])
|
|
if err != nil {
|
|
return 0, 0, err
|
|
}
|
|
|
|
behind, err := strconv.Atoi(lr[1])
|
|
if err != nil {
|
|
return 0, 0, err
|
|
}
|
|
|
|
return ahead, behind, nil
|
|
}
|
|
|
|
// Remote returns URL of remote repository.
|
|
func (r *Repo) Remote() (string, error) {
|
|
// https://stackoverflow.com/a/16880000/1085632
|
|
cmd := gitCmd(r.path, "ls-remote", "--get-url")
|
|
|
|
out, err := cmd.Output()
|
|
if err != nil {
|
|
return "", cmdError(cmd, err)
|
|
}
|
|
|
|
lines := lines(out)
|
|
|
|
// TODO: needs testing. What happens when there are more than 1 remotes?
|
|
return lines[0], nil
|
|
}
|
|
|
|
// Path returns path to the repository.
|
|
func (r *Repo) Path() string {
|
|
return r.path
|
|
}
|
|
|
|
func gitCmd(repoPath string, args ...string) *exec.Cmd {
|
|
args = append([]string{"--work-tree", repoPath, "--git-dir", path.Join(repoPath, dotgit)}, args...)
|
|
return exec.Command("git", args...)
|
|
}
|
|
|
|
func lines(output []byte) []string {
|
|
lines := strings.TrimSuffix(string(output), "\n")
|
|
return strings.Split(lines, "\n")
|
|
}
|
|
|
|
func cmdError(cmd *exec.Cmd, err error) error {
|
|
if err != nil {
|
|
return errors.Wrapf(err, "%s failed: %+v", strings.Join(cmd.Args, " "), err) // Show which git command failed (skip "--work-tree and --gitdir flags")
|
|
}
|
|
return nil
|
|
}
|