diff --git a/go.mod b/go.mod index fa67506..2999160 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,6 @@ go 1.14 require ( github.com/go-git/go-billy/v5 v5.0.0 github.com/go-git/go-git/v5 v5.1.0 - github.com/libgit2/git2go/v30 v30.0.3 github.com/mitchellh/go-homedir v1.1.0 github.com/pkg/errors v0.9.1 github.com/spf13/cobra v1.0.0 diff --git a/new/helpers_test.go b/new/helpers_test.go index c6038f0..7633e78 100644 --- a/new/helpers_test.go +++ b/new/helpers_test.go @@ -2,11 +2,13 @@ package new import ( "io/ioutil" - urlpkg "net/url" + 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" @@ -15,7 +17,8 @@ import ( type TestRepo struct { Repo *git.Repository - URL *urlpkg.URL + Path string + URL *pkgurl.URL t *testing.T } @@ -25,11 +28,12 @@ func NewRepoEmpty(t *testing.T) *TestRepo { repo, err := git.PlainInit(dir, false) checkFatal(t, err) - url, err := urlpkg.Parse("file://" + dir) + url, err := ParseURL("file://" + dir) checkFatal(t, err) return &TestRepo{ Repo: repo, + Path: dir, URL: url, t: t, } @@ -108,20 +112,33 @@ func (r *TestRepo) NewCommit(msg string) { 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")) -// -// commit, err := repo.LookupCommit(head.Target()) -// checkFatal(t, errors.Wrap(err, "Failed getting commit id from head")) -// -// branch, err := repo.CreateBranch(name, commit, false) -// checkFatal(t, errors.Wrap(err, "Failed creating branch")) -// -// return branch -//} -// +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 checkoutBranch(t *testing.T, repo *git.Repository, name string) { // branch, err := repo.LookupBranch(name, git.BranchAll) // diff --git a/new/repo.go b/new/repo.go index 06ba858..3a797f9 100644 --- a/new/repo.go +++ b/new/repo.go @@ -39,8 +39,25 @@ func CloneRepo(url *url.URL, path string, quiet bool) (r *Repo, err error) { return nil, errors.Wrap(err, "Failed cloning repo") } - r = &Repo{ - repo: repo, - } - return r, nil + 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{ + HasUntrackedFiles: false, + HasUncommittedChanges: false, + Branches: make(map[string]*BranchStatus), + }, + } } diff --git a/new/repo_test.go b/new/repo_test.go index 2d3c8ad..99c8d83 100644 --- a/new/repo_test.go +++ b/new/repo_test.go @@ -6,8 +6,8 @@ import ( func TestRepoClone(t *testing.T) { origin := NewRepoWithCommit(t) - path := NewTempDir(t) + path := NewTempDir(t) repo, err := CloneRepo(origin.URL, path, true) checkFatal(t, err) diff --git a/new/status.go b/new/status.go index 78751ae..d3aff5a 100644 --- a/new/status.go +++ b/new/status.go @@ -2,23 +2,28 @@ package new import ( "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 map[string]*BranchStatus } type BranchStatus struct { - Name string - IsRemote bool - HasUpstream bool - NeedsPull bool - NeedsPush bool - Ahead int - Behind int + Name string + Upstream *Upstream + NeedsPull bool + NeedsPush bool + Ahead int + Behind int +} + +type Upstream struct { + Remote string + Branch string } func (r *Repo) LoadStatus() error { @@ -34,6 +39,12 @@ func (r *Repo) LoadStatus() error { r.Status.HasUncommittedChanges = !status.IsClean() r.Status.HasUntrackedFiles = hasUntracked(status) + + err = r.LoadBranchesStatus() + if err != nil { + return err + } + return nil } @@ -47,3 +58,73 @@ func hasUntracked(status git.Status) bool { 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[bs.Name] = bs + return nil + }) + if err != nil { + return errors.Wrap(err, "Failed iterating over branches") + } + + return nil +} + +func (r *Repo) newBranchStatus(branch string) (*BranchStatus, error) { + upstream, err := r.upstream(branch) + if err != nil { + return nil, err + } + + return &BranchStatus{ + Name: branch, + Upstream: upstream, + }, nil +} + +// upstream finds if a given branch tracks an upstream. +// Returns found upstream or nil 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) (*Upstream, error) { + cfg, err := r.repo.Config() + if err != nil { + return nil, errors.Wrap(err, "Failed getting repo config") + } + + // Find our branch in "branch" config sections + bcfg := cfg.Branches[branch] + if bcfg == nil { + return nil, nil + } + + remote := bcfg.Remote + if remote == "" { + return nil, nil + } + + // TODO: check if this should be short or full ref name + merge := bcfg.Merge.Short() + if merge == "" { + return nil, nil + } + + return &Upstream{ + Remote: remote, + Branch: merge, + }, nil +} diff --git a/new/status_test.go b/new/status_test.go new file mode 100644 index 0000000..a969eaa --- /dev/null +++ b/new/status_test.go @@ -0,0 +1,44 @@ +package new + +import "testing" + +func TestBranchStatusLocal(t *testing.T) { + tr := NewRepoWithCommit(t) + tr.NewBranch("branch") + + repo, err := OpenRepo(tr.Path) + checkFatal(t, err) + + err = repo.LoadStatus() + checkFatal(t, err) + + if repo.Status.Branches["master"].Upstream != nil { + t.Errorf("'master' branch should not have an upstream") + } + + if repo.Status.Branches["branch"].Upstream != nil { + t.Errorf("'branch' branch should not have an upstream") + } +} + +func TestBranchStatusCloned(t *testing.T) { + origin := NewRepoWithCommit(t) + + clone := origin.Clone() + clone.NewBranch("local") + + repo, err := OpenRepo(clone.Path) + checkFatal(t, err) + + err = repo.LoadStatus() + checkFatal(t, err) + + if repo.Status.Branches["master"].Upstream == nil { + t.Errorf("'master' branch should have an upstream") + } + + if repo.Status.Branches["local"].Upstream != nil { + t.Errorf("'local' branch should not have an upstream") + } + +} diff --git a/new/url.go b/new/url.go new file mode 100644 index 0000000..abd8d6f --- /dev/null +++ b/new/url.go @@ -0,0 +1,68 @@ +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 new file mode 100644 index 0000000..c0e538f --- /dev/null +++ b/new/url_test.go @@ -0,0 +1,81 @@ +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) + } + } +}