From 28b24ec5ceb4ae808ed54b776dd1dec1ae4578e8 Mon Sep 17 00:00:00 2001 From: Grzegorz Dlugoszewski Date: Fri, 26 Jun 2020 13:36:58 +0200 Subject: [PATCH] Add a run package responsible for running git commands - Add better git error handling - Move repo helpers into a separate package --- pkg/cfg/config.go | 2 +- pkg/git/config.go | 11 +- pkg/git/config_test.go | 18 +-- pkg/git/repo.go | 156 +++++++++------------- pkg/git/repo_helpers_test.go | 245 ----------------------------------- pkg/git/repo_test.go | 77 +++++------ pkg/load.go | 6 +- pkg/run/run.go | 125 ++++++++++++++++++ pkg/test/helpers.go | 73 +++++++++++ pkg/test/testrepos.go | 168 ++++++++++++++++++++++++ 10 files changed, 482 insertions(+), 399 deletions(-) delete mode 100644 pkg/git/repo_helpers_test.go create mode 100644 pkg/run/run.go create mode 100644 pkg/test/helpers.go create mode 100644 pkg/test/testrepos.go diff --git a/pkg/cfg/config.go b/pkg/cfg/config.go index a5539e9..d49c496 100644 --- a/pkg/cfg/config.go +++ b/pkg/cfg/config.go @@ -83,7 +83,7 @@ func setMissingValues(cfg Gitconfig) { } func getOrDef(cfg Gitconfig, key string, def string) string { - if val := cfg.Get(key); val != "" { + if val := cfg.Get(fmt.Sprintf("%s.%s", GitgetPrefix, key)); val != "" { return val } return def diff --git a/pkg/git/config.go b/pkg/git/config.go index 6203b0c..618a894 100644 --- a/pkg/git/config.go +++ b/pkg/git/config.go @@ -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 } diff --git a/pkg/git/config_test.go b/pkg/git/config_test.go index 93d18aa..bc3e96a 100644 --- a/pkg/git/config_test.go +++ b/pkg/git/config_test.go @@ -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 } diff --git a/pkg/git/repo.go b/pkg/git/repo.go index dae73ec..b905deb 100644 --- a/pkg/git/repo.go +++ b/pkg/git/repo.go @@ -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 -} diff --git a/pkg/git/repo_helpers_test.go b/pkg/git/repo_helpers_test.go deleted file mode 100644 index f303528..0000000 --- a/pkg/git/repo_helpers_test.go +++ /dev/null @@ -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 \"") - 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) - } -} diff --git a/pkg/git/repo_test.go b/pkg/git/repo_test.go index 635dadd..1f6c909 100644 --- a/pkg/git/repo_test.go +++ b/pkg/git/repo_test.go @@ -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) diff --git a/pkg/load.go b/pkg/load.go index e90cd8d..bca20fa 100644 --- a/pkg/load.go +++ b/pkg/load.go @@ -54,7 +54,7 @@ func Load(path string) *Loaded { return loaded } -func loadBranches(r *git.Repo) (map[string]string, []error) { +func loadBranches(r git.Repo) (map[string]string, []error) { statuses := make(map[string]string) errors := make([]error, 0) @@ -75,7 +75,7 @@ func loadBranches(r *git.Repo) (map[string]string, []error) { return statuses, errors } -func loadBranchStatus(r *git.Repo, branch string) (string, error) { +func loadBranchStatus(r git.Repo, branch string) (string, error) { upstream, err := r.Upstream(branch) if err != nil { return "", err @@ -105,7 +105,7 @@ func loadBranchStatus(r *git.Repo, branch string) (string, error) { return strings.Join(res, " "), nil } -func loadWorkTree(r *git.Repo) (string, error) { +func loadWorkTree(r git.Repo) (string, error) { uncommitted, err := r.Uncommitted() if err != nil { return "", err diff --git a/pkg/run/run.go b/pkg/run/run.go new file mode 100644 index 0000000..6367f31 --- /dev/null +++ b/pkg/run/run.go @@ -0,0 +1,125 @@ +// Package run provides methods for running git command and capturing their output and errors +package run + +import ( + "bytes" + "fmt" + "os" + "os/exec" + pathpkg "path" + "strings" +) + +// Cmd represents a git command. +// The command is executed by chaining functions: Git() + optional OnRepo() + output specifier. +// This way the function chain reads more naturally. +// +// Examples of different compositions: +// +// - run.Git("clone", ).AndShow() +// means running "git clone " and printing the progress into stdout +// +// - run.Git("branch","-a").OnRepo().AndCaptureLines() +// means running "git branch -a" inside and returning a slice of branch names +// +// - run.Git("pull").OnRepo().AndShutUp() +// means running "git pull" inside and not printing any output +type Cmd struct { + cmd *exec.Cmd +} + +// Git creates a git command with given arguments. +func Git(args ...string) *Cmd { + return &Cmd{ + cmd: exec.Command("git", args...), + } +} + +// OnRepo makes the command run inside a given repository path. Otherwise the command is run outside of any repository. +// Commands like "git clone" or "git config --global" don't have to (or shouldn't in some cases) be run inside a repo. +func (c *Cmd) OnRepo(path string) *Cmd { + if strings.TrimSpace(path) == "" { + return c + } + + insert := []string{"--work-tree", path, "--git-dir", pathpkg.Join(path, ".git")} + // Insert into the args slice after the 1st element (https://github.com/golang/go/wiki/SliceTricks#insert) + c.cmd.Args = append(c.cmd.Args[:1], append(insert, c.cmd.Args[1:]...)...) + + return c +} + +// AndCaptureLines executes the command and returns its output as a slice of lines. +func (c *Cmd) AndCaptureLines() ([]string, error) { + errStream := &bytes.Buffer{} + c.cmd.Stderr = errStream + + out, err := c.cmd.Output() + if err != nil { + return nil, &GitError{errStream, c.cmd.Args, err} + } + + lines := lines(out) + if len(lines) == 0 { + return []string{""}, nil + } + + return lines, nil +} + +// AndCaptureLine executes the command and returns the first line of its output. +func (c *Cmd) AndCaptureLine() (string, error) { + lines, err := c.AndCaptureLines() + if err != nil { + return "", err + } + return lines[0], nil +} + +// AndShow executes the command and prints its output into standard output. +func (c *Cmd) AndShow() error { + c.cmd.Stdout = os.Stdout + + errStream := &bytes.Buffer{} + c.cmd.Stderr = errStream + + err := c.cmd.Run() + if err != nil { + return &GitError{errStream, c.cmd.Args, err} + } + return nil +} + +// AndShutUp executes the command and doesn't return or show any output. +func (c *Cmd) AndShutUp() error { + c.cmd.Stdout = nil + + errStream := &bytes.Buffer{} + c.cmd.Stderr = errStream + + err := c.cmd.Run() + if err != nil { + return &GitError{errStream, c.cmd.Args, err} + } + return nil +} + +// GitError provides more visibility into why an git command had failed. +type GitError struct { + Stderr *bytes.Buffer + Args []string + Err error +} + +func (e GitError) Error() string { + msg := e.Stderr.String() + if msg != "" && !strings.HasSuffix(msg, "\n") { + msg += "\n" + } + return fmt.Sprintf("%s%q: %s", msg, strings.Join(e.Args, " "), e.Err) +} + +func lines(output []byte) []string { + lines := strings.TrimSuffix(string(output), "\n") + return strings.Split(lines, "\n") +} diff --git a/pkg/test/helpers.go b/pkg/test/helpers.go new file mode 100644 index 0000000..55b1471 --- /dev/null +++ b/pkg/test/helpers.go @@ -0,0 +1,73 @@ +package test + +import ( + "fmt" + "git-get/pkg/io" + "git-get/pkg/run" + "path" + "testing" +) + +func (r *Repo) writeFile(filename string, content string) { + path := path.Join(r.path, filename) + err := io.Write(path, content) + checkFatal(r.t, err) +} + +func (r *Repo) init() { + err := run.Git("init", "--quiet", r.path).AndShutUp() + checkFatal(r.t, err) +} + +func (r *Repo) stageFile(path string) { + err := run.Git("add", path).OnRepo(r.path).AndShutUp() + checkFatal(r.t, err) +} + +func (r *Repo) commit(msg string) { + err := run.Git("commit", "-m", fmt.Sprintf("%q", msg), "--author=\"user \"").OnRepo(r.path).AndShutUp() + checkFatal(r.t, err) +} + +func (r *Repo) branch(name string) { + err := run.Git("branch", name).OnRepo(r.path).AndShutUp() + checkFatal(r.t, err) +} + +func (r *Repo) tag(name string) { + err := run.Git("tag", "-a", name, "-m", name).OnRepo(r.path).AndShutUp() + checkFatal(r.t, err) +} + +func (r *Repo) checkout(name string) { + err := run.Git("checkout", name).OnRepo(r.path).AndShutUp() + checkFatal(r.t, err) +} + +func (r *Repo) clone() *Repo { + dir, err := io.TempDir() + checkFatal(r.t, err) + + url := fmt.Sprintf("file://%s/.git", r.path) + err = run.Git("clone", url, dir).AndShutUp() + checkFatal(r.t, err) + + clone := &Repo{ + path: dir, + t: r.t, + } + + clone.t.Cleanup(r.cleanup) + return clone +} + +func (r *Repo) fetch() { + err := run.Git("fetch", "--all").OnRepo(r.path).AndShutUp() + checkFatal(r.t, err) +} + +func checkFatal(t *testing.T, err error) { + if err != nil { + t.Fatalf("failed making test repo: %+v", err) + } +} diff --git a/pkg/test/testrepos.go b/pkg/test/testrepos.go new file mode 100644 index 0000000..6c78294 --- /dev/null +++ b/pkg/test/testrepos.go @@ -0,0 +1,168 @@ +package test + +import ( + "git-get/pkg/io" + "os" + "testing" +) + +// Repo represents a test repository. +// It embeds testing.T so that any error thrown while creating a test repo will cause a t.Fatal call. +type Repo struct { + path string + t *testing.T +} + +func (r *Repo) Path() string { + return r.path +} + +// TODO: this should be a method of a tempDir, not a repo +// Automatically remove test repo when the test is over +func (r *Repo) cleanup() { + err := os.RemoveAll(r.path) + if err != nil { + r.t.Errorf("failed removing test repo directory %s", r.path) + } +} + +func RepoEmpty(t *testing.T) *Repo { + dir, err := io.TempDir() + checkFatal(t, err) + + r := &Repo{ + path: dir, + t: t, + } + + t.Cleanup(r.cleanup) + + r.init() + return r +} + +func RepoWithUntracked(t *testing.T) *Repo { + r := RepoEmpty(t) + r.writeFile("README.md", "I'm a readme file") + + return r +} + +func RepoWithStaged(t *testing.T) *Repo { + r := RepoEmpty(t) + r.writeFile("README.md", "I'm a readme file") + r.stageFile("README.md") + + return r +} + +func RepoWithCommit(t *testing.T) *Repo { + r := RepoEmpty(t) + r.writeFile("README.md", "I'm a readme file") + r.stageFile("README.md") + r.commit("Initial commit") + + return r +} + +func RepoWithUncommittedAndUntracked(t *testing.T) *Repo { + r := RepoEmpty(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 RepoWithBranch(t *testing.T) *Repo { + r := RepoWithCommit(t) + r.branch("feature/branch") + r.checkout("feature/branch") + + return r +} + +func RepoWithTag(t *testing.T) *Repo { + r := RepoWithCommit(t) + r.tag("v0.0.1") + r.checkout("v0.0.1") + + return r +} + +func RepoWithBranchWithUpstream(t *testing.T) *Repo { + origin := RepoWithCommit(t) + origin.branch("feature/branch") + + r := origin.clone() + r.checkout("feature/branch") + return r +} + +func RepoWithBranchWithoutUpstream(t *testing.T) *Repo { + origin := RepoWithCommit(t) + + r := origin.clone() + r.branch("feature/branch") + r.checkout("feature/branch") + return r +} + +func RepoWithBranchAhead(t *testing.T) *Repo { + origin := RepoWithCommit(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 RepoWithBranchBehind(t *testing.T) *Repo { + origin := RepoWithCommit(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.fetch() + + return r +} + +// returns a repo with 2 commits ahead and 1 behind +func RepoWithBranchAheadAndBehind(t *testing.T) *Repo { + origin := RepoWithCommit(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") + + r.fetch() + + return r +}