From 1bc3928cf54427e2c38141870688589264f6aaf0 Mon Sep 17 00:00:00 2001 From: Grzegorz Dlugoszewski Date: Thu, 28 May 2020 16:34:44 +0200 Subject: [PATCH] Finish refactoring by replacing old pkg with new one --- cmd/git-get/main.go | 2 +- new/helpers_test.go | 230 -------------------------- new/repo.go | 76 --------- new/status.go | 215 ------------------------- new/status_test.go | 149 ----------------- new/url.go | 68 -------- new/url_test.go | 81 ---------- pkg/helpers_test.go | 280 +++++++++++++++++++++----------- pkg/repo.go | 127 +++++---------- pkg/repo_test.go | 66 -------- pkg/status.go | 262 ++++++++++++++++++------------ pkg/status_test.go | 383 +++++++++++++++----------------------------- pkg/url.go | 6 +- pkg/url_test.go | 10 +- 14 files changed, 523 insertions(+), 1432 deletions(-) delete mode 100644 new/helpers_test.go delete mode 100644 new/repo.go delete mode 100644 new/status.go delete mode 100644 new/status_test.go delete mode 100644 new/url.go delete mode 100644 new/url_test.go delete mode 100644 pkg/repo_test.go diff --git a/cmd/git-get/main.go b/cmd/git-get/main.go index 1d7eebe..29070f2 100644 --- a/cmd/git-get/main.go +++ b/cmd/git-get/main.go @@ -27,7 +27,7 @@ func Run(cmd *cobra.Command, args []string) error { return err } - _, err = pkg.CloneRepo(url, ReposRoot) + _, err = pkg.CloneRepo(url, ReposRoot, false) return err } diff --git a/new/helpers_test.go b/new/helpers_test.go deleted file mode 100644 index 62510dc..0000000 --- a/new/helpers_test.go +++ /dev/null @@ -1,230 +0,0 @@ -package new - -import ( - "io/ioutil" - pkgurl "net/url" - "os" - "testing" - "time" - - "github.com/go-git/go-git/v5/plumbing" - - "github.com/go-git/go-git/v5/plumbing/object" - - "github.com/go-git/go-git/v5" - "github.com/pkg/errors" -) - -type TestRepo struct { - Repo *git.Repository - Path string - URL *pkgurl.URL - t *testing.T -} - -func NewRepoEmpty(t *testing.T) *TestRepo { - dir := NewTempDir(t) - - repo, err := git.PlainInit(dir, false) - checkFatal(t, err) - - url, err := ParseURL("file://" + dir) - checkFatal(t, err) - - return &TestRepo{ - Repo: repo, - Path: dir, - URL: url, - t: t, - } -} - -func NewRepoWithUntracked(t *testing.T) *TestRepo { - tr := NewRepoEmpty(t) - tr.WriteFile("README", "I'm a README file") - - return tr -} - -func NewRepoWithStaged(t *testing.T) *TestRepo { - tr := NewRepoEmpty(t) - tr.WriteFile("README", "I'm a README file") - tr.AddFile("README") - - return tr -} -func NewRepoWithCommit(t *testing.T) *TestRepo { - tr := NewRepoEmpty(t) - tr.WriteFile("README", "I'm a README file") - tr.AddFile("README") - tr.NewCommit("Initial commit") - - return tr -} - -func NewRepoWithModified(t *testing.T) *TestRepo { - tr := NewRepoEmpty(t) - tr.WriteFile("README", "I'm a README file") - tr.AddFile("README") - tr.NewCommit("Initial commit") - tr.WriteFile("README", "I'm modified") - - return tr -} - -func NewRepoWithIgnored(t *testing.T) *TestRepo { - tr := NewRepoEmpty(t) - tr.WriteFile(".gitignore", "ignoreme") - tr.AddFile(".gitignore") - tr.NewCommit("Initial commit") - tr.WriteFile("ignoreme", "I'm being ignored") - - return tr -} - -func NewRepoWithLocalBranch(t *testing.T) *TestRepo { - tr := NewRepoWithCommit(t) - tr.NewBranch("local") - return tr -} - -func NewRepoWithClonedBranch(t *testing.T) *TestRepo { - origin := NewRepoWithCommit(t) - - tr := origin.Clone() - tr.NewBranch("local") - - return tr -} - -func NewRepoWithBranchAhead(t *testing.T) *TestRepo { - origin := NewRepoWithCommit(t) - - tr := origin.Clone() - tr.WriteFile("new", "I'm a new file") - tr.AddFile("new") - tr.NewCommit("New commit") - - return tr -} - -func NewRepoWithBranchBehind(t *testing.T) *TestRepo { - origin := NewRepoWithCommit(t) - - tr := origin.Clone() - - origin.WriteFile("origin.new", "I'm a new file on origin") - origin.AddFile("origin.new") - origin.NewCommit("New origin commit") - - tr.Fetch() - return tr -} - -func NewRepoWithBranchAheadAndBehind(t *testing.T) *TestRepo { - origin := NewRepoWithCommit(t) - - tr := origin.Clone() - tr.WriteFile("local.new", "I'm a new file on local") - tr.AddFile("local.new") - tr.NewCommit("New local commit") - - origin.WriteFile("origin.new", "I'm a new file on origin") - origin.AddFile("origin.new") - origin.NewCommit("New origin commit") - - tr.Fetch() - return tr -} - -func NewTempDir(t *testing.T) string { - dir, err := ioutil.TempDir("", "git-get-repo-") - checkFatal(t, errors.Wrap(err, "Failed creating test repo directory")) - - // Automatically remove repo when test is over - t.Cleanup(func() { - err := os.RemoveAll(dir) - if err != nil { - t.Errorf("failed cleaning up repo") - } - }) - - return dir -} - -func (r *TestRepo) WriteFile(name string, content string) { - wt, err := r.Repo.Worktree() - checkFatal(r.t, errors.Wrap(err, "Failed getting worktree")) - - file, err := wt.Filesystem.OpenFile(name, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644) - checkFatal(r.t, errors.Wrap(err, "Failed opening a file")) - - _, err = file.Write([]byte(content)) - checkFatal(r.t, errors.Wrap(err, "Failed writing a file")) -} - -func (r *TestRepo) AddFile(name string) { - wt, err := r.Repo.Worktree() - checkFatal(r.t, errors.Wrap(err, "Failed getting worktree")) - - _, err = wt.Add(name) - checkFatal(r.t, errors.Wrap(err, "Failed adding file to index")) -} - -func (r *TestRepo) NewCommit(msg string) { - wt, err := r.Repo.Worktree() - checkFatal(r.t, errors.Wrap(err, "Failed getting worktree")) - - opts := &git.CommitOptions{ - Author: &object.Signature{ - Name: "Some Guy", - Email: "someguy@example.com", - When: time.Date(2000, 01, 01, 16, 00, 00, 0, time.UTC), - }, - } - - _, err = wt.Commit(msg, opts) - checkFatal(r.t, errors.Wrap(err, "Failed creating commit")) -} - -func (r *TestRepo) NewBranch(name string) { - head, err := r.Repo.Head() - checkFatal(r.t, err) - - ref := plumbing.NewHashReference(plumbing.NewBranchReferenceName(name), head.Hash()) - - err = r.Repo.Storer.SetReference(ref) - checkFatal(r.t, err) -} - -func (r *TestRepo) Clone() *TestRepo { - dir := NewTempDir(r.t) - - repo, err := CloneRepo(r.URL, dir, true) - checkFatal(r.t, err) - - url, err := ParseURL("file://" + dir) - checkFatal(r.t, err) - - return &TestRepo{ - Repo: repo.repo, - Path: dir, - URL: url, - t: r.t, - } -} - -func (r *TestRepo) Fetch() { - repo := &Repo{ - repo: r.Repo, - } - - err := repo.Fetch() - checkFatal(r.t, err) -} - -func checkFatal(t *testing.T, err error) { - if err != nil { - t.Fatalf("%+v", err) - } -} diff --git a/new/repo.go b/new/repo.go deleted file mode 100644 index 043b4fa..0000000 --- a/new/repo.go +++ /dev/null @@ -1,76 +0,0 @@ -package new - -import ( - "io" - "net/url" - "os" - - "github.com/pkg/errors" - - "github.com/go-git/go-git/v5" -) - -type Repo struct { - repo *git.Repository - Status *RepoStatus -} - -func CloneRepo(url *url.URL, path string, quiet bool) (r *Repo, err error) { - var output io.Writer - if !quiet { - output = os.Stdout - } - - opts := &git.CloneOptions{ - URL: url.String(), - Auth: nil, - RemoteName: git.DefaultRemoteName, - ReferenceName: "", - SingleBranch: false, - NoCheckout: false, - Depth: 0, - RecurseSubmodules: git.NoRecurseSubmodules, - Progress: output, - Tags: git.AllTags, - } - - repo, err := git.PlainClone(path, false, opts) - if err != nil { - return nil, errors.Wrap(err, "Failed cloning repo") - } - - return newRepo(repo), nil -} - -func OpenRepo(path string) (r *Repo, err error) { - repo, err := git.PlainOpen(path) - if err != nil { - return nil, errors.Wrap(err, "Failed opening repo") - } - - return newRepo(repo), nil -} - -func newRepo(repo *git.Repository) *Repo { - return &Repo{ - repo: repo, - Status: &RepoStatus{}, - } -} - -// Fetch performs a git fetch on all remotes -func (r *Repo) Fetch() error { - remotes, err := r.repo.Remotes() - if err != nil { - return errors.Wrap(err, "Failed getting remotes") - } - - for _, remote := range remotes { - err = remote.Fetch(&git.FetchOptions{}) - if err != nil { - return errors.Wrapf(err, "Failed fetching remote %s", remote.Config().Name) - } - } - - return nil -} diff --git a/new/status.go b/new/status.go deleted file mode 100644 index d2352e4..0000000 --- a/new/status.go +++ /dev/null @@ -1,215 +0,0 @@ -package new - -import ( - "sort" - "strings" - - "github.com/go-git/go-git/v5" - "github.com/go-git/go-git/v5/plumbing" - "github.com/pkg/errors" -) - -type RepoStatus struct { - HasUntrackedFiles bool - HasUncommittedChanges bool - Branches []*BranchStatus -} - -type BranchStatus struct { - Name string - Upstream string - NeedsPull bool - NeedsPush bool -} - -func (r *Repo) LoadStatus() error { - wt, err := r.repo.Worktree() - if err != nil { - return errors.Wrap(err, "Failed getting worktree") - } - - status, err := wt.Status() - if err != nil { - return errors.Wrap(err, "Failed getting worktree status") - } - - r.Status.HasUncommittedChanges = hasUncommitted(status) - r.Status.HasUntrackedFiles = hasUntracked(status) - - err = r.loadBranchesStatus() - if err != nil { - return err - } - - return nil -} - -// hasUntracked returns true if there are any untracked files in the worktree -func hasUntracked(status git.Status) bool { - for _, fs := range status { - if fs.Worktree == git.Untracked || fs.Staging == git.Untracked { - return true - } - } - - return false -} - -// hasUncommitted returns true if there are any uncommitted (but tracked) files in the worktree -func hasUncommitted(status git.Status) bool { - // If repo is clean it means every file in worktree and staging has 'Unmodified' state - if status.IsClean() { - return false - } - - // If repo is not clean, check if any file has state different than 'Untracked' - it means they are tracked and have uncommitted modifications - for _, fs := range status { - if fs.Worktree != git.Untracked || fs.Staging != git.Untracked { - return true - } - } - - return false -} - -func (r *Repo) loadBranchesStatus() error { - iter, err := r.repo.Branches() - if err != nil { - return errors.Wrap(err, "Failed getting branches iterator") - } - - err = iter.ForEach(func(reference *plumbing.Reference) error { - bs, err := r.newBranchStatus(reference.Name().Short()) - if err != nil { - return err - } - - r.Status.Branches = append(r.Status.Branches, bs) - return nil - }) - if err != nil { - return errors.Wrap(err, "Failed iterating over branches") - } - - // Sort branches by name. It's useful to have them sorted for printing and testing. - sort.Slice(r.Status.Branches, func(i, j int) bool { - return strings.Compare(r.Status.Branches[i].Name, r.Status.Branches[j].Name) < 0 - }) - return nil -} - -func (r *Repo) newBranchStatus(branch string) (*BranchStatus, error) { - bs := &BranchStatus{ - Name: branch, - } - - upstream, err := r.upstream(branch) - if err != nil { - return nil, err - } - - if upstream == "" { - return bs, nil - } - - needsPull, needsPush, err := r.needsPullOrPush(branch, upstream) - if err != nil { - return nil, err - } - - bs.Upstream = upstream - bs.NeedsPush = needsPush - bs.NeedsPull = needsPull - - return bs, nil -} - -// upstream finds if a given branch tracks an upstream. -// Returns found upstream branch name (eg, origin/master) or empty string if branch doesn't track an upstream. -// -// Information about upstream is taken from .git/config file. -// If a branch has an upstream, there's a [branch] section in the file with two fields: -// "remote" - name of the remote containing upstreamn branch (or "." if upstream is a local branch) -// "merge" - full ref name of the upstream branch (eg, ref/heads/master) -func (r *Repo) upstream(branch string) (string, error) { - cfg, err := r.repo.Config() - if err != nil { - return "", errors.Wrap(err, "Failed getting repo config") - } - - // Check if our branch exists in "branch" config sections. If not, it doesn't have an upstream configured. - bcfg := cfg.Branches[branch] - if bcfg == nil { - return "", nil - } - - remote := bcfg.Remote - if remote == "" { - return "", nil - } - - merge := bcfg.Merge.Short() - if merge == "" { - return "", nil - } - return remote + "/" + merge, nil -} - -func (r *Repo) needsPullOrPush(localBranch string, upstreamBranch string) (needsPull bool, needsPush bool, err error) { - localHash, err := r.repo.ResolveRevision(plumbing.Revision(localBranch)) - if err != nil { - return false, false, errors.Wrapf(err, "Failed resolving revision %s", localBranch) - } - - upstreamHash, err := r.repo.ResolveRevision(plumbing.Revision(upstreamBranch)) - if err != nil { - return false, false, errors.Wrapf(err, "Failed resolving revision %s", upstreamBranch) - } - - localCommit, err := r.repo.CommitObject(*localHash) - if err != nil { - return false, false, errors.Wrapf(err, "Failed finding a commit for hash %s", localHash.String()) - } - - upstreamCommit, err := r.repo.CommitObject(*upstreamHash) - if err != nil { - return false, false, errors.Wrapf(err, "Failed finding a commit for hash %s", upstreamHash.String()) - } - - // If local branch hash is the same as upstream, it means there is no difference between local and upstream - if *localHash == *upstreamHash { - return false, false, nil - } - - commons, err := localCommit.MergeBase(upstreamCommit) - if err != nil { - return false, false, errors.Wrapf(err, "Failed finding common ancestors for branches %s & %s", localBranch, upstreamBranch) - } - - if len(commons) == 0 { - // TODO: No common ancestors. This should be an error - return false, false, nil - } - - if len(commons) > 1 { - // TODO: multiple best ancestors. How to handle this? - return false, false, nil - } - - mergeBase := commons[0] - - // If merge base is the same as upstream branch, local branch is ahead and push is needed - // If merge base is the same as local branch, local branch is behind and pull is needed - // If merge base is something else, branches have diverged and merge is needed (both pull and push) - // ref: https://stackoverflow.com/a/17723781/1085632 - - if mergeBase.Hash == *upstreamHash { - return false, true, nil - } - - if mergeBase.Hash == *localHash { - return true, false, nil - } - - return true, true, nil -} diff --git a/new/status_test.go b/new/status_test.go deleted file mode 100644 index d00daf8..0000000 --- a/new/status_test.go +++ /dev/null @@ -1,149 +0,0 @@ -package new - -import ( - "reflect" - "testing" -) - -func TestStatus(t *testing.T) { - var tests = []struct { - makeTestRepo func(*testing.T) *TestRepo - want *RepoStatus - }{ - {NewRepoEmpty, &RepoStatus{ - HasUntrackedFiles: false, - HasUncommittedChanges: false, - Branches: nil, - }}, - {NewRepoWithUntracked, &RepoStatus{ - HasUntrackedFiles: true, - HasUncommittedChanges: false, - Branches: nil, - }}, - {NewRepoWithStaged, &RepoStatus{ - HasUntrackedFiles: false, - HasUncommittedChanges: true, - Branches: nil, - }}, - {NewRepoWithCommit, &RepoStatus{ - HasUntrackedFiles: false, - HasUncommittedChanges: false, - Branches: []*BranchStatus{ - { - Name: "master", - Upstream: "", - NeedsPull: false, - NeedsPush: false, - }, - }, - }}, - {NewRepoWithModified, &RepoStatus{ - HasUntrackedFiles: false, - HasUncommittedChanges: true, - Branches: []*BranchStatus{ - { - Name: "master", - Upstream: "", - NeedsPull: false, - NeedsPush: false, - }, - }, - }}, - {NewRepoWithIgnored, &RepoStatus{ - HasUntrackedFiles: false, - HasUncommittedChanges: false, - Branches: []*BranchStatus{ - { - Name: "master", - Upstream: "", - NeedsPull: false, - NeedsPush: false, - }, - }, - }}, - {NewRepoWithLocalBranch, &RepoStatus{ - HasUntrackedFiles: false, - HasUncommittedChanges: false, - Branches: []*BranchStatus{ - { - Name: "local", - Upstream: "", - NeedsPull: false, - NeedsPush: false, - }, { - Name: "master", - Upstream: "", - NeedsPull: false, - NeedsPush: false, - }, - }, - }}, - {NewRepoWithClonedBranch, &RepoStatus{ - HasUntrackedFiles: false, - HasUncommittedChanges: false, - Branches: []*BranchStatus{ - { - Name: "local", - Upstream: "", - NeedsPull: false, - NeedsPush: false, - }, { - Name: "master", - Upstream: "origin/master", - NeedsPull: false, - NeedsPush: false, - }, - }, - }}, - {NewRepoWithBranchAhead, &RepoStatus{ - HasUntrackedFiles: false, - HasUncommittedChanges: false, - Branches: []*BranchStatus{ - { - Name: "master", - Upstream: "origin/master", - NeedsPull: false, - NeedsPush: true, - }, - }, - }}, - {NewRepoWithBranchBehind, &RepoStatus{ - HasUntrackedFiles: false, - HasUncommittedChanges: false, - Branches: []*BranchStatus{ - { - Name: "master", - Upstream: "origin/master", - NeedsPull: true, - NeedsPush: false, - }, - }, - }}, - {NewRepoWithBranchAheadAndBehind, &RepoStatus{ - HasUntrackedFiles: false, - HasUncommittedChanges: false, - Branches: []*BranchStatus{ - { - Name: "master", - Upstream: "origin/master", - NeedsPull: true, - NeedsPush: true, - }, - }, - }}, - } - - for _, test := range tests { - tr := test.makeTestRepo(t) - - repo, err := OpenRepo(tr.Path) - checkFatal(t, err) - - err = repo.LoadStatus() - checkFatal(t, err) - - if !reflect.DeepEqual(repo.Status, test.want) { - t.Errorf("Wrong repo status, got: %+v; want: %+v", repo.Status, test.want) - } - } -} diff --git a/new/url.go b/new/url.go deleted file mode 100644 index abd8d6f..0000000 --- a/new/url.go +++ /dev/null @@ -1,68 +0,0 @@ -package new - -import ( - urlpkg "net/url" - "path" - "regexp" - "strings" - - "github.com/pkg/errors" -) - -// scpSyntax matches the SCP-like addresses used by the ssh protocol (eg, [user@]host.xz:path/to/repo.git/). -// See: https://golang.org/src/cmd/go/internal/get/vcs.go -var scpSyntax = regexp.MustCompile(`^([a-zA-Z0-9_]+)@([a-zA-Z0-9._-]+):(.*)$`) - -func ParseURL(rawURL string) (url *urlpkg.URL, err error) { - // If rawURL matches the SCP-like syntax, convert it into a standard ssh Path. - // eg, git@github.com:user/repo => ssh://git@github.com/user/repo - if m := scpSyntax.FindStringSubmatch(rawURL); m != nil { - url = &urlpkg.URL{ - Scheme: "ssh", - User: urlpkg.User(m[1]), - Host: m[2], - Path: m[3], - } - } else { - url, err = urlpkg.Parse(rawURL) - if err != nil { - return nil, errors.Wrap(err, "Failed parsing Path") - } - } - - if url.Host == "" && url.Path == "" { - return nil, errors.New("Parsed Path is empty") - } - - if url.Scheme == "git+ssh" { - url.Scheme = "ssh" - } - - // Default to "git" user when using ssh and no user is provided - if url.Scheme == "ssh" && url.User == nil { - url.User = urlpkg.User("git") - } - - // Default to https - if url.Scheme == "" { - url.Scheme = "https" - } - - // TODO: Default to github host - - return url, nil -} - -func URLToPath(url *urlpkg.URL) (repoPath string) { - // Remove port numbers from host - repoHost := strings.Split(url.Host, ":")[0] - - // Remove trailing ".git" from repo name - repoPath = path.Join(repoHost, url.Path) - repoPath = strings.TrimSuffix(repoPath, ".git") - - // Remove tilde (~) char from username - repoPath = strings.ReplaceAll(repoPath, "~", "") - - return repoPath -} diff --git a/new/url_test.go b/new/url_test.go deleted file mode 100644 index c0e538f..0000000 --- a/new/url_test.go +++ /dev/null @@ -1,81 +0,0 @@ -package new - -import ( - "testing" -) - -// Following URLs are considered valid according to https://git-scm.com/docs/git-clone#_git_urls: -// ssh://[user@]host.xz[:port]/path/to/repo.git -// ssh://[user@]host.xz[:port]/~[user]/path/to/repo.git/ -// [user@]host.xz:path/to/repo.git/ -// [user@]host.xz:/~[user]/path/to/repo.git/ -// git://host.xz[:port]/path/to/repo.git/ -// git://host.xz[:port]/~[user]/path/to/repo.git/ -// http[s]://host.xz[:port]/path/to/repo.git/ -// ftp[s]://host.xz[:port]/path/to/repo.git/ -// /path/to/repo.git/ -// file:///path/to/repo.git/ - -func TestURLParse(t *testing.T) { - tests := []struct { - in string - want string - }{ - {"ssh://github.com/grdl/git-get.git", "github.com/grdl/git-get"}, - {"ssh://user@github.com/grdl/git-get.git", "github.com/grdl/git-get"}, - {"ssh://user@github.com:1234/grdl/git-get.git", "github.com/grdl/git-get"}, - {"ssh://user@github.com/~user/grdl/git-get.git", "github.com/user/grdl/git-get"}, - {"git+ssh://github.com/grdl/git-get.git", "github.com/grdl/git-get"}, - {"git@github.com:grdl/git-get.git", "github.com/grdl/git-get"}, - {"git@github.com:/~user/grdl/git-get.git", "github.com/user/grdl/git-get"}, - {"git://github.com/grdl/git-get.git", "github.com/grdl/git-get"}, - {"git://github.com/~user/grdl/git-get.git", "github.com/user/grdl/git-get"}, - {"https://github.com/grdl/git-get.git", "github.com/grdl/git-get"}, - {"http://github.com/grdl/git-get.git", "github.com/grdl/git-get"}, - {"https://github.com/grdl/git-get", "github.com/grdl/git-get"}, - {"https://github.com/git-get.git", "github.com/git-get"}, - {"https://github.com/git-get", "github.com/git-get"}, - {"https://github.com/grdl/sub/path/git-get.git", "github.com/grdl/sub/path/git-get"}, - {"https://github.com:1234/grdl/git-get.git", "github.com/grdl/git-get"}, - {"https://github.com/grdl/git-get.git/", "github.com/grdl/git-get"}, - {"https://github.com/grdl/git-get/", "github.com/grdl/git-get"}, - {"https://github.com/grdl/git-get/////", "github.com/grdl/git-get"}, - {"https://github.com/grdl/git-get.git/////", "github.com/grdl/git-get"}, - {"ftp://github.com/grdl/git-get.git", "github.com/grdl/git-get"}, - {"ftps://github.com/grdl/git-get.git", "github.com/grdl/git-get"}, - {"rsync://github.com/grdl/git-get.git", "github.com/grdl/git-get"}, - {"local/grdl/git-get/", "local/grdl/git-get"}, - {"file://local/grdl/git-get", "local/grdl/git-get"}, - } - - for _, test := range tests { - url, err := ParseURL(test.in) - if err != nil { - t.Errorf("Error parsing Path: %+v", err) - } - - got := URLToPath(url) - - if got != test.want { - t.Errorf("Wrong result of parsing Path: %s, got: %s; want: %s", test.in, got, test.want) - } - } -} - -func TestInvalidURLParse(t *testing.T) { - invalidURLs := []string{ - "", - //TODO: This Path is technically a correct scp-like syntax. Not sure how to handle it - "github.com:grdl/git-git.get.git", - - //TODO: Is this a valid git Path? - //"git@github.com:1234:grdl/git-get.git", - } - - for _, in := range invalidURLs { - got, err := ParseURL(in) - if err == nil { - t.Errorf("Wrong result of parsing invalid Path: %s, got: %s, want: error", in, got) - } - } -} diff --git a/pkg/helpers_test.go b/pkg/helpers_test.go index e922e28..11d2ec3 100644 --- a/pkg/helpers_test.go +++ b/pkg/helpers_test.go @@ -2,23 +2,142 @@ package pkg import ( "io/ioutil" + pkgurl "net/url" "os" - "path" "testing" "time" - "github.com/pkg/errors" + "github.com/go-git/go-git/v5/plumbing" - git "github.com/libgit2/git2go/v30" + "github.com/go-git/go-git/v5/plumbing/object" + + "github.com/go-git/go-git/v5" + "github.com/pkg/errors" ) -func checkFatal(t *testing.T, err error) { - if err != nil { - t.Fatalf("%+v", err) +type TestRepo struct { + Repo *git.Repository + Path string + URL *pkgurl.URL + t *testing.T +} + +func NewRepoEmpty(t *testing.T) *TestRepo { + dir := NewTempDir(t) + + repo, err := git.PlainInit(dir, false) + checkFatal(t, err) + + url, err := ParseURL("file://" + dir) + checkFatal(t, err) + + return &TestRepo{ + Repo: repo, + Path: dir, + URL: url, + t: t, } } -func newTempDir(t *testing.T) string { +func NewRepoWithUntracked(t *testing.T) *TestRepo { + tr := NewRepoEmpty(t) + tr.WriteFile("README", "I'm a README file") + + return tr +} + +func NewRepoWithStaged(t *testing.T) *TestRepo { + tr := NewRepoEmpty(t) + tr.WriteFile("README", "I'm a README file") + tr.AddFile("README") + + return tr +} +func NewRepoWithCommit(t *testing.T) *TestRepo { + tr := NewRepoEmpty(t) + tr.WriteFile("README", "I'm a README file") + tr.AddFile("README") + tr.NewCommit("Initial commit") + + return tr +} + +func NewRepoWithModified(t *testing.T) *TestRepo { + tr := NewRepoEmpty(t) + tr.WriteFile("README", "I'm a README file") + tr.AddFile("README") + tr.NewCommit("Initial commit") + tr.WriteFile("README", "I'm modified") + + return tr +} + +func NewRepoWithIgnored(t *testing.T) *TestRepo { + tr := NewRepoEmpty(t) + tr.WriteFile(".gitignore", "ignoreme") + tr.AddFile(".gitignore") + tr.NewCommit("Initial commit") + tr.WriteFile("ignoreme", "I'm being ignored") + + return tr +} + +func NewRepoWithLocalBranch(t *testing.T) *TestRepo { + tr := NewRepoWithCommit(t) + tr.NewBranch("local") + return tr +} + +func NewRepoWithClonedBranch(t *testing.T) *TestRepo { + origin := NewRepoWithCommit(t) + + tr := origin.Clone() + tr.NewBranch("local") + + return tr +} + +func NewRepoWithBranchAhead(t *testing.T) *TestRepo { + origin := NewRepoWithCommit(t) + + tr := origin.Clone() + tr.WriteFile("new", "I'm a new file") + tr.AddFile("new") + tr.NewCommit("New commit") + + return tr +} + +func NewRepoWithBranchBehind(t *testing.T) *TestRepo { + origin := NewRepoWithCommit(t) + + tr := origin.Clone() + + origin.WriteFile("origin.new", "I'm a new file on origin") + origin.AddFile("origin.new") + origin.NewCommit("New origin commit") + + tr.Fetch() + return tr +} + +func NewRepoWithBranchAheadAndBehind(t *testing.T) *TestRepo { + origin := NewRepoWithCommit(t) + + tr := origin.Clone() + tr.WriteFile("local.new", "I'm a new file on local") + tr.AddFile("local.new") + tr.NewCommit("New local commit") + + origin.WriteFile("origin.new", "I'm a new file on origin") + origin.AddFile("origin.new") + origin.NewCommit("New origin commit") + + tr.Fetch() + return tr +} + +func NewTempDir(t *testing.T) string { dir, err := ioutil.TempDir("", "git-get-repo-") checkFatal(t, errors.Wrap(err, "Failed creating test repo directory")) @@ -33,108 +152,79 @@ func newTempDir(t *testing.T) string { return dir } -func newTestRepo(t *testing.T) *git.Repository { - dir := newTempDir(t) - repo, err := git.InitRepository(dir, false) - checkFatal(t, errors.Wrap(err, "Failed initializing a temp repo")) +func (r *TestRepo) WriteFile(name string, content string) { + wt, err := r.Repo.Worktree() + checkFatal(r.t, errors.Wrap(err, "Failed getting worktree")) - return repo + file, err := wt.Filesystem.OpenFile(name, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644) + checkFatal(r.t, errors.Wrap(err, "Failed opening a file")) + + _, err = file.Write([]byte(content)) + checkFatal(r.t, errors.Wrap(err, "Failed writing a file")) } -func createFile(t *testing.T, repo *git.Repository, name string) { - err := ioutil.WriteFile(path.Join(repo.Workdir(), name), []byte("I'm a file"), 0644) - checkFatal(t, errors.Wrap(err, "Failed writing a file")) +func (r *TestRepo) AddFile(name string) { + wt, err := r.Repo.Worktree() + checkFatal(r.t, errors.Wrap(err, "Failed getting worktree")) + + _, err = wt.Add(name) + checkFatal(r.t, errors.Wrap(err, "Failed adding file to index")) } -func stageFile(t *testing.T, repo *git.Repository, name string) { - index, err := repo.Index() - checkFatal(t, errors.Wrap(err, "Failed getting repo index")) +func (r *TestRepo) NewCommit(msg string) { + wt, err := r.Repo.Worktree() + checkFatal(r.t, errors.Wrap(err, "Failed getting worktree")) - err = index.AddByPath(name) - checkFatal(t, errors.Wrap(err, "Failed adding file to index")) - - err = index.Write() - checkFatal(t, errors.Wrap(err, "Failed writing index")) -} - -func createCommit(t *testing.T, repo *git.Repository, message string) *git.Commit { - index, err := repo.Index() - checkFatal(t, errors.Wrap(err, "Failed getting repo index")) - - treeId, err := index.WriteTree() - checkFatal(t, errors.Wrap(err, "Failed building tree from index")) - - tree, err := repo.LookupTree(treeId) - checkFatal(t, errors.Wrap(err, "Failed looking up tree id")) - - signature := &git.Signature{ - Name: "Some Guy", - Email: "someguy@example.com", - When: time.Date(2000, 01, 01, 16, 00, 00, 0, time.UTC), + opts := &git.CommitOptions{ + Author: &object.Signature{ + Name: "Some Guy", + Email: "someguy@example.com", + When: time.Date(2000, 01, 01, 16, 00, 00, 0, time.UTC), + }, } - empty, err := repo.IsEmpty() - checkFatal(t, errors.Wrap(err, "Failed checking if repo is empty")) - - var commitId *git.Oid - if !empty { - currentBranch, err := repo.Head() - checkFatal(t, errors.Wrap(err, "Failed getting current branch")) - - currentTip, err := repo.LookupCommit(currentBranch.Target()) - checkFatal(t, errors.Wrap(err, "Failed getting current tip")) - - commitId, err = repo.CreateCommit("HEAD", signature, signature, message, tree, currentTip) - } else { - commitId, err = repo.CreateCommit("HEAD", signature, signature, message, tree) - } - - commit, err := repo.LookupCommit(commitId) - checkFatal(t, errors.Wrap(err, "Failed looking up a commit")) - - return commit + _, err = wt.Commit(msg, opts) + checkFatal(r.t, errors.Wrap(err, "Failed creating commit")) } -func createBranch(t *testing.T, repo *git.Repository, name string) *git.Branch { - head, err := repo.Head() - checkFatal(t, errors.Wrap(err, "Failed getting repo head")) +func (r *TestRepo) NewBranch(name string) { + head, err := r.Repo.Head() + checkFatal(r.t, err) - commit, err := repo.LookupCommit(head.Target()) - checkFatal(t, errors.Wrap(err, "Failed getting commit id from head")) + ref := plumbing.NewHashReference(plumbing.NewBranchReferenceName(name), head.Hash()) - branch, err := repo.CreateBranch(name, commit, false) - checkFatal(t, errors.Wrap(err, "Failed creating branch")) - - return branch + err = r.Repo.Storer.SetReference(ref) + checkFatal(r.t, err) } -func checkoutBranch(t *testing.T, repo *git.Repository, name string) { - branch, err := repo.LookupBranch(name, git.BranchAll) +func (r *TestRepo) Clone() *TestRepo { + dir := NewTempDir(r.t) - // If branch can't be found, let's check if it's a remote branch - if branch == nil { - branch, err = repo.LookupBranch("origin/"+name, git.BranchAll) + repo, err := CloneRepo(r.URL, dir, true) + checkFatal(r.t, err) + + url, err := ParseURL("file://" + dir) + checkFatal(r.t, err) + + return &TestRepo{ + Repo: repo.repo, + Path: dir, + URL: url, + t: r.t, + } +} + +func (r *TestRepo) Fetch() { + repo := &Repo{ + repo: r.Repo, + } + + err := repo.Fetch() + checkFatal(r.t, err) +} + +func checkFatal(t *testing.T, err error) { + if err != nil { + t.Fatalf("%+v", err) } - checkFatal(t, errors.Wrap(err, "Failed looking up branch")) - - // If branch is remote, we need to create a local one first - if branch.IsRemote() { - commit, err := repo.LookupCommit(branch.Target()) - checkFatal(t, errors.Wrap(err, "Failed looking up commit")) - - localBranch, err := repo.CreateBranch(name, commit, false) - checkFatal(t, errors.Wrap(err, "Failed creating local branch")) - - err = localBranch.SetUpstream("origin/" + name) - checkFatal(t, errors.Wrap(err, "Failed setting upstream")) - } - - err = repo.SetHead("refs/heads/" + name) - checkFatal(t, errors.Wrap(err, "Failed setting head")) - - options := &git.CheckoutOpts{ - Strategy: git.CheckoutForce, - } - err = repo.CheckoutHead(options) - checkFatal(t, errors.Wrap(err, "Failed checking out tree")) } diff --git a/pkg/repo.go b/pkg/repo.go index 42e6702..0cc99c8 100644 --- a/pkg/repo.go +++ b/pkg/repo.go @@ -1,14 +1,13 @@ package pkg import ( - urlpkg "net/url" + "io" + "net/url" "os" - "path" - "github.com/mitchellh/go-homedir" "github.com/pkg/errors" - git "github.com/libgit2/git2go/v30" + "github.com/go-git/go-git/v5" ) type Repo struct { @@ -16,112 +15,62 @@ type Repo struct { Status *RepoStatus } -func credentialsCallback(url string, username string, allowedTypes git.CredType) (*git.Cred, error) { - home, err := homedir.Dir() - if err != nil { - return nil, errors.Wrap(err, "Failed getting user home directory") +func CloneRepo(url *url.URL, path string, quiet bool) (r *Repo, err error) { + var output io.Writer + if !quiet { + output = os.Stdout } - // TODO: Add option to provide custom path - publicKey := path.Join(home, ".ssh", "id_rsa.pub") - privateKey := path.Join(home, ".ssh", "id_rsa") - - cred, err := git.NewCredSshKey(username, publicKey, privateKey, "") - if err != nil { - return nil, errors.Wrap(err, "Failed getting SSH credentials") + opts := &git.CloneOptions{ + URL: url.String(), + Auth: nil, + RemoteName: git.DefaultRemoteName, + ReferenceName: "", + SingleBranch: false, + NoCheckout: false, + Depth: 0, + RecurseSubmodules: git.NoRecurseSubmodules, + Progress: output, + Tags: git.AllTags, } - return cred, err + + repo, err := git.PlainClone(path, false, opts) + if err != nil { + return nil, errors.Wrap(err, "Failed cloning repo") + } + + return newRepo(repo), nil } -func certificateCheckCallback(cert *git.Certificate, valid bool, hostname string) git.ErrorCode { - // TODO: check the certificate - return 0 -} - -func CloneRepo(url *urlpkg.URL, repoRoot string) (path string, err error) { - repoPath := URLToPath(url) - - path, err = MakeDir(repoRoot, repoPath) - if err != nil { - return path, err - } - - options := &git.CloneOptions{ - Bare: false, - CheckoutBranch: "", - FetchOptions: &git.FetchOptions{ - RemoteCallbacks: git.RemoteCallbacks{ - CredentialsCallback: credentialsCallback, - CertificateCheckCallback: certificateCheckCallback, - }, - }, - } - - _, err = git.Clone(url.String(), path, options) - if err != nil { - _ = os.RemoveAll(path) - - return path, errors.Wrap(err, "Failed cloning repo") - } - return path, nil -} - -func OpenRepo(path string) (*Repo, error) { - r, err := git.OpenRepository(path) +func OpenRepo(path string) (r *Repo, err error) { + repo, err := git.PlainOpen(path) if err != nil { return nil, errors.Wrap(err, "Failed opening repo") } - repoStatus, err := loadStatus(r) - if err != nil { - return nil, err - } - - repo := &Repo{ - repo: r, - Status: repoStatus, - } - - return repo, nil + return newRepo(repo), nil } -func (r *Repo) Reload() error { - status, err := loadStatus(r.repo) - if err != nil { - return err +func newRepo(repo *git.Repository) *Repo { + return &Repo{ + repo: repo, + Status: &RepoStatus{}, } - - r.Status = status - return nil } +// Fetch performs a git fetch on all remotes func (r *Repo) Fetch() error { - remoteNames, err := r.repo.Remotes.List() + remotes, err := r.repo.Remotes() if err != nil { - return errors.Wrap(err, "Failed listing remoteNames") + return errors.Wrap(err, "Failed getting remotes") } - for _, name := range remoteNames { - remote, err := r.repo.Remotes.Lookup(name) + for _, remote := range remotes { + err = remote.Fetch(&git.FetchOptions{}) if err != nil { - return errors.Wrap(err, "Failed looking up remote") - } - - err = remote.Fetch(nil, nil, "") - if err != nil { - return errors.Wrap(err, "Failed fetching remote") + return errors.Wrapf(err, "Failed fetching remote %s", remote.Config().Name) } } return nil } - -func MakeDir(repoRoot, repoPath string) (string, error) { - dir := path.Join(repoRoot, repoPath) - err := os.MkdirAll(dir, 0775) - if err != nil { - return "", errors.Wrap(err, "Failed creating repo directory") - } - - return dir, nil -} diff --git a/pkg/repo_test.go b/pkg/repo_test.go deleted file mode 100644 index 64aafda..0000000 --- a/pkg/repo_test.go +++ /dev/null @@ -1,66 +0,0 @@ -package pkg - -import ( - urlpkg "net/url" - "os" - "testing" -) - -func TestFetch(t *testing.T) { - // Create origin repo with a single commit in master - origin := newTestRepo(t) - createFile(t, origin, "file") - stageFile(t, origin, "file") - createCommit(t, origin, "Initial commit") - - // Clone the origin repo - repoRoot := newTempDir(t) - url, err := urlpkg.Parse(origin.Path()) - checkFatal(t, err) - path, err := CloneRepo(url, repoRoot) - checkFatal(t, err) - - // Open cloned repo and load its status - repo, err := OpenRepo(path) - checkFatal(t, err) - - // Check cloned status. It should not be behind origin - if repo.Status.Branches["master"].Behind != 0 { - t.Errorf("Master should not be behind") - } - - // Add another commit to origin - createFile(t, origin, "anotherFile") - stageFile(t, origin, "anotherFile") - createCommit(t, origin, "Second commit") - - // Fetch cloned repo and check the status again - err = repo.Fetch() - checkFatal(t, err) - err = repo.Reload() - checkFatal(t, err) - - // Cloned master should now be 1 commit behind origin - if repo.Status.Branches["master"].Behind != 1 { - t.Errorf("Master should be 1 commit behind") - } - - if repo.Status.Branches["master"].Ahead != 0 { - t.Errorf("Master should not be ahead") - } -} - -func TestMakeDir(t *testing.T) { - repoRoot := newTempDir(t) - repoPath := "github.com/grdl/git-get" - - dir, err := MakeDir(repoRoot, repoPath) - checkFatal(t, err) - - stat, err := os.Stat(dir) - checkFatal(t, err) - - if !stat.IsDir() { - t.Errorf("Path is not a directory: %s", dir) - } -} diff --git a/pkg/status.go b/pkg/status.go index fdecd15..27b2e91 100644 --- a/pkg/status.go +++ b/pkg/status.go @@ -1,153 +1,215 @@ package pkg import ( - git "github.com/libgit2/git2go/v30" + "sort" + "strings" + + "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing" "github.com/pkg/errors" ) type RepoStatus struct { HasUntrackedFiles bool HasUncommittedChanges bool - Branches map[string]BranchStatus + Branches []*BranchStatus } type BranchStatus struct { - Name string - IsRemote bool - HasUpstream bool - NeedsPull bool - NeedsPush bool - Ahead int - Behind int + Name string + Upstream string + NeedsPull bool + NeedsPush bool } -func loadStatus(r *git.Repository) (*RepoStatus, error) { - entries, err := statusEntries(r) +func (r *Repo) LoadStatus() error { + wt, err := r.repo.Worktree() if err != nil { - return nil, err + return errors.Wrap(err, "Failed getting worktree") } - branches, err := branches(r) + status, err := wt.Status() if err != nil { - return nil, err + return errors.Wrap(err, "Failed getting worktree status") } - status := &RepoStatus{ - Branches: branches, + r.Status.HasUncommittedChanges = hasUncommitted(status) + r.Status.HasUntrackedFiles = hasUntracked(status) + + err = r.loadBranchesStatus() + if err != nil { + return err } - for _, entry := range entries { - switch entry.Status { - case git.StatusWtNew: - status.HasUntrackedFiles = true - case git.StatusIndexNew: - status.HasUncommittedChanges = true + return nil +} + +// hasUntracked returns true if there are any untracked files in the worktree +func hasUntracked(status git.Status) bool { + for _, fs := range status { + if fs.Worktree == git.Untracked || fs.Staging == git.Untracked { + return true } } - return status, nil + return false } -func statusEntries(r *git.Repository) ([]git.StatusEntry, error) { - opts := &git.StatusOptions{ - Show: git.StatusShowIndexAndWorkdir, - Flags: git.StatusOptIncludeUntracked, +// hasUncommitted returns true if there are any uncommitted (but tracked) files in the worktree +func hasUncommitted(status git.Status) bool { + // If repo is clean it means every file in worktree and staging has 'Unmodified' state + if status.IsClean() { + return false } - status, err := r.StatusList(opts) + // If repo is not clean, check if any file has state different than 'Untracked' - it means they are tracked and have uncommitted modifications + for _, fs := range status { + if fs.Worktree != git.Untracked || fs.Staging != git.Untracked { + return true + } + } + + return false +} + +func (r *Repo) loadBranchesStatus() error { + iter, err := r.repo.Branches() if err != nil { - return nil, errors.Wrap(err, "Failed getting repository status list") + return errors.Wrap(err, "Failed getting branches iterator") } - entryCount, err := status.EntryCount() - if err != nil { - return nil, errors.Wrap(err, "Failed getting repository status list count") - } - - var entries []git.StatusEntry - for i := 0; i < entryCount; i++ { - entry, err := status.ByIndex(i) + err = iter.ForEach(func(reference *plumbing.Reference) error { + bs, err := r.newBranchStatus(reference.Name().Short()) if err != nil { - return nil, errors.Wrap(err, "Failed getting repository status entry") + return err } - entries = append(entries, entry) - } - - return entries, nil -} - -func branches(r *git.Repository) (map[string]BranchStatus, error) { - iter, err := r.NewBranchIterator(git.BranchAll) - if err != nil { - return nil, errors.Wrap(err, "Failed creating branch iterator") - } - - var branches []*git.Branch - err = iter.ForEach(func(branch *git.Branch, branchType git.BranchType) error { - branches = append(branches, branch) + r.Status.Branches = append(r.Status.Branches, bs) return nil }) - if err != nil { - return nil, errors.Wrap(err, "Failed iterating over branches") + return errors.Wrap(err, "Failed iterating over branches") } - statuses := make(map[string]BranchStatus) - for _, branch := range branches { - status, err := branchStatus(branch) - if err != nil { - // TODO: Handle error. We should tell user that we couldn't read status of that branch but probably shouldn't exit - continue - } - statuses[status.Name] = status - } - - return statuses, nil + // Sort branches by name. It's useful to have them sorted for printing and testing. + sort.Slice(r.Status.Branches, func(i, j int) bool { + return strings.Compare(r.Status.Branches[i].Name, r.Status.Branches[j].Name) < 0 + }) + return nil } -func branchStatus(branch *git.Branch) (BranchStatus, error) { - var status BranchStatus +func (r *Repo) newBranchStatus(branch string) (*BranchStatus, error) { + bs := &BranchStatus{ + Name: branch, + } - name, err := branch.Name() + upstream, err := r.upstream(branch) if err != nil { - return status, errors.Wrap(err, "Failed getting branch name") - } - status.Name = name - - // If branch is a remote one, return immediately. Upstream can only be found for local branches. - if branch.IsRemote() { - status.IsRemote = true - return status, nil + return nil, err } - upstream, err := branch.Upstream() - if err != nil && !git.IsErrorCode(err, git.ErrNotFound) { - return status, errors.Wrap(err, "Failed getting branch upstream") + if upstream == "" { + return bs, nil } - // If there's no upstream, return immediately. Ahead/Behind can only be found when upstream exists. - if upstream == nil { - return status, nil - } - - status.HasUpstream = true - - ahead, behind, err := branch.Owner().AheadBehind(branch.Target(), upstream.Target()) + needsPull, needsPush, err := r.needsPullOrPush(branch, upstream) if err != nil { - return status, errors.Wrap(err, "Failed getting ahead/behind information") + return nil, err } - status.Ahead = ahead - status.Behind = behind + bs.Upstream = upstream + bs.NeedsPush = needsPush + bs.NeedsPull = needsPull - if ahead > 0 { - status.NeedsPush = true - } - - if behind > 0 { - status.NeedsPull = true - } - - return status, nil + return bs, nil +} + +// upstream finds if a given branch tracks an upstream. +// Returns found upstream branch name (eg, origin/master) or empty string if branch doesn't track an upstream. +// +// Information about upstream is taken from .git/config file. +// If a branch has an upstream, there's a [branch] section in the file with two fields: +// "remote" - name of the remote containing upstreamn branch (or "." if upstream is a local branch) +// "merge" - full ref name of the upstream branch (eg, ref/heads/master) +func (r *Repo) upstream(branch string) (string, error) { + cfg, err := r.repo.Config() + if err != nil { + return "", errors.Wrap(err, "Failed getting repo config") + } + + // Check if our branch exists in "branch" config sections. If not, it doesn't have an upstream configured. + bcfg := cfg.Branches[branch] + if bcfg == nil { + return "", nil + } + + remote := bcfg.Remote + if remote == "" { + return "", nil + } + + merge := bcfg.Merge.Short() + if merge == "" { + return "", nil + } + return remote + "/" + merge, nil +} + +func (r *Repo) needsPullOrPush(localBranch string, upstreamBranch string) (needsPull bool, needsPush bool, err error) { + localHash, err := r.repo.ResolveRevision(plumbing.Revision(localBranch)) + if err != nil { + return false, false, errors.Wrapf(err, "Failed resolving revision %s", localBranch) + } + + upstreamHash, err := r.repo.ResolveRevision(plumbing.Revision(upstreamBranch)) + if err != nil { + return false, false, errors.Wrapf(err, "Failed resolving revision %s", upstreamBranch) + } + + localCommit, err := r.repo.CommitObject(*localHash) + if err != nil { + return false, false, errors.Wrapf(err, "Failed finding a commit for hash %s", localHash.String()) + } + + upstreamCommit, err := r.repo.CommitObject(*upstreamHash) + if err != nil { + return false, false, errors.Wrapf(err, "Failed finding a commit for hash %s", upstreamHash.String()) + } + + // If local branch hash is the same as upstream, it means there is no difference between local and upstream + if *localHash == *upstreamHash { + return false, false, nil + } + + commons, err := localCommit.MergeBase(upstreamCommit) + if err != nil { + return false, false, errors.Wrapf(err, "Failed finding common ancestors for branches %s & %s", localBranch, upstreamBranch) + } + + if len(commons) == 0 { + // TODO: No common ancestors. This should be an error + return false, false, nil + } + + if len(commons) > 1 { + // TODO: multiple best ancestors. How to handle this? + return false, false, nil + } + + mergeBase := commons[0] + + // If merge base is the same as upstream branch, local branch is ahead and push is needed + // If merge base is the same as local branch, local branch is behind and pull is needed + // If merge base is something else, branches have diverged and merge is needed (both pull and push) + // ref: https://stackoverflow.com/a/17723781/1085632 + + if mergeBase.Hash == *upstreamHash { + return false, true, nil + } + + if mergeBase.Hash == *localHash { + return true, false, nil + } + + return true, true, nil } diff --git a/pkg/status_test.go b/pkg/status_test.go index 508fc9a..031a38a 100644 --- a/pkg/status_test.go +++ b/pkg/status_test.go @@ -1,274 +1,149 @@ package pkg import ( - urlpkg "net/url" "reflect" "testing" - - git "github.com/libgit2/git2go/v30" ) -func TestStatusWithEmptyRepo(t *testing.T) { - repo := newTestRepo(t) - - entries, err := statusEntries(repo) - checkFatal(t, err) - - if len(entries) != 0 { - t.Errorf("Empty repo should have no status entries") - } - - status, err := loadStatus(repo) - checkFatal(t, err) - - want := &RepoStatus{ - HasUntrackedFiles: false, - HasUncommittedChanges: false, - Branches: status.Branches, - } - - if !reflect.DeepEqual(status, want) { - t.Errorf("Wrong repo status, got %+v; want %+v", status, want) - } -} - -func TestStatusWithUntrackedFile(t *testing.T) { - repo := newTestRepo(t) - createFile(t, repo, "SomeFile") - - entries, err := statusEntries(repo) - checkFatal(t, err) - - if len(entries) != 1 { - t.Errorf("Repo with untracked file should have only one status entry") - } - - if entries[0].Status != git.StatusWtNew { - t.Errorf("Invalid status, got %d; want %d", entries[0].Status, git.StatusWtNew) - } - - status, err := loadStatus(repo) - checkFatal(t, err) - - want := &RepoStatus{ - HasUntrackedFiles: true, - HasUncommittedChanges: false, - Branches: status.Branches, - } - - if !reflect.DeepEqual(status, want) { - t.Errorf("Wrong repo status, got %+v; want %+v", status, want) - } -} - -func TestStatusWithUnstagedFile(t *testing.T) { - //todo -} - -func TestStatusWithUntrackedButIgnoredFile(t *testing.T) { - //todo -} - -func TestStatusWithStagedFile(t *testing.T) { - repo := newTestRepo(t) - createFile(t, repo, "SomeFile") - stageFile(t, repo, "SomeFile") - - entries, err := statusEntries(repo) - checkFatal(t, err) - - if len(entries) != 1 { - t.Errorf("Repo with staged file should have only one status entry") - } - - if entries[0].Status != git.StatusIndexNew { - t.Errorf("Invalid status, got %d; want %d", entries[0].Status, git.StatusIndexNew) - } - - status, err := loadStatus(repo) - checkFatal(t, err) - - want := &RepoStatus{ - HasUntrackedFiles: false, - HasUncommittedChanges: true, - Branches: status.Branches, - } - - if !reflect.DeepEqual(status, want) { - t.Errorf("Wrong repo status, got %+v; want %+v", status, want) - } -} - -func TestStatusWithSingleCommit(t *testing.T) { - repo := newTestRepo(t) - createFile(t, repo, "SomeFile") - stageFile(t, repo, "SomeFile") - createCommit(t, repo, "Initial commit") - - entries, err := statusEntries(repo) - checkFatal(t, err) - - if len(entries) != 0 { - t.Errorf("Repo with no uncommitted files should have no status entries") - } - - status, err := loadStatus(repo) - checkFatal(t, err) - - want := &RepoStatus{ - HasUntrackedFiles: false, - HasUncommittedChanges: false, - Branches: status.Branches, - } - - if !reflect.DeepEqual(status, want) { - t.Errorf("Wrong repo status, got %+v; want %+v", status, want) - } -} - -func TestStatusWithMultipleCommits(t *testing.T) { - repo := newTestRepo(t) - createFile(t, repo, "SomeFile") - stageFile(t, repo, "SomeFile") - createCommit(t, repo, "Initial commit") - createFile(t, repo, "AnotherFile") - stageFile(t, repo, "AnotherFile") - createCommit(t, repo, "Second commit") - - entries, err := statusEntries(repo) - checkFatal(t, err) - - if len(entries) != 0 { - t.Errorf("Repo with no uncommitted files should have no status entries") - } - - status, err := loadStatus(repo) - checkFatal(t, err) - - want := &RepoStatus{ - HasUntrackedFiles: false, - HasUncommittedChanges: false, - Branches: status.Branches, - } - - if !reflect.DeepEqual(status, want) { - t.Errorf("Wrong repo status, got %+v; want %+v", status, want) - } -} - -func TestStatusCloned(t *testing.T) { - origin := newTestRepo(t) - repoRoot := newTempDir(t) - - url, err := urlpkg.Parse(origin.Path()) - checkFatal(t, err) - path, err := CloneRepo(url, repoRoot) - checkFatal(t, err) - repo, err := OpenRepo(path) - checkFatal(t, err) - - status, err := loadStatus(repo.repo) - checkFatal(t, err) - - want := &RepoStatus{ - HasUntrackedFiles: false, - HasUncommittedChanges: false, - Branches: status.Branches, - } - - if !reflect.DeepEqual(status, want) { - t.Errorf("Wrong repo status, got %+v; want %+v", status, want) - } -} - -func TestBranchNewLocal(t *testing.T) { - repo := newTestRepo(t) - - createFile(t, repo, "file") - stageFile(t, repo, "file") - createCommit(t, repo, "Initial commit") - branch := createBranch(t, repo, "branch") - - status, err := branchStatus(branch) - checkFatal(t, err) - - want := BranchStatus{ - Name: "branch", - IsRemote: false, - HasUpstream: false, - NeedsPull: false, - NeedsPush: false, - Ahead: 0, - Behind: 0, - } - - if status != want { - t.Errorf("Wrong branch status, got %+v; want %+v", status, want) - } -} - -func TestBranchCloned(t *testing.T) { - origin := newTestRepo(t) - createFile(t, origin, "file") - stageFile(t, origin, "file") - createCommit(t, origin, "Initial commit") - - createBranch(t, origin, "branch") - - repoRoot := newTempDir(t) - url, err := urlpkg.Parse(origin.Path()) - checkFatal(t, err) - path, err := CloneRepo(url, repoRoot) - checkFatal(t, err) - repo, err := OpenRepo(path) - checkFatal(t, err) - - createBranch(t, repo.repo, "local") - - checkoutBranch(t, repo.repo, "branch") - createFile(t, repo.repo, "anotherFile") - stageFile(t, repo.repo, "anotherFile") - createCommit(t, repo.repo, "Second commit") - - err = repo.Reload() - checkFatal(t, err) - +func TestStatus(t *testing.T) { var tests = []struct { - got BranchStatus - want BranchStatus + makeTestRepo func(*testing.T) *TestRepo + want *RepoStatus }{ - {repo.Status.Branches["master"], BranchStatus{ - Name: "master", - IsRemote: false, - HasUpstream: true, + {NewRepoEmpty, &RepoStatus{ + HasUntrackedFiles: false, + HasUncommittedChanges: false, + Branches: nil, }}, - {repo.Status.Branches["origin/master"], BranchStatus{ - Name: "origin/master", - IsRemote: true, - HasUpstream: false, + {NewRepoWithUntracked, &RepoStatus{ + HasUntrackedFiles: true, + HasUncommittedChanges: false, + Branches: nil, }}, - {repo.Status.Branches["branch"], BranchStatus{ - Name: "branch", - IsRemote: false, - HasUpstream: true, - Ahead: 1, - NeedsPush: true, + {NewRepoWithStaged, &RepoStatus{ + HasUntrackedFiles: false, + HasUncommittedChanges: true, + Branches: nil, }}, - {repo.Status.Branches["origin/branch"], BranchStatus{ - Name: "origin/branch", - IsRemote: true, - HasUpstream: false, + {NewRepoWithCommit, &RepoStatus{ + HasUntrackedFiles: false, + HasUncommittedChanges: false, + Branches: []*BranchStatus{ + { + Name: "master", + Upstream: "", + NeedsPull: false, + NeedsPush: false, + }, + }, }}, - {repo.Status.Branches["local"], BranchStatus{ - Name: "local", - IsRemote: false, - HasUpstream: false, + {NewRepoWithModified, &RepoStatus{ + HasUntrackedFiles: false, + HasUncommittedChanges: true, + Branches: []*BranchStatus{ + { + Name: "master", + Upstream: "", + NeedsPull: false, + NeedsPush: false, + }, + }, + }}, + {NewRepoWithIgnored, &RepoStatus{ + HasUntrackedFiles: false, + HasUncommittedChanges: false, + Branches: []*BranchStatus{ + { + Name: "master", + Upstream: "", + NeedsPull: false, + NeedsPush: false, + }, + }, + }}, + {NewRepoWithLocalBranch, &RepoStatus{ + HasUntrackedFiles: false, + HasUncommittedChanges: false, + Branches: []*BranchStatus{ + { + Name: "local", + Upstream: "", + NeedsPull: false, + NeedsPush: false, + }, { + Name: "master", + Upstream: "", + NeedsPull: false, + NeedsPush: false, + }, + }, + }}, + {NewRepoWithClonedBranch, &RepoStatus{ + HasUntrackedFiles: false, + HasUncommittedChanges: false, + Branches: []*BranchStatus{ + { + Name: "local", + Upstream: "", + NeedsPull: false, + NeedsPush: false, + }, { + Name: "master", + Upstream: "origin/master", + NeedsPull: false, + NeedsPush: false, + }, + }, + }}, + {NewRepoWithBranchAhead, &RepoStatus{ + HasUntrackedFiles: false, + HasUncommittedChanges: false, + Branches: []*BranchStatus{ + { + Name: "master", + Upstream: "origin/master", + NeedsPull: false, + NeedsPush: true, + }, + }, + }}, + {NewRepoWithBranchBehind, &RepoStatus{ + HasUntrackedFiles: false, + HasUncommittedChanges: false, + Branches: []*BranchStatus{ + { + Name: "master", + Upstream: "origin/master", + NeedsPull: true, + NeedsPush: false, + }, + }, + }}, + {NewRepoWithBranchAheadAndBehind, &RepoStatus{ + HasUntrackedFiles: false, + HasUncommittedChanges: false, + Branches: []*BranchStatus{ + { + Name: "master", + Upstream: "origin/master", + NeedsPull: true, + NeedsPush: true, + }, + }, }}, } for _, test := range tests { - if !reflect.DeepEqual(test.got, test.want) { - t.Errorf("Wrong branch status, got %+v; want %+v", test.got, test.want) + tr := test.makeTestRepo(t) + + repo, err := OpenRepo(tr.Path) + checkFatal(t, err) + + err = repo.LoadStatus() + checkFatal(t, err) + + if !reflect.DeepEqual(repo.Status, test.want) { + t.Errorf("Wrong repo status, got: %+v; want: %+v", repo.Status, test.want) } } } diff --git a/pkg/url.go b/pkg/url.go index 6506678..16b0c19 100644 --- a/pkg/url.go +++ b/pkg/url.go @@ -14,7 +14,7 @@ import ( var scpSyntax = regexp.MustCompile(`^([a-zA-Z0-9_]+)@([a-zA-Z0-9._-]+):(.*)$`) func ParseURL(rawURL string) (url *urlpkg.URL, err error) { - // If rawURL matches the SCP-like syntax, convert it into a standard ssh URL. + // If rawURL matches the SCP-like syntax, convert it into a standard ssh Path. // eg, git@github.com:user/repo => ssh://git@github.com/user/repo if m := scpSyntax.FindStringSubmatch(rawURL); m != nil { url = &urlpkg.URL{ @@ -26,12 +26,12 @@ func ParseURL(rawURL string) (url *urlpkg.URL, err error) { } else { url, err = urlpkg.Parse(rawURL) if err != nil { - return nil, errors.Wrap(err, "Failed parsing URL") + return nil, errors.Wrap(err, "Failed parsing Path") } } if url.Host == "" && url.Path == "" { - return nil, errors.New("Parsed URL is empty") + return nil, errors.New("Parsed Path is empty") } if url.Scheme == "git+ssh" { diff --git a/pkg/url_test.go b/pkg/url_test.go index adf29e4..85cf2f2 100644 --- a/pkg/url_test.go +++ b/pkg/url_test.go @@ -51,13 +51,13 @@ func TestURLParse(t *testing.T) { for _, test := range tests { url, err := ParseURL(test.in) if err != nil { - t.Errorf("Error parsing URL: %+v", err) + t.Errorf("Error parsing Path: %+v", err) } got := URLToPath(url) if got != test.want { - t.Errorf("Wrong result of parsing URL: %s, got: %s; want: %s", test.in, got, test.want) + t.Errorf("Wrong result of parsing Path: %s, got: %s; want: %s", test.in, got, test.want) } } } @@ -65,17 +65,17 @@ func TestURLParse(t *testing.T) { func TestInvalidURLParse(t *testing.T) { invalidURLs := []string{ "", - //TODO: This URL is technically a correct scp-like syntax. Not sure how to handle it + //TODO: This Path is technically a correct scp-like syntax. Not sure how to handle it "github.com:grdl/git-git.get.git", - //TODO: Is this a valid git URL? + //TODO: Is this a valid git Path? //"git@github.com:1234:grdl/git-get.git", } for _, in := range invalidURLs { got, err := ParseURL(in) if err == nil { - t.Errorf("Wrong result of parsing invalid URL: %s, got: %s, want: error", in, got) + t.Errorf("Wrong result of parsing invalid Path: %s, got: %s, want: error", in, got) } } }