6
0
mirror of https://github.com/grdl/git-get.git synced 2026-02-05 02:39:42 +00:00

Add a run package responsible for running git commands

- Add better git error handling
- Move repo helpers into a separate package
This commit is contained in:
Grzegorz Dlugoszewski
2020-06-26 13:36:58 +02:00
parent 7c5abae165
commit 28b24ec5ce
10 changed files with 482 additions and 399 deletions

View File

@@ -1,20 +1,19 @@
package git
import "os/exec"
import (
"git-get/pkg/run"
)
// ConfigGlobal represents a global gitconfig file.
type ConfigGlobal struct{}
// Get reads a value from global gitconfig file. Returns empty string when key is missing.
func (c *ConfigGlobal) Get(key string) string {
cmd := exec.Command("git", "config", "--global", key)
out, err := cmd.Output()
out, err := run.Git("config", "--global").AndCaptureLine()
// In case of error return an empty string, the missing value will fall back to a default.
if err != nil {
return ""
}
lines := lines(out)
return lines[0]
return out
}

View File

@@ -1,30 +1,32 @@
package git
import (
"git-get/pkg/io"
"git-get/pkg/run"
"git-get/pkg/test"
"path"
"testing"
)
// cfgStub represents a gitconfig file but instead of using a global one, it creates a temporary git repo and uses its local gitconfig.
type cfgStub struct {
repo *testRepo
repo *test.Repo
}
func newCfgStub(t *testing.T) *cfgStub {
r := testRepoEmpty(t)
r := test.RepoEmpty(t)
return &cfgStub{
repo: r,
}
}
func (c *cfgStub) Get(key string) string {
cmd := gitCmd(c.repo.path, "config", "--local", key)
out, err := cmd.Output()
out, err := run.Git("config", "--local", key).OnRepo(c.repo.Path()).AndCaptureLine()
if err != nil {
return ""
}
lines := lines(out)
return lines[0]
return out
}
func TestGitConfig(t *testing.T) {
@@ -73,7 +75,7 @@ func TestGitConfig(t *testing.T) {
func makeConfigEmpty(t *testing.T) *cfgStub {
c := newCfgStub(t)
c.repo.writeFile(".git/config", "")
io.Write(path.Join(c.repo.Path(), dotgit, "config"), "")
return c
}
@@ -87,7 +89,7 @@ func makeConfigValid(t *testing.T) *cfgStub {
[gitget]
host = github.com
`
c.repo.writeFile(".git/config", gitconfig)
io.Write(path.Join(c.repo.Path(), dotgit, "config"), gitconfig)
return c
}

View File

@@ -3,14 +3,10 @@ package git
import (
"fmt"
"git-get/pkg/io"
"git-get/pkg/run"
"net/url"
"os"
"os/exec"
"path"
"strconv"
"strings"
"github.com/pkg/errors"
)
const (
@@ -21,7 +17,19 @@ const (
)
// Repo represents a git repository on disk.
type Repo struct {
type Repo interface {
Path() string
Branches() ([]string, error)
CurrentBranch() (string, error)
Fetch() error
Remote() (string, error)
Uncommitted() (int, error)
Untracked() (int, error)
Upstream(string) (string, error)
AheadBehind(string, string) (int, int, error)
}
type repo struct {
path string
}
@@ -35,44 +43,38 @@ type CloneOpts struct {
}
// Open checks if given path can be accessed and returns a Repo instance pointing to it.
func Open(path string) (*Repo, error) {
func Open(path string) (Repo, error) {
_, err := io.Exists(path)
if err != nil {
return nil, err
}
return &Repo{
return &repo{
path: path,
}, nil
}
// Clone clones repository specified with CloneOpts.
func Clone(opts *CloneOpts) (*Repo, error) {
func Clone(opts *CloneOpts) (Repo, error) {
// TODO: not sure if this check should be here
if opts.IgnoreExisting {
return nil, nil
}
args := []string{"clone"}
runGit := run.Git("clone", opts.URL.String(), opts.Path)
if opts.Branch != "" {
args = append(args, "--branch", opts.Branch, "--single-branch")
runGit = run.Git("clone", "--branch", opts.Branch, "--single-branch", opts.URL.String(), opts.Path)
}
var err error
if opts.Quiet {
args = append(args, "--quiet")
err = runGit.AndShutUp()
} else {
err = runGit.AndShow()
}
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")
return nil, err
}
repo, err := Open(opts.Path)
@@ -80,24 +82,21 @@ func Clone(opts *CloneOpts) (*Repo, error) {
}
// Fetch preforms a git fetch on all remotes
func (r *Repo) Fetch() error {
cmd := gitCmd(r.path, "fetch", "--all", "--quiet")
return cmd.Run()
func (r *repo) Fetch() error {
err := run.Git("fetch", "--all").OnRepo(r.path).AndShutUp()
return err
}
// 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()
func (r *repo) Uncommitted() (int, error) {
out, err := run.Git("status", "--ignore-submodules", "--porcelain").OnRepo(r.path).AndCaptureLines()
if err != nil {
return 0, cmdError(cmd, err)
return 0, err
}
lines := lines(out)
count := 0
for _, line := range lines {
for _, line := range out {
// Don't count lines with untracked files and empty lines.
if !strings.HasPrefix(line, untracked) && strings.TrimSpace(line) != "" {
count++
@@ -108,17 +107,14 @@ func (r *Repo) Uncommitted() (int, error) {
}
// 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()
func (r *repo) Untracked() (int, error) {
out, err := run.Git("status", "--ignore-submodules", "--untracked-files=all", "--porcelain").OnRepo(r.path).AndCaptureLines()
if err != nil {
return 0, cmdError(cmd, err)
return 0, err
}
lines := lines(out)
count := 0
for _, line := range lines {
for _, line := range out {
if strings.HasPrefix(line, untracked) {
count++
}
@@ -129,69 +125,54 @@ func (r *Repo) Untracked() (int, error) {
// 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()
func (r *repo) CurrentBranch() (string, error) {
out, err := run.Git("rev-parse", "--symbolic-full-name", "--abbrev-ref", "HEAD").OnRepo(r.path).AndCaptureLine()
if err != nil {
return "", cmdError(cmd, err)
return "", err
}
lines := lines(out)
return lines[0], nil
return out, 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()
func (r *repo) Branches() ([]string, error) {
out, err := run.Git("branch", "--format=%(refname:short)").OnRepo(r.path).AndCaptureLines()
if err != nil {
return nil, cmdError(cmd, err)
return nil, 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 {
for i, line := range out {
if strings.Contains(line, "HEAD detached") {
lines = append(lines[:i], lines[i+1:]...)
out = append(out[:i], out[i+1:]...)
}
}
return lines, nil
return out, 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()
func (r *repo) Upstream(branch string) (string, error) {
out, err := run.Git("rev-parse", "--abbrev-ref", "--symbolic-full-name", fmt.Sprintf("%s@{upstream}", branch)).OnRepo(r.path).AndCaptureLine()
if err != nil {
// TODO: no upstream will also throw an error.
return "", nil //cmdError(cmd, err)
return "", nil
}
lines := lines(out)
return lines[0], nil
return out, 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()
func (r *repo) AheadBehind(branch string, upstream string) (int, int, error) {
out, err := run.Git("rev-list", "--left-right", "--count", fmt.Sprintf("%s...%s", branch, upstream)).OnRepo(r.path).AndCaptureLine()
if err != nil {
return 0, 0, cmdError(cmd, err)
return 0, 0, err
}
lines := lines(out)
// rev-list --left-right --count output is separated by a tab
lr := strings.Split(lines[0], "\t")
lr := strings.Split(out, "\t")
ahead, err := strconv.Atoi(lr[0])
if err != nil {
@@ -207,39 +188,18 @@ func (r *Repo) AheadBehind(branch string, upstream string) (int, int, error) {
}
// Remote returns URL of remote repository.
func (r *Repo) Remote() (string, error) {
func (r *repo) Remote() (string, error) {
// https://stackoverflow.com/a/16880000/1085632
cmd := gitCmd(r.path, "ls-remote", "--get-url")
out, err := cmd.Output()
out, err := run.Git("ls-remote", "--get-url").OnRepo(r.path).AndCaptureLine()
if err != nil {
return "", cmdError(cmd, err)
return "", err
}
lines := lines(out)
// TODO: needs testing. What happens when there are more than 1 remotes?
return lines[0], nil
return out, nil
}
// Path returns path to the repository.
func (r *Repo) Path() string {
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
}

View File

@@ -1,245 +0,0 @@
package git
import (
"fmt"
"git-get/pkg/io"
"net/url"
"os"
"os/exec"
"path"
"testing"
)
// testRepo embeds testing.T into a Repo instance to simplify creation of test repos.
// Any error thrown while creating a test repo will cause a t.Fatal call.
type testRepo struct {
*Repo
*testing.T
}
// TODO: this should be a method of a tempDir, not a repo
// Automatically remove test repo when the test is over
func (r *testRepo) cleanup() {
err := os.RemoveAll(r.path)
if err != nil {
r.T.Errorf("failed removing test repo directory %s", r.path)
}
}
func testRepoEmpty(t *testing.T) *testRepo {
dir, err := io.TempDir()
checkFatal(t, err)
r, err := Open(dir)
checkFatal(t, err)
tr := &testRepo{
Repo: r,
T: t,
}
t.Cleanup(tr.cleanup)
tr.init()
return tr
}
func testRepoWithUntracked(t *testing.T) *testRepo {
r := testRepoEmpty(t)
r.writeFile("README.md", "I'm a readme file")
return r
}
func testRepoWithStaged(t *testing.T) *testRepo {
r := testRepoEmpty(t)
r.writeFile("README.md", "I'm a readme file")
r.stageFile("README.md")
return r
}
func testRepoWithCommit(t *testing.T) *testRepo {
r := testRepoEmpty(t)
r.writeFile("README.md", "I'm a readme file")
r.stageFile("README.md")
r.commit("Initial commit")
return r
}
func testRepoWithUncommittedAndUntracked(t *testing.T) *testRepo {
r := testRepoEmpty(t)
r.writeFile("README.md", "I'm a readme file")
r.stageFile("README.md")
r.commit("Initial commit")
r.writeFile("README.md", "These changes won't be committed")
r.writeFile("untracked.txt", "I'm untracked")
return r
}
func testRepoWithBranch(t *testing.T) *testRepo {
r := testRepoWithCommit(t)
r.branch("feature/branch")
r.checkout("feature/branch")
return r
}
func testRepoWithTag(t *testing.T) *testRepo {
r := testRepoWithCommit(t)
r.tag("v0.0.1")
r.checkout("v0.0.1")
return r
}
func testRepoWithBranchWithUpstream(t *testing.T) *testRepo {
origin := testRepoWithCommit(t)
origin.branch("feature/branch")
r := origin.clone()
r.checkout("feature/branch")
return r
}
func testRepoWithBranchWithoutUpstream(t *testing.T) *testRepo {
origin := testRepoWithCommit(t)
r := origin.clone()
r.branch("feature/branch")
r.checkout("feature/branch")
return r
}
func testRepoWithBranchAhead(t *testing.T) *testRepo {
origin := testRepoWithCommit(t)
origin.branch("feature/branch")
r := origin.clone()
r.checkout("feature/branch")
r.writeFile("local.new", "local.new")
r.stageFile("local.new")
r.commit("local.new")
return r
}
func testRepoWithBranchBehind(t *testing.T) *testRepo {
origin := testRepoWithCommit(t)
origin.branch("feature/branch")
origin.checkout("feature/branch")
r := origin.clone()
r.checkout("feature/branch")
origin.writeFile("origin.new", "origin.new")
origin.stageFile("origin.new")
origin.commit("origin.new")
err := r.Fetch()
checkFatal(r.T, err)
return r
}
// returns a repo with 2 commits ahead and 1 behind
func testRepoWithBranchAheadAndBehind(t *testing.T) *testRepo {
origin := testRepoWithCommit(t)
origin.branch("feature/branch")
origin.checkout("feature/branch")
r := origin.clone()
r.checkout("feature/branch")
origin.writeFile("origin.new", "origin.new")
origin.stageFile("origin.new")
origin.commit("origin.new")
r.writeFile("local.new", "local.new")
r.stageFile("local.new")
r.commit("local.new")
r.writeFile("local.new2", "local.new2")
r.stageFile("local.new2")
r.commit("local.new2")
err := r.Fetch()
checkFatal(r.T, err)
return r
}
func (r *testRepo) writeFile(filename string, content string) {
path := path.Join(r.path, filename)
err := io.Write(path, content)
checkFatal(r.T, err)
}
func (r *testRepo) init() {
cmd := exec.Command("git", "init", "--quiet", r.path)
runGitCmd(r.T, cmd)
}
func (r *testRepo) stageFile(path string) {
cmd := gitCmd(r.path, "add", path)
runGitCmd(r.T, cmd)
}
func (r *testRepo) commit(msg string) {
cmd := gitCmd(r.path, "commit", "-m", fmt.Sprintf("%q", msg), "--author=\"user <user@example.com>\"")
runGitCmd(r.T, cmd)
}
func (r *testRepo) branch(name string) {
cmd := gitCmd(r.path, "branch", name)
runGitCmd(r.T, cmd)
}
func (r *testRepo) tag(name string) {
cmd := gitCmd(r.path, "tag", "-a", name, "-m", name)
runGitCmd(r.T, cmd)
}
func (r *testRepo) checkout(name string) {
cmd := gitCmd(r.path, "checkout", name)
runGitCmd(r.T, cmd)
}
func (r *testRepo) clone() *testRepo {
dir, err := io.TempDir()
checkFatal(r.T, err)
url, err := url.Parse(fmt.Sprintf("file://%s/.git", r.path))
checkFatal(r.T, err)
opts := &CloneOpts{
URL: url,
Quiet: true,
Path: dir,
}
repo, err := Clone(opts)
checkFatal(r.T, err)
tr := &testRepo{
Repo: repo,
T: r.T,
}
tr.T.Cleanup(tr.cleanup)
return tr
}
func runGitCmd(t *testing.T, cmd *exec.Cmd) {
err := cmd.Run()
checkFatal(t, cmdError(cmd, err))
}
func checkFatal(t *testing.T, err error) {
if err != nil {
t.Fatalf("%+v", err)
}
}

View File

@@ -2,6 +2,7 @@ package git
import (
"git-get/pkg/io"
"git-get/pkg/test"
"reflect"
"testing"
)
@@ -17,39 +18,39 @@ func TestOpen(t *testing.T) {
func TestUncommitted(t *testing.T) {
tests := []struct {
name string
repoMaker func(*testing.T) *testRepo
repoMaker func(*testing.T) *test.Repo
want int
}{
{
name: "empty",
repoMaker: testRepoEmpty,
repoMaker: test.RepoEmpty,
want: 0,
},
{
name: "single untracked",
repoMaker: testRepoWithUntracked,
repoMaker: test.RepoWithUntracked,
want: 0,
},
{
name: "single tracked ",
repoMaker: testRepoWithStaged,
repoMaker: test.RepoWithStaged,
want: 1,
},
{
name: "committed",
repoMaker: testRepoWithCommit,
repoMaker: test.RepoWithCommit,
want: 0,
},
{
name: "untracked and uncommitted",
repoMaker: testRepoWithUncommittedAndUntracked,
repoMaker: test.RepoWithUncommittedAndUntracked,
want: 1,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
r := test.repoMaker(t)
r, _ := Open(test.repoMaker(t).Path())
got, err := r.Uncommitted()
if err != nil {
@@ -65,39 +66,39 @@ func TestUncommitted(t *testing.T) {
func TestUntracked(t *testing.T) {
tests := []struct {
name string
repoMaker func(*testing.T) *testRepo
repoMaker func(*testing.T) *test.Repo
want int
}{
{
name: "empty",
repoMaker: testRepoEmpty,
repoMaker: test.RepoEmpty,
want: 0,
},
{
name: "single untracked",
repoMaker: testRepoWithUntracked,
repoMaker: test.RepoWithUntracked,
want: 0,
},
{
name: "single tracked ",
repoMaker: testRepoWithStaged,
repoMaker: test.RepoWithStaged,
want: 1,
},
{
name: "committed",
repoMaker: testRepoWithCommit,
repoMaker: test.RepoWithCommit,
want: 0,
},
{
name: "untracked and uncommitted",
repoMaker: testRepoWithUncommittedAndUntracked,
repoMaker: test.RepoWithUncommittedAndUntracked,
want: 1,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
r := test.repoMaker(t)
r, _ := Open(test.repoMaker(t).Path())
got, err := r.Uncommitted()
if err != nil {
@@ -114,7 +115,7 @@ func TestUntracked(t *testing.T) {
func TestCurrentBranch(t *testing.T) {
tests := []struct {
name string
repoMaker func(*testing.T) *testRepo
repoMaker func(*testing.T) *test.Repo
want string
}{
// TODO: maybe add wantErr to check if error is returned correctly?
@@ -125,24 +126,24 @@ func TestCurrentBranch(t *testing.T) {
// },
{
name: "only master branch",
repoMaker: testRepoWithCommit,
repoMaker: test.RepoWithCommit,
want: master,
},
{
name: "checked out new branch",
repoMaker: testRepoWithBranch,
repoMaker: test.RepoWithBranch,
want: "feature/branch",
},
{
name: "checked out new tag",
repoMaker: testRepoWithTag,
repoMaker: test.RepoWithTag,
want: head,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
r := test.repoMaker(t)
r, _ := Open(test.repoMaker(t).Path())
got, err := r.CurrentBranch()
if err != nil {
@@ -158,34 +159,34 @@ func TestCurrentBranch(t *testing.T) {
func TestBranches(t *testing.T) {
tests := []struct {
name string
repoMaker func(*testing.T) *testRepo
repoMaker func(*testing.T) *test.Repo
want []string
}{
{
name: "empty",
repoMaker: testRepoEmpty,
repoMaker: test.RepoEmpty,
want: []string{""},
},
{
name: "only master branch",
repoMaker: testRepoWithCommit,
repoMaker: test.RepoWithCommit,
want: []string{"master"},
},
{
name: "new branch",
repoMaker: testRepoWithBranch,
repoMaker: test.RepoWithBranch,
want: []string{"feature/branch", "master"},
},
{
name: "checked out new tag",
repoMaker: testRepoWithTag,
repoMaker: test.RepoWithTag,
want: []string{"master"},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
r := test.repoMaker(t)
r, _ := Open(test.repoMaker(t).Path())
got, err := r.Branches()
if err != nil {
@@ -201,38 +202,38 @@ func TestBranches(t *testing.T) {
func TestUpstream(t *testing.T) {
tests := []struct {
name string
repoMaker func(*testing.T) *testRepo
repoMaker func(*testing.T) *test.Repo
branch string
want string
}{
{
name: "empty",
repoMaker: testRepoEmpty,
repoMaker: test.RepoEmpty,
branch: "master",
want: "",
},
// TODO: add wantErr
{
name: "wrong branch name",
repoMaker: testRepoWithCommit,
repoMaker: test.RepoWithCommit,
branch: "wrong_branch_name",
want: "",
},
{
name: "master with upstream",
repoMaker: testRepoWithBranchWithUpstream,
repoMaker: test.RepoWithBranchWithUpstream,
branch: "master",
want: "origin/master",
},
{
name: "branch with upstream",
repoMaker: testRepoWithBranchWithUpstream,
repoMaker: test.RepoWithBranchWithUpstream,
branch: "feature/branch",
want: "origin/feature/branch",
},
{
name: "branch without upstream",
repoMaker: testRepoWithBranchWithoutUpstream,
repoMaker: test.RepoWithBranchWithoutUpstream,
branch: "feature/branch",
want: "",
},
@@ -240,7 +241,7 @@ func TestUpstream(t *testing.T) {
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
r := test.repoMaker(t)
r, _ := Open(test.repoMaker(t).Path())
got, _ := r.Upstream(test.branch)
// TODO:
@@ -257,32 +258,32 @@ func TestUpstream(t *testing.T) {
func TestAheadBehind(t *testing.T) {
tests := []struct {
name string
repoMaker func(*testing.T) *testRepo
repoMaker func(*testing.T) *test.Repo
branch string
want []int
}{
{
name: "fresh clone",
repoMaker: testRepoWithBranchWithUpstream,
repoMaker: test.RepoWithBranchWithUpstream,
branch: "master",
want: []int{0, 0},
},
{
name: "branch ahead",
repoMaker: testRepoWithBranchAhead,
repoMaker: test.RepoWithBranchAhead,
branch: "feature/branch",
want: []int{1, 0},
},
{
name: "branch behind",
repoMaker: testRepoWithBranchBehind,
repoMaker: test.RepoWithBranchBehind,
branch: "feature/branch",
want: []int{0, 1},
},
{
name: "branch ahead and behind",
repoMaker: testRepoWithBranchAheadAndBehind,
repoMaker: test.RepoWithBranchAheadAndBehind,
branch: "feature/branch",
want: []int{2, 1},
},
@@ -290,7 +291,7 @@ func TestAheadBehind(t *testing.T) {
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
r := test.repoMaker(t)
r, _ := Open(test.repoMaker(t).Path())
upstream, err := r.Upstream(test.branch)
if err != nil {
t.Errorf("got error %q", err)