6
0
mirror of https://github.com/grdl/git-get.git synced 2026-02-04 17:24:49 +00:00

Remove gogit and major refactoring (#2)

* Fix typo in readme

* Reimplement all git methods without go-git

* Rename repo pkg to git, add gitconfig methods

* Improve tests for configuration reading

* Rename package file to io and move RepoFinder there

* Refactor printers

- Remove smart printer
- Decouple printers from git repos with interfaces
- Update printer functions
- Remove unnecessary flags
- Add better remote URL detection

* Update readme and go.mod

* Add author to git commit in tests

Otherwise tests will fail in CI.

* Install git before running tests and don't use cgo

* Add better error message, revert installing git

* Ensure commit message is in quotes

* Set up git config before running tests
This commit is contained in:
Grzegorz Dlugoszewski
2020-06-24 23:54:44 +02:00
committed by GitHub
parent 2ef739ea49
commit 8c132cdafa
26 changed files with 1452 additions and 1648 deletions

View File

@@ -1,3 +1,5 @@
// Package cfg provides common configuration to all commands.
// It contains config key names, default values and provides methods to read values from global gitconfig file.
package cfg
import (
@@ -5,8 +7,6 @@ import (
"path"
"strings"
"github.com/go-git/go-git/v5/config"
plumbing "github.com/go-git/go-git/v5/plumbing/format/config"
"github.com/mitchellh/go-homedir"
"github.com/spf13/viper"
)
@@ -17,29 +17,25 @@ const GitgetPrefix = "gitget"
// CLI flag keys and their default values.
const (
KeyBranch = "branch"
DefBranch = "master"
KeyDump = "dump"
KeyDefaultHost = "host"
DefDefaultHost = "github.com"
KeyFetch = "fetch"
KeyOutput = "out"
DefOutput = OutTree
KeyPrivateKey = "privateKey"
DefPrivateKey = "id_rsa"
KeyReposRoot = "root"
DefReposRoot = "repositories"
)
// Values for the --out flag.
const (
OutDump = "dump"
OutFlat = "flat"
OutSmart = "smart"
OutTree = "tree"
OutDump = "dump"
OutFlat = "flat"
OutTree = "tree"
)
// AllowedOut are allowed values for the --out flag.
var AllowedOut = []string{OutDump, OutFlat, OutSmart, OutTree}
var AllowedOut = []string{OutDump, OutFlat, OutTree}
// Version metadata set by ldflags during the build.
var (
@@ -58,79 +54,39 @@ func Version() string {
return fmt.Sprintf("%s - revision %s built at %s", version, commit[:6], date)
}
// gitconfig provides methods for looking up configiration values inside .gitconfig file
type gitconfig struct {
*config.Config
// Gitconfig represents gitconfig file
type Gitconfig interface {
Get(key string) string
}
// Init initializes viper config registry. Values are looked up in the following order: cli flag, env variable, gitconfig file, default value
// Init initializes viper config registry. Values are looked up in the following order: cli flag, env variable, gitconfig file, default value.
// Viper doesn't support gitconfig file format so it can't find missing values there automatically. They need to be specified in setMissingValues func.
//
// Because it reads the cli flags it needs to be called after the cmd.Execute().
func Init() {
func Init(cfg Gitconfig) {
viper.SetEnvPrefix(strings.ToUpper(GitgetPrefix))
viper.AutomaticEnv()
cfg := loadGitconfig()
setMissingValues(cfg)
}
// loadGitconfig loads configuration from a gitconfig file.
// We ignore errors when gitconfig file can't be found, opened or parsed. In those cases viper will provide default config values.
func loadGitconfig() *gitconfig {
// TODO: load system scope
cfg, _ := config.LoadConfig(config.GlobalScope)
return &gitconfig{
Config: cfg,
}
}
// setMissingValues checks if config values are provided by flags or env vars. If not, it tries loading them from gitconfig file.
// If that fails, the default values are used.
func setMissingValues(cfg *gitconfig) {
func setMissingValues(cfg Gitconfig) {
if isUnsetOrEmpty(KeyReposRoot) {
viper.Set(KeyReposRoot, cfg.get(KeyReposRoot, path.Join(home(), DefReposRoot)))
viper.Set(KeyReposRoot, getOrDef(cfg, KeyReposRoot, path.Join(home(), DefReposRoot)))
}
if isUnsetOrEmpty(KeyDefaultHost) {
viper.Set(KeyDefaultHost, cfg.get(KeyDefaultHost, DefDefaultHost))
}
if isUnsetOrEmpty(KeyPrivateKey) {
viper.Set(KeyPrivateKey, cfg.get(KeyPrivateKey, path.Join(home(), ".ssh", DefPrivateKey)))
viper.Set(KeyDefaultHost, getOrDef(cfg, KeyDefaultHost, DefDefaultHost))
}
}
// get looks up the value for a given key in gitconfig file.
// It returns the default value when gitconfig is missing, or it doesn't contain a gitget section,
// or if the section is empty, or if it doesn't contain a valid value for the key.
func (c *gitconfig) get(key string, def string) string {
if c == nil || c.Config == nil {
return def
func getOrDef(cfg Gitconfig, key string, def string) string {
if val := cfg.Get(key); val != "" {
return val
}
gitget := c.findGitconfigSection(GitgetPrefix)
if gitget == nil {
return def
}
opt := gitget.Option(key)
if strings.TrimSpace(opt) == "" {
return def
}
return opt
}
func (c *gitconfig) findGitconfigSection(name string) *plumbing.Section {
for _, s := range c.Raw.Sections {
if strings.ToLower(s.Name) == strings.ToLower(name) {
return s
}
}
return nil
return def
}
// home returns path to a home directory or empty string if can't be found.

View File

@@ -3,163 +3,115 @@ package cfg
import (
"fmt"
"os"
"path"
"strings"
"testing"
"github.com/go-git/go-git/v5/config"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
var (
envDefaultHost = strings.ToUpper(fmt.Sprintf("%s_%s", GitgetPrefix, KeyDefaultHost))
envReposRoot = strings.ToUpper(fmt.Sprintf("%s_%s", GitgetPrefix, KeyReposRoot))
envVarName = strings.ToUpper(fmt.Sprintf("%s_%s", GitgetPrefix, KeyDefaultHost))
fromGitconfig = "value.from.gitconfig"
fromEnv = "value.from.env"
fromFlag = "value.from.flag"
)
func newConfigWithFullGitconfig() *gitconfig {
cfg := config.NewConfig()
gitget := cfg.Raw.Section(GitgetPrefix)
gitget.AddOption(KeyReposRoot, "file.root")
gitget.AddOption(KeyDefaultHost, "file.host")
return &gitconfig{
Config: cfg,
}
}
func newConfigWithEmptyGitgetSection() *gitconfig {
cfg := config.NewConfig()
_ = cfg.Raw.Section(GitgetPrefix)
return &gitconfig{
Config: cfg,
}
}
func newConfigWithEmptyValues() *gitconfig {
cfg := config.NewConfig()
gitget := cfg.Raw.Section(GitgetPrefix)
gitget.AddOption(KeyReposRoot, "")
gitget.AddOption(KeyDefaultHost, " ")
return &gitconfig{
Config: cfg,
}
}
func newConfigWithoutGitgetSection() *gitconfig {
cfg := config.NewConfig()
return &gitconfig{
Config: cfg,
}
}
func newConfigWithEmptyGitconfig() *gitconfig {
return &gitconfig{
Config: nil,
}
}
func newConfigWithEnvVars() *gitconfig {
_ = os.Setenv(envDefaultHost, "env.host")
_ = os.Setenv(envReposRoot, "env.root")
return &gitconfig{
Config: nil,
}
}
func newConfigWithGitconfigAndEnvVars() *gitconfig {
cfg := config.NewConfig()
gitget := cfg.Raw.Section(GitgetPrefix)
gitget.AddOption(KeyReposRoot, "file.root")
gitget.AddOption(KeyDefaultHost, "file.host")
_ = os.Setenv(envDefaultHost, "env.host")
_ = os.Setenv(envReposRoot, "env.root")
return &gitconfig{
Config: cfg,
}
}
func newConfigWithEmptySectionAndEnvVars() *gitconfig {
cfg := config.NewConfig()
_ = cfg.Raw.Section(GitgetPrefix)
_ = os.Setenv(envDefaultHost, "env.host")
_ = os.Setenv(envReposRoot, "env.root")
return &gitconfig{
Config: cfg,
}
}
func newConfigWithMixed() *gitconfig {
cfg := config.NewConfig()
gitget := cfg.Raw.Section(GitgetPrefix)
gitget.AddOption(KeyReposRoot, "file.root")
gitget.AddOption(KeyDefaultHost, "file.host")
_ = os.Setenv(envDefaultHost, "env.host")
return &gitconfig{
Config: cfg,
}
}
func TestConfig(t *testing.T) {
defReposRoot := path.Join(home(), DefReposRoot)
var tests = []struct {
makeConfig func() *gitconfig
wantReposRoot string
wantDefaultHost string
tests := []struct {
name string
configMaker func(*testing.T)
key string
want string
}{
{newConfigWithFullGitconfig, "file.root", "file.host"},
{newConfigWithoutGitgetSection, defReposRoot, DefDefaultHost},
{newConfigWithEmptyGitconfig, defReposRoot, DefDefaultHost},
{newConfigWithEnvVars, "env.root", "env.host"},
{newConfigWithGitconfigAndEnvVars, "env.root", "env.host"},
{newConfigWithEmptySectionAndEnvVars, "env.root", "env.host"},
{newConfigWithEmptyGitgetSection, defReposRoot, DefDefaultHost},
{newConfigWithEmptyValues, defReposRoot, DefDefaultHost},
{newConfigWithMixed, "file.root", "env.host"},
{
name: "no config",
configMaker: testConfigEmpty,
key: KeyDefaultHost,
want: DefDefaultHost,
},
{
name: "value only in gitconfig",
configMaker: testConfigOnlyInGitconfig,
key: KeyDefaultHost,
want: fromGitconfig,
},
{
name: "value only in env var",
configMaker: testConfigOnlyInEnvVar,
key: KeyDefaultHost,
want: fromEnv,
},
{
name: "value in gitconfig and env var",
configMaker: testConfigInGitconfigAndEnvVar,
key: KeyDefaultHost,
want: fromEnv,
},
{
name: "value in flag",
configMaker: testConfigInFlag,
key: KeyDefaultHost,
want: fromFlag,
},
}
for _, test := range tests {
viper.SetEnvPrefix(strings.ToUpper(GitgetPrefix))
viper.AutomaticEnv()
t.Run(test.name, func(t *testing.T) {
test.configMaker(t)
cfg := test.makeConfig()
setMissingValues(cfg)
got := viper.GetString(test.key)
if got != test.want {
t.Errorf("expected %q; got %q", test.want, got)
}
if viper.GetString(KeyDefaultHost) != test.wantDefaultHost {
t.Errorf("Wrong %s value, got: %s; want: %s", KeyDefaultHost, viper.GetString(KeyDefaultHost), test.wantDefaultHost)
}
if viper.GetString(KeyReposRoot) != test.wantReposRoot {
t.Errorf("Wrong %s value, got: %s; want: %s", KeyReposRoot, viper.GetString(KeyReposRoot), test.wantReposRoot)
}
// Unset env variables and reset viper registry after each test
viper.Reset()
err := os.Unsetenv(envDefaultHost)
checkFatal(t, err)
err = os.Unsetenv(envReposRoot)
checkFatal(t, err)
// Clear env variables and reset viper registry after each test so they impact other tests.
os.Clearenv()
viper.Reset()
})
}
}
func checkFatal(t *testing.T, err error) {
if err != nil {
t.Fatalf("%+v", err)
}
type gitconfigEmpty struct{}
func (c *gitconfigEmpty) Get(key string) string {
return ""
}
type gitconfigValid struct{}
func (c *gitconfigValid) Get(key string) string {
return fromGitconfig
}
func testConfigEmpty(t *testing.T) {
Init(&gitconfigEmpty{})
}
func testConfigOnlyInGitconfig(t *testing.T) {
Init(&gitconfigValid{})
}
func testConfigOnlyInEnvVar(t *testing.T) {
os.Setenv(envVarName, fromEnv)
Init(&gitconfigEmpty{})
}
func testConfigInGitconfigAndEnvVar(t *testing.T) {
os.Setenv(envVarName, fromEnv)
Init(&gitconfigValid{})
}
func testConfigInFlag(t *testing.T) {
os.Setenv(envVarName, fromEnv)
cmd := cobra.Command{}
cmd.PersistentFlags().String(KeyDefaultHost, DefDefaultHost, "")
viper.BindPFlag(KeyDefaultHost, cmd.PersistentFlags().Lookup(KeyDefaultHost))
cmd.SetArgs([]string{"--" + KeyDefaultHost, fromFlag})
cmd.Execute()
Init(&gitconfigValid{})
}

View File

@@ -2,7 +2,7 @@ package pkg
import (
"fmt"
"git-get/pkg/repo"
"git-get/pkg/git"
"path"
)
@@ -37,13 +37,13 @@ func cloneSingleRepo(c *GetCfg) error {
return err
}
cloneOpts := &repo.CloneOpts{
cloneOpts := &git.CloneOpts{
URL: url,
Path: path.Join(c.Root, URLToPath(url)),
Branch: c.Branch,
}
_, err = repo.Clone(cloneOpts)
_, err = git.Clone(cloneOpts)
return err
}
@@ -60,14 +60,14 @@ func cloneDumpFile(c *GetCfg) error {
return err
}
cloneOpts := &repo.CloneOpts{
cloneOpts := &git.CloneOpts{
URL: url,
Path: path.Join(c.Root, URLToPath(url)),
Branch: line.branch,
IgnoreExisting: true,
}
_, err = repo.Clone(cloneOpts)
_, err = git.Clone(cloneOpts)
if err != nil {
return err
}

20
pkg/git/config.go Normal file
View File

@@ -0,0 +1,20 @@
package git
import "os/exec"
// 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()
// 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]
}

93
pkg/git/config_test.go Normal file
View File

@@ -0,0 +1,93 @@
package git
import (
"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
}
func newCfgStub(t *testing.T) *cfgStub {
r := testRepoEmpty(t)
return &cfgStub{
repo: r,
}
}
func (c *cfgStub) Get(key string) string {
cmd := gitCmd(c.repo.path, "config", "--local", key)
out, err := cmd.Output()
if err != nil {
return ""
}
lines := lines(out)
return lines[0]
}
func TestGitConfig(t *testing.T) {
tests := []struct {
name string
configMaker func(t *testing.T) *cfgStub
key string
want string
}{
{
name: "empty",
configMaker: makeConfigEmpty,
key: "gitget.host",
want: "",
},
{
name: "valid",
configMaker: makeConfigValid,
key: "gitget.host",
want: "github.com",
}, {
name: "only section name",
configMaker: makeConfigValid,
key: "gitget",
want: "",
}, {
name: "missing key",
configMaker: makeConfigValid,
key: "gitget.missingkey",
want: "",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
cfg := test.configMaker(t)
got := cfg.Get(test.key)
if got != test.want {
t.Errorf("expected %q; got %q", test.want, got)
}
})
}
}
func makeConfigEmpty(t *testing.T) *cfgStub {
c := newCfgStub(t)
c.repo.writeFile(".git/config", "")
return c
}
func makeConfigValid(t *testing.T) *cfgStub {
c := newCfgStub(t)
gitconfig := `
[user]
name = grdl
[gitget]
host = github.com
`
c.repo.writeFile(".git/config", gitconfig)
return c
}

245
pkg/git/repo.go Normal file
View File

@@ -0,0 +1,245 @@
package git
import (
"fmt"
"git-get/pkg/io"
"net/url"
"os"
"os/exec"
"path"
"strconv"
"strings"
"github.com/pkg/errors"
)
const (
dotgit = ".git"
untracked = "??" // Untracked files are marked as "??" in git status output.
master = "master"
head = "HEAD"
)
// Repo represents a git repository on disk.
type Repo struct {
path string
}
// CloneOpts specify detail about repository to clone.
type CloneOpts struct {
URL *url.URL
Path string // TODO: should Path be a part of clone opts?
Branch string
Quiet bool
IgnoreExisting bool
}
// Open checks if given path can be accessed and returns a Repo instance pointing to it.
func Open(path string) (*Repo, error) {
_, err := io.Exists(path)
if err != nil {
return nil, err
}
return &Repo{
path: path,
}, nil
}
// Clone clones repository specified with CloneOpts.
func Clone(opts *CloneOpts) (*Repo, error) {
// TODO: not sure if this check should be here
if opts.IgnoreExisting {
return nil, nil
}
args := []string{"clone", "--progress", "-v"}
if opts.Branch != "" {
args = append(args, "--branch", opts.Branch, "--single-branch")
}
if opts.Quiet {
args = append(args, "--quiet")
}
args = append(args, opts.URL.String())
args = append(args, opts.Path)
cmd := exec.Command("git", args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
err := cmd.Run()
if err != nil {
return nil, errors.Wrapf(err, "git clone failed")
}
repo, err := Open(opts.Path)
return repo, err
}
// Fetch preforms a git fetch on all remotes
func (r *Repo) Fetch() error {
cmd := gitCmd(r.path, "fetch", "--all", "--quiet")
return cmd.Run()
}
// Uncommitted returns the number of uncommitted files in the repository.
// Only tracked files are not counted.
func (r *Repo) Uncommitted() (int, error) {
cmd := gitCmd(r.path, "status", "--ignore-submodules", "--porcelain")
out, err := cmd.Output()
if err != nil {
return 0, cmdError(cmd, err)
}
lines := lines(out)
count := 0
for _, line := range lines {
// Don't count lines with untracked files and empty lines.
if !strings.HasPrefix(line, untracked) && strings.TrimSpace(line) != "" {
count++
}
}
return count, nil
}
// Untracked returns the number of untracked files in the repository.
func (r *Repo) Untracked() (int, error) {
cmd := gitCmd(r.path, "status", "--ignore-submodules", "--untracked-files=all", "--porcelain")
out, err := cmd.Output()
if err != nil {
return 0, cmdError(cmd, err)
}
lines := lines(out)
count := 0
for _, line := range lines {
if strings.HasPrefix(line, untracked) {
count++
}
}
return count, nil
}
// CurrentBranch returns the short name currently checked-out branch for the repository.
// If repo is in a detached head state, it will return "HEAD".
func (r *Repo) CurrentBranch() (string, error) {
cmd := gitCmd(r.path, "rev-parse", "--symbolic-full-name", "--abbrev-ref", "HEAD")
out, err := cmd.Output()
if err != nil {
return "", cmdError(cmd, err)
}
lines := lines(out)
return lines[0], nil
}
// Branches returns a list of local branches in the repository.
func (r *Repo) Branches() ([]string, error) {
cmd := gitCmd(r.path, "branch", "--format=%(refname:short)")
out, err := cmd.Output()
if err != nil {
return nil, cmdError(cmd, err)
}
lines := lines(out)
// TODO: Is detached head shown always on the first line? Maybe we don't need to iterate over everything.
// Remove the line containing detached head.
for i, line := range lines {
if strings.Contains(line, "HEAD detached") {
lines = append(lines[:i], lines[i+1:]...)
}
}
return lines, nil
}
// Upstream returns the name of an upstream branch if a given branch is tracking one.
// Otherwise it returns an empty string.
func (r *Repo) Upstream(branch string) (string, error) {
cmd := gitCmd(r.path, "rev-parse", "--abbrev-ref", "--symbolic-full-name", fmt.Sprintf("%s@{upstream}", branch))
out, err := cmd.Output()
if err != nil {
// TODO: no upstream will also throw an error.
return "", nil //cmdError(cmd, err)
}
lines := lines(out)
return lines[0], nil
}
// AheadBehind returns the number of commits a given branch is ahead and/or behind the upstream.
func (r *Repo) AheadBehind(branch string, upstream string) (int, int, error) {
cmd := gitCmd(r.path, "rev-list", "--left-right", "--count", fmt.Sprintf("%s...%s", branch, upstream))
out, err := cmd.Output()
if err != nil {
return 0, 0, cmdError(cmd, err)
}
lines := lines(out)
// rev-list --left-right --count output is separated by a tab
lr := strings.Split(lines[0], "\t")
ahead, err := strconv.Atoi(lr[0])
if err != nil {
return 0, 0, err
}
behind, err := strconv.Atoi(lr[1])
if err != nil {
return 0, 0, err
}
return ahead, behind, nil
}
// Remote returns URL of remote repository.
func (r *Repo) Remote() (string, error) {
// https://stackoverflow.com/a/16880000/1085632
cmd := gitCmd(r.path, "ls-remote", "--get-url")
out, err := cmd.Output()
if err != nil {
return "", cmdError(cmd, err)
}
lines := lines(out)
// TODO: needs testing. What happens when there are more than 1 remotes?
return lines[0], nil
}
// Path returns path to the repository.
func (r *Repo) Path() string {
return r.path
}
func gitCmd(repoPath string, args ...string) *exec.Cmd {
args = append([]string{"--work-tree", repoPath, "--git-dir", path.Join(repoPath, dotgit)}, args...)
return exec.Command("git", args...)
}
func lines(output []byte) []string {
lines := strings.TrimSuffix(string(output), "\n")
return strings.Split(lines, "\n")
}
func cmdError(cmd *exec.Cmd, err error) error {
if err != nil {
return errors.Wrapf(err, "%s failed: %+v", strings.Join(cmd.Args, " "), err) // Show which git command failed (skip "--work-tree and --gitdir flags")
}
return nil
}

View File

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

309
pkg/git/repo_test.go Normal file
View File

@@ -0,0 +1,309 @@
package git
import (
"git-get/pkg/io"
"reflect"
"testing"
)
func TestOpen(t *testing.T) {
_, err := Open("/paththatdoesnotexist/repo")
if err != io.ErrDirectoryAccess {
t.Errorf("Opening a repo in non existing path should throw an error")
}
}
func TestUncommitted(t *testing.T) {
tests := []struct {
name string
repoMaker func(*testing.T) *testRepo
want int
}{
{
name: "empty",
repoMaker: testRepoEmpty,
want: 0,
},
{
name: "single untracked",
repoMaker: testRepoWithUntracked,
want: 0,
},
{
name: "single tracked ",
repoMaker: testRepoWithStaged,
want: 1,
},
{
name: "committed",
repoMaker: testRepoWithCommit,
want: 0,
},
{
name: "untracked and uncommitted",
repoMaker: testRepoWithUncommittedAndUntracked,
want: 1,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
r := test.repoMaker(t)
got, err := r.Uncommitted()
if err != nil {
t.Errorf("got error %q", err)
}
if got != test.want {
t.Errorf("expected %d; got %d", test.want, got)
}
})
}
}
func TestUntracked(t *testing.T) {
tests := []struct {
name string
repoMaker func(*testing.T) *testRepo
want int
}{
{
name: "empty",
repoMaker: testRepoEmpty,
want: 0,
},
{
name: "single untracked",
repoMaker: testRepoWithUntracked,
want: 0,
},
{
name: "single tracked ",
repoMaker: testRepoWithStaged,
want: 1,
},
{
name: "committed",
repoMaker: testRepoWithCommit,
want: 0,
},
{
name: "untracked and uncommitted",
repoMaker: testRepoWithUncommittedAndUntracked,
want: 1,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
r := test.repoMaker(t)
got, err := r.Uncommitted()
if err != nil {
t.Errorf("got error %q", err)
}
if got != test.want {
t.Errorf("expected %d; got %d", test.want, got)
}
})
}
}
func TestCurrentBranch(t *testing.T) {
tests := []struct {
name string
repoMaker func(*testing.T) *testRepo
want string
}{
// TODO: maybe add wantErr to check if error is returned correctly?
// {
// name: "empty",
// repoMaker: newTestRepo,
// want: "",
// },
{
name: "only master branch",
repoMaker: testRepoWithCommit,
want: master,
},
{
name: "checked out new branch",
repoMaker: testRepoWithBranch,
want: "feature/branch",
},
{
name: "checked out new tag",
repoMaker: testRepoWithTag,
want: head,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
r := test.repoMaker(t)
got, err := r.CurrentBranch()
if err != nil {
t.Errorf("got error %q", err)
}
if got != test.want {
t.Errorf("expected %q; got %q", test.want, got)
}
})
}
}
func TestBranches(t *testing.T) {
tests := []struct {
name string
repoMaker func(*testing.T) *testRepo
want []string
}{
{
name: "empty",
repoMaker: testRepoEmpty,
want: []string{""},
},
{
name: "only master branch",
repoMaker: testRepoWithCommit,
want: []string{"master"},
},
{
name: "new branch",
repoMaker: testRepoWithBranch,
want: []string{"feature/branch", "master"},
},
{
name: "checked out new tag",
repoMaker: testRepoWithTag,
want: []string{"master"},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
r := test.repoMaker(t)
got, err := r.Branches()
if err != nil {
t.Errorf("got error %q", err)
}
if !reflect.DeepEqual(got, test.want) {
t.Errorf("expected %+v; got %+v", test.want, got)
}
})
}
}
func TestUpstream(t *testing.T) {
tests := []struct {
name string
repoMaker func(*testing.T) *testRepo
branch string
want string
}{
{
name: "empty",
repoMaker: testRepoEmpty,
branch: "master",
want: "",
},
// TODO: add wantErr
{
name: "wrong branch name",
repoMaker: testRepoWithCommit,
branch: "wrong_branch_name",
want: "",
},
{
name: "master with upstream",
repoMaker: testRepoWithBranchWithUpstream,
branch: "master",
want: "origin/master",
},
{
name: "branch with upstream",
repoMaker: testRepoWithBranchWithUpstream,
branch: "feature/branch",
want: "origin/feature/branch",
},
{
name: "branch without upstream",
repoMaker: testRepoWithBranchWithoutUpstream,
branch: "feature/branch",
want: "",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
r := test.repoMaker(t)
got, _ := r.Upstream(test.branch)
// TODO:
// if err != nil {
// t.Errorf("got error %q", err)
// }
if !reflect.DeepEqual(got, test.want) {
t.Errorf("expected %+v; got %+v", test.want, got)
}
})
}
}
func TestAheadBehind(t *testing.T) {
tests := []struct {
name string
repoMaker func(*testing.T) *testRepo
branch string
want []int
}{
{
name: "fresh clone",
repoMaker: testRepoWithBranchWithUpstream,
branch: "master",
want: []int{0, 0},
},
{
name: "branch ahead",
repoMaker: testRepoWithBranchAhead,
branch: "feature/branch",
want: []int{1, 0},
},
{
name: "branch behind",
repoMaker: testRepoWithBranchBehind,
branch: "feature/branch",
want: []int{0, 1},
},
{
name: "branch ahead and behind",
repoMaker: testRepoWithBranchAheadAndBehind,
branch: "feature/branch",
want: []int{2, 1},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
r := test.repoMaker(t)
upstream, err := r.Upstream(test.branch)
if err != nil {
t.Errorf("got error %q", err)
}
ahead, behind, err := r.AheadBehind(test.branch, upstream)
if err != nil {
t.Errorf("got error %q", err)
}
if ahead != test.want[0] || behind != test.want[1] {
t.Errorf("expected %+v; got [%d, %d]", test.want, ahead, behind)
}
})
}
}

121
pkg/io/io.go Normal file
View File

@@ -0,0 +1,121 @@
// Package io provides functions to read, write and search files and directories.
package io
import (
"fmt"
"io/ioutil"
"os"
"sort"
"strings"
"syscall"
"github.com/karrick/godirwalk"
"github.com/pkg/errors"
)
// ErrSkipNode is used as an error indicating that .git directory has been found.
// It's handled by ErrorsCallback to tell the WalkCallback to skip this dir.
var ErrSkipNode = errors.New(".git directory found, skipping this node")
// ErrDirectoryAccess indicated a direcotry doesn't exists or can't be accessed
var ErrDirectoryAccess = errors.New("directory doesn't exist or can't be accessed")
// TempDir creates a temporary directory for test repos.
func TempDir() (string, error) {
dir, err := ioutil.TempDir("", "git-get-repo-")
if err != nil {
return "", errors.Wrap(err, "failed creating test repo directory")
}
return dir, nil
}
// Write writes string content into a file. If file doesn't exists, it will create it.
func Write(path string, content string) error {
file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644)
if err != nil {
return errors.Wrapf(err, "failed opening a file for writing %s", path)
}
_, err = file.Write([]byte(content))
if err != nil {
errors.Wrapf(err, "Failed writing to a file %s", path)
}
return nil
}
// Exists returns true if a directory exists. If it doesn't or the directory can't be accessed it returns an error.
func Exists(path string) (bool, error) {
_, err := os.Stat(path)
if err == nil {
return true, nil
}
if err != nil {
if os.IsNotExist(err) {
return false, ErrDirectoryAccess
}
}
// Directory exists but can't be accessed
return true, ErrDirectoryAccess
}
// RepoFinder finds paths to git repos inside given path.
type RepoFinder struct {
root string
repos []string
}
// NewRepoFinder returns a RepoFinder pointed at given root path.
func NewRepoFinder(root string) *RepoFinder {
return &RepoFinder{
root: root,
}
}
// Find returns a sorted list of paths to git repos found inside a given root path.
// Returns error if root repo path can't be found or accessed.
func (r *RepoFinder) Find() ([]string, error) {
if _, err := Exists(r.root); err != nil {
return nil, err
}
walkOpts := &godirwalk.Options{
ErrorCallback: r.errorCb,
Callback: r.walkCb,
// Use Unsorted to improve speed because repos will be processed by goroutines in a random order anyway.
Unsorted: true,
}
err := godirwalk.Walk(r.root, walkOpts)
if err != nil {
return nil, err
}
if len(r.repos) == 0 {
return nil, fmt.Errorf("no git repos found in root path %s", r.root)
}
sort.Strings(r.repos)
return r.repos, nil
}
func (r *RepoFinder) walkCb(path string, ent *godirwalk.Dirent) error {
if ent.IsDir() && ent.Name() == ".git" {
r.repos = append(r.repos, strings.TrimSuffix(path, ".git"))
return ErrSkipNode
}
return nil
}
func (r *RepoFinder) errorCb(_ string, err error) godirwalk.ErrorAction {
// Skip .git directory and directories we don't have permissions to access
// TODO: Will syscall.EACCES work on windows?
if errors.Is(err, ErrSkipNode) || errors.Is(err, syscall.EACCES) {
return godirwalk.SkipNode
}
return godirwalk.Halt
}

View File

@@ -3,140 +3,72 @@ package pkg
import (
"fmt"
"git-get/pkg/cfg"
"git-get/pkg/git"
"git-get/pkg/io"
"git-get/pkg/print"
"git-get/pkg/repo"
"os"
"sort"
"strings"
"syscall"
"github.com/karrick/godirwalk"
"github.com/pkg/errors"
)
// errSkipNode is used as an error indicating that .git directory has been found.
// It's handled by ErrorsCallback to tell the WalkCallback to skip this dir.
var errSkipNode = errors.New(".git directory found, skipping this node")
var repos []string
// ListCfg provides configuration for the List command.
type ListCfg struct {
Fetch bool
Output string
PrivateKey string
Root string
Fetch bool
Output string
Root string
}
// List executes the "git list" command.
func List(c *ListCfg) error {
paths, err := findRepos(c.Root)
paths, err := io.NewRepoFinder(c.Root).Find()
if err != nil {
return err
}
repos, err := openAll(paths)
if err != nil {
return err
// TODO: we should open, fetch and read status of each repo in separate goroutine
var repos []git.Repo
for _, path := range paths {
repo, err := git.Open(path)
if err != nil {
// TODO: how should we handle it?
continue
}
if c.Fetch {
err := repo.Fetch()
if err != nil {
// TODO: handle error
}
}
repos = append(repos, *repo)
}
var printer print.Printer
switch c.Output {
case cfg.OutFlat:
printer = &print.FlatPrinter{}
printables := make([]print.Repo, len(repos))
for i := range repos {
printables[i] = &repos[i]
}
fmt.Println(print.NewFlatPrinter().Print(printables))
case cfg.OutTree:
printer = &print.TreePrinter{}
case cfg.OutSmart:
printer = &print.SmartPrinter{}
printables := make([]print.Repo, len(repos))
for i := range repos {
printables[i] = &repos[i]
}
fmt.Println(print.NewTreePrinter().Print(c.Root, printables))
case cfg.OutDump:
printer = &print.DumpPrinter{}
printables := make([]print.DumpRepo, len(repos))
for i := range repos {
printables[i] = &repos[i]
}
fmt.Println(print.NewDumpPrinter().Print(printables))
default:
return fmt.Errorf("invalid --out flag; allowed values: [%s]", strings.Join(cfg.AllowedOut, ", "))
}
fmt.Println(printer.Print(c.Root, repos))
return nil
}
func findRepos(root string) ([]string, error) {
repos = []string{}
if _, err := os.Stat(root); err != nil {
return nil, fmt.Errorf("repos root %s doesn't exist or can't be accessed", root)
}
walkOpts := &godirwalk.Options{
ErrorCallback: errorCb,
Callback: walkCb,
// Use Unsorted to improve speed because repos will be processed by goroutines in a random order anyway.
Unsorted: true,
}
err := godirwalk.Walk(root, walkOpts)
if err != nil {
return nil, err
}
if len(repos) == 0 {
return nil, fmt.Errorf("no git repos found in root path %s", root)
}
return repos, nil
}
func walkCb(path string, ent *godirwalk.Dirent) error {
if ent.IsDir() && ent.Name() == ".git" {
repos = append(repos, strings.TrimSuffix(path, ".git"))
return errSkipNode
}
return nil
}
func errorCb(_ string, err error) godirwalk.ErrorAction {
// Skip .git directory and directories we don't have permissions to access
// TODO: Will syscall.EACCES work on windows?
if errors.Is(err, errSkipNode) || errors.Is(err, syscall.EACCES) {
return godirwalk.SkipNode
}
return godirwalk.Halt
}
func openAll(paths []string) ([]*repo.Repo, error) {
var repos []*repo.Repo
reposChan := make(chan *repo.Repo)
for _, path := range paths {
go func(path string) {
repo, err := repo.Open(path)
if err != nil {
// TODO handle error
fmt.Println(err)
}
err = repo.LoadStatus()
if err != nil {
// TODO handle error
fmt.Println(err)
}
// when error happened we just sent a nil
reposChan <- repo
}(path)
}
for repo := range reposChan {
repos = append(repos, repo)
// TODO: is this the right way to close the channel? What if we have non-unique paths?
if len(repos) == len(paths) {
close(reposChan)
}
}
// sort the final array to make printing easier
sort.Slice(repos, func(i, j int) bool {
return strings.Compare(repos[i].Path, repos[j].Path) < 0
})
return repos, nil
}

View File

@@ -1,31 +1,40 @@
package print
import (
"git-get/pkg/repo"
"strings"
)
// DumpPrinter implements Printer interface and provides method for printing list of repos in dump file format.
// DumpRepo is a git repository printable into a dump file.
type DumpRepo interface {
Path() string
Remote() (string, error)
CurrentBranch() (string, error)
}
// DumpPrinter prints a list of repos in a dump file format.
type DumpPrinter struct{}
// NewDumpPrinter creates a DumpPrinter.
func NewDumpPrinter() *DumpPrinter {
return &DumpPrinter{}
}
// Print generates a list of repos URLs. Each line contains a URL and, if applicable, a currently checked out branch name.
// It's a way to dump all repositories managed by git-get and is supposed to be consumed by `git get --dump`.
func (p *DumpPrinter) Print(_ string, repos []*repo.Repo) string {
func (p *DumpPrinter) Print(repos []DumpRepo) string {
var str strings.Builder
for i, r := range repos {
remotes, err := r.Remotes()
if err != nil || len(remotes) == 0 {
url, err := r.Remote()
if err != nil {
continue
// TODO: handle error?
}
// TODO: Needs work. Right now we're just assuming the first remote is the origin one and the one from which the current branch is checked out.
url := remotes[0].Config().URLs[0]
current := r.Status.CurrentBranch
str.WriteString(url)
if current != repo.StatusDetached && current != repo.StatusUnknown {
current, err := r.CurrentBranch()
if err != nil || current != detached {
str.WriteString(" " + current)
}

View File

@@ -2,32 +2,49 @@ package print
import (
"fmt"
"git-get/pkg/repo"
"path/filepath"
"strings"
)
// FlatPrinter implements Printer interface and provides method for printing list of repos in flat format.
// FlatPrinter prints a list of repos in a flat format.
type FlatPrinter struct{}
// NewFlatPrinter creates a FlatPrinter.
func NewFlatPrinter() *FlatPrinter {
return &FlatPrinter{}
}
// Print generates a flat list of repositories and their statuses - each repo in new line with full path.
func (p *FlatPrinter) Print(root string, repos []*repo.Repo) string {
func (p *FlatPrinter) Print(repos []Repo) string {
var str strings.Builder
for _, r := range repos {
path := strings.TrimPrefix(r.Path, root)
path = strings.Trim(path, string(filepath.Separator))
str.WriteString(fmt.Sprintf("\n%s %s", r.Path(), printCurrentBranchLine(r)))
str.WriteString(fmt.Sprintf("\n%s %s", path, printWorktreeStatus(r)))
branches, err := r.Branches()
if err != nil {
str.WriteString(printErr(err))
continue
}
for _, branch := range r.Status.Branches {
current, err := r.CurrentBranch()
if err != nil {
str.WriteString(printErr(err))
continue
}
for _, branch := range branches {
// Don't print the status of the current branch. It was already printed above.
if branch.Name == r.Status.CurrentBranch {
if branch == current {
continue
}
indent := strings.Repeat(" ", len(path))
str.WriteString(fmt.Sprintf("\n%s %s", indent, printBranchStatus(branch)))
status, err := printBranchStatus(r, branch)
if err != nil {
status = printErr(err)
}
indent := strings.Repeat(" ", len(r.Path()))
str.WriteString(fmt.Sprintf("\n%s %s %s", indent, printBranchName(branch), status))
}
}

View File

@@ -2,85 +2,147 @@ package print
import (
"fmt"
"git-get/pkg/repo"
"strings"
)
// Printer prints list of repos and their statuses
type Printer interface {
Print(root string, repos []*repo.Repo) string
}
// TODO: not sure if this works on windows. See https://github.com/mattn/go-colorable
const (
ColorRed = "\033[1;31m%s\033[0m"
ColorGreen = "\033[1;32m%s\033[0m"
ColorBlue = "\033[1;34m%s\033[0m"
ColorYellow = "\033[1;33m%s\033[0m"
colorRed = "\033[1;31m%s\033[0m"
colorGreen = "\033[1;32m%s\033[0m"
colorBlue = "\033[1;34m%s\033[0m"
colorYellow = "\033[1;33m%s\033[0m"
)
func printWorktreeStatus(r *repo.Repo) string {
clean := true
var status []string
const (
untracked = "untracked"
uncommitted = "uncommitted"
ahead = "ahead"
behind = "behind"
noUpstream = "no upstream"
ok = "ok"
detached = "detached"
head = "HEAD"
)
// if current branch status can't be found it's probably a detached head
// TODO: what if current HEAD points to a tag?
if current := r.CurrentBranchStatus(); current == nil {
status = append(status, fmt.Sprintf(ColorYellow, r.Status.CurrentBranch))
// Repo is a git repository
// TODO: maybe branch should be a separate interface
type Repo interface {
Path() string
Branches() ([]string, error)
CurrentBranch() (string, error)
Upstream(branch string) (string, error)
AheadBehind(branch string, upstream string) (int, int, error)
Uncommitted() (int, error)
Untracked() (int, error)
}
// // Printer provides a way to print a list of repos and their statuses
// type Printer interface {
// Print(root string, repos []Repo) string
// }
// prints status of currently checked out branch and the work tree.
// The format is: branch_name branch_status [ worktree_status ]
// Eg: master 1 head 2 behind [ 1 uncomitted ]
func printCurrentBranchLine(r Repo) string {
var res []string
current, err := r.CurrentBranch()
if err != nil {
return printErr(err)
}
// if current head is detached don't print its status
if current == head {
return fmt.Sprintf(colorYellow, detached)
}
status, err := printBranchStatus(r, current)
if err != nil {
return printErr(err)
}
worktree, err := printWorkTreeStatus(r)
if err != nil {
return printErr(err)
}
res = append(res, printBranchName(current))
// if worktree is not clean and branch is ok then it shouldn't be ok
if worktree != "" && strings.Contains(status, ok) {
res = append(res, worktree)
} else {
status = append(status, printBranchStatus(current))
res = append(res, status)
res = append(res, worktree)
}
// TODO: this is ugly
// unset clean flag to use it to render braces around worktree status and remove "ok" from branch status if it's there
if r.Status.HasUncommittedChanges || r.Status.HasUntrackedFiles {
clean = false
}
if !clean {
status[len(status)-1] = strings.TrimSuffix(status[len(status)-1], repo.StatusOk)
status = append(status, "[")
}
if r.Status.HasUntrackedFiles {
status = append(status, fmt.Sprintf(ColorRed, repo.StatusUntracked))
}
if r.Status.HasUncommittedChanges {
status = append(status, fmt.Sprintf(ColorRed, repo.StatusUncommitted))
}
if !clean {
status = append(status, "]")
}
return strings.Join(status, " ")
return strings.Join(res, " ")
}
func printBranchStatus(branch *repo.BranchStatus) string {
// ok indicates that the branch has upstream and is not ahead or behind it
ok := true
var status []string
status = append(status, fmt.Sprintf(ColorBlue, branch.Name))
if branch.Upstream == "" {
ok = false
status = append(status, fmt.Sprintf(ColorYellow, repo.StatusNoUpstream))
}
if branch.Behind != 0 {
ok = false
status = append(status, fmt.Sprintf(ColorYellow, fmt.Sprintf("%d %s", branch.Behind, repo.StatusBehind)))
}
if branch.Ahead != 0 {
ok = false
status = append(status, fmt.Sprintf(ColorYellow, fmt.Sprintf("%d %s", branch.Ahead, repo.StatusAhead)))
}
if ok {
status = append(status, fmt.Sprintf(ColorGreen, repo.StatusOk))
}
return strings.Join(status, " ")
func printBranchName(branch string) string {
return fmt.Sprintf(colorBlue, branch)
}
func printBranchStatus(r Repo, branch string) (string, error) {
var res []string
upstream, err := r.Upstream(branch)
if err != nil {
return "", err
}
if upstream == "" {
return fmt.Sprintf(colorYellow, noUpstream), nil
}
a, b, err := r.AheadBehind(branch, upstream)
if err != nil {
return printErr(err), nil
}
if a == 0 && b == 0 {
return fmt.Sprintf(colorGreen, ok), nil
}
if a != 0 {
res = append(res, fmt.Sprintf(colorYellow, fmt.Sprintf("%d %s", a, ahead)))
}
if b != 0 {
res = append(res, fmt.Sprintf(colorYellow, fmt.Sprintf("%d %s", b, behind)))
}
return strings.Join(res, " "), nil
}
func printWorkTreeStatus(r Repo) (string, error) {
uc, err := r.Uncommitted()
if err != nil {
return "", err
}
ut, err := r.Untracked()
if err != nil {
return "", err
}
if uc == 0 && ut == 0 {
return "", nil
}
var res []string
res = append(res, "[")
if uc != 0 {
res = append(res, fmt.Sprintf(colorRed, fmt.Sprintf("%d %s", uc, uncommitted)))
}
if ut != 0 {
res = append(res, fmt.Sprintf(colorRed, fmt.Sprintf("%d %s", ut, untracked)))
}
res = append(res, "]")
return strings.Join(res, " "), nil
}
func printErr(err error) string {
return fmt.Sprintf(colorRed, err.Error())
}

View File

@@ -1,98 +0,0 @@
package print
import (
"git-get/pkg/repo"
"path/filepath"
"strings"
)
// SmartPrinter implements Printer interface and provides methods for printing repos and their statuses.
// It's "smart" because it automatically folds branches which only have a single child and indents branches with many children.
type SmartPrinter struct {
// length is the size (number of chars) of the currently processed line.
// It's used to correctly indent the lines with branches status.
length int
}
// Print generates a list of repositories and their statuses.
func (p *SmartPrinter) Print(root string, repos []*repo.Repo) string {
tree := buildTree(root, repos)
return p.printSmartTree(tree)
}
// printSmartTree recursively traverses the tree and prints its nodes.
// If a node contains multiple children, they are be printed in new lines and indented.
// If a node contains only a single child, it is printed in the same line using path separator.
// For better readability the first level (repos hosts) is not indented.
//
// Example:
// Following paths:
// /repos/github.com/user/repo1
// /repos/github.com/user/repo2
// /repos/github.com/another/repo
//
// will render a tree:
// /repos/
// github.com/
// user/
// repo1
// repo2
// another/repo
//
func (p *SmartPrinter) printSmartTree(node *Node) string {
if node.children == nil {
// If node is a leaf, print repo name and its status and finish processing this node.
value := node.val
// TODO: Ugly
// If this is called from tests the repo will be nil and we should return just the name without the status.
if node.repo.Repository == nil {
return value
}
value += " " + printWorktreeStatus(node.repo)
// Print the status of each branch on a new line, indented to match the position of the current branch name.
indent := "\n" + strings.Repeat(" ", p.length+len(node.val))
for _, branch := range node.repo.Status.Branches {
// Don't print the status of the current branch. It was already printed above.
if branch.Name == node.repo.Status.CurrentBranch {
continue
}
value += indent + printBranchStatus(branch)
}
return value
}
val := node.val + string(filepath.Separator)
shift := ""
if node.parent == nil {
// If node is a root, print its children on a new line without indentation.
shift = "\n"
} else if len(node.children) == 1 {
// If node has only a single child, print it on the same line as its parent.
// Setting node's depth to the same as parent's ensures that its children will be indented only once even if
// node's path has multiple levels above.
node.depth = node.parent.depth
p.length += len(val)
} else {
// If node has multiple children, print each of them on a new line
// and indent them once relative to the parent
node.depth = node.parent.depth + 1
shift = "\n" + strings.Repeat(" ", node.depth)
p.length = 0
}
for _, child := range node.children {
p.length += len(shift)
val += shift + p.printSmartTree(child)
p.length = 0
}
return val
}

View File

@@ -1,20 +1,29 @@
package print
import (
"git-get/pkg/repo"
"fmt"
"path/filepath"
"strings"
"github.com/xlab/treeprint"
)
// TreePrinter implements Printer interface and provides methods for printing repos and their statuses.
type TreePrinter struct{}
// TreePrinter prints list of repos in a directory tree format.
type TreePrinter struct {
}
// NewTreePrinter creates a TreePrinter.
func NewTreePrinter() *TreePrinter {
return &TreePrinter{}
}
// Print generates a tree view of repos and their statuses.
func (p *TreePrinter) Print(root string, repos []*repo.Repo) string {
tree := buildTree(root, repos)
func (p *TreePrinter) Print(root string, repos []Repo) string {
if len(repos) == 0 {
return fmt.Sprintf("There are no git repos under %s", root)
}
tree := buildTree(root, repos)
tp := treeprint.New()
tp.SetValue(root)
@@ -23,16 +32,15 @@ func (p *TreePrinter) Print(root string, repos []*repo.Repo) string {
return tp.String()
}
// Node represents a node (ie. path fragment) in a repos tree.
// Node represents a path fragment in repos tree.
type Node struct {
val string
depth int // depth is a nesting depth used when rendering a smart tree, not a depth level of a tree node.
parent *Node
children []*Node
repo *repo.Repo
repo Repo
}
// Root creates a new root of a tree
// Root creates a new root of a tree.
func Root(val string) *Node {
root := &Node{
val: val,
@@ -40,7 +48,7 @@ func Root(val string) *Node {
return root
}
// Add adds a child node
// Add adds a child node with given value to a current node.
func (n *Node) Add(val string) *Node {
if n.children == nil {
n.children = make([]*Node, 0)
@@ -73,11 +81,11 @@ func (n *Node) GetChild(val string) *Node {
// buildTree builds a directory tree of paths to repositories.
// Each node represents a directory in the repo path.
// Each leaf (final node) contains a pointer to the repo.
func buildTree(root string, repos []*repo.Repo) *Node {
func buildTree(root string, repos []Repo) *Node {
tree := Root(root)
for _, r := range repos {
path := strings.TrimPrefix(r.Path, root)
path := strings.TrimPrefix(r.Path(), root)
path = strings.Trim(path, string(filepath.Separator))
subs := strings.Split(path, string(filepath.Separator))
@@ -106,16 +114,34 @@ func buildTree(root string, repos []*repo.Repo) *Node {
func (p *TreePrinter) printTree(node *Node, tp treeprint.Tree) {
if node.children == nil {
tp.SetValue(node.val + " " + printWorktreeStatus(node.repo))
r := node.repo
tp.SetValue(node.val + " " + printCurrentBranchLine(r))
for _, branch := range node.repo.Status.Branches {
// Don't print the status of the current branch. It was already printed above.
if branch.Name == node.repo.Status.CurrentBranch {
continue
}
tp.AddNode(printBranchStatus(branch))
branches, err := r.Branches()
if err != nil {
tp.AddNode(printErr(err))
return
}
current, err := r.CurrentBranch()
if err != nil {
tp.AddNode(printErr(err))
return
}
for _, branch := range branches {
// Don't print the status of the current branch. It was already printed above.
if branch == current {
continue
}
status, err := printBranchStatus(r, branch)
if err != nil {
tp.AddNode(printErr(err))
continue
}
tp.AddNode(printBranchName(branch) + " " + status)
}
}
for _, child := range node.children {

View File

@@ -1,108 +0,0 @@
package print
import (
"fmt"
"git-get/pkg/repo"
"strings"
"testing"
)
func TestTree(t *testing.T) {
var tests = []struct {
paths []string
want string
}{
{
[]string{
"root/github.com/grdl/repo1",
}, `
root/
github.com/grdl/repo1
`,
},
{
[]string{
"root/github.com/grdl/repo1",
"root/github.com/grdl/repo2",
}, `
root/
github.com/grdl/
repo1
repo2
`,
},
{
[]string{
"root/gitlab.com/grdl/repo1",
"root/github.com/grdl/repo1",
}, `
root/
gitlab.com/grdl/repo1
github.com/grdl/repo1
`,
},
{
[]string{
"root/gitlab.com/grdl/repo1",
"root/gitlab.com/grdl/repo2",
"root/gitlab.com/other/repo1",
"root/github.com/grdl/repo1",
"root/github.com/grdl/nested/repo2",
}, `
root/
gitlab.com/
grdl/
repo1
repo2
other/repo1
github.com/grdl/
repo1
nested/repo2
`,
},
{
[]string{
"root/gitlab.com/grdl/nested/repo1",
"root/gitlab.com/grdl/nested/repo2",
"root/gitlab.com/other/repo1",
}, `
root/
gitlab.com/
grdl/nested/
repo1
repo2
other/repo1
`,
},
{
[]string{
"root/gitlab.com/grdl/double/nested/repo1",
"root/gitlab.com/grdl/nested/repo2",
"root/gitlab.com/other/repo1",
}, `
root/
gitlab.com/
grdl/
double/nested/repo1
nested/repo2
other/repo1
`,
},
}
for i, test := range tests {
var repos []*repo.Repo
for _, path := range test.paths {
repos = append(repos, repo.New(nil, path)) //&Repo{path: path})
}
printer := SmartPrinter{}
// Leading and trailing newlines are added to test cases for readability. We also need to add them to the rendering result.
got := fmt.Sprintf("\n%s\n", printer.Print("root", repos))
// Rendered tree uses spaces for indentation but the test cases use tabs.
if got != strings.ReplaceAll(test.want, "\t", " ") {
t.Errorf("Failed test case %d, got: %+v; want: %+v", i, got, test.want)
}
}
}

View File

@@ -1,158 +0,0 @@
package repo
import (
"fmt"
"git-get/pkg/cfg"
"github.com/go-git/go-git/v5/plumbing"
"io"
"io/ioutil"
"net/url"
"os"
"github.com/pkg/errors"
"github.com/spf13/viper"
"golang.org/x/crypto/ssh"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing/transport"
go_git_ssh "github.com/go-git/go-git/v5/plumbing/transport/ssh"
)
type Repo struct {
*git.Repository
Path string
Status *RepoStatus
}
// CloneOpts specify details about repository to clone.
type CloneOpts struct {
URL *url.URL
Path string // TODO: should Path be a part of clone opts?
Branch string
Quiet bool
IgnoreExisting bool
}
// Clone clones repository specified in CloneOpts.
func Clone(opts *CloneOpts) (*Repo, error) {
var progress io.Writer
if !opts.Quiet {
progress = os.Stdout
fmt.Printf("Cloning into '%s'...\n", opts.Path)
}
// TODO: can this be cleaner?
var auth transport.AuthMethod
var err error
if opts.URL.Scheme == "ssh" {
if auth, err = sshKeyAuth(); err != nil {
return nil, err
}
}
if opts.Branch == "" {
opts.Branch = cfg.DefBranch
}
// If branch name is actually a tag (ie. is prefixed with refs/tags) - check out that tag.
// Otherwise, assume it's a branch name and check it out.
refName := plumbing.ReferenceName(opts.Branch)
if !refName.IsTag() {
refName = plumbing.NewBranchReferenceName(opts.Branch)
}
gitOpts := &git.CloneOptions{
URL: opts.URL.String(),
Auth: auth,
RemoteName: git.DefaultRemoteName,
ReferenceName: refName,
SingleBranch: false,
NoCheckout: false,
Depth: 0,
RecurseSubmodules: git.NoRecurseSubmodules,
Progress: progress,
Tags: git.AllTags,
}
repo, err := git.PlainClone(opts.Path, false, gitOpts)
if err != nil {
if opts.IgnoreExisting && errors.Is(err, git.ErrRepositoryAlreadyExists) {
return nil, nil
}
return nil, errors.Wrapf(err, "failed cloning %s", opts.URL.String())
}
return New(repo, opts.Path), nil
}
// Open opens a repository on a given path.
func Open(path string) (*Repo, error) {
repo, err := git.PlainOpen(path)
if err != nil {
return nil, errors.Wrapf(err, "failed opening repo %s", path)
}
return New(repo, path), nil
}
// New returns a new Repo instance from a given go-git Repository.
func New(repo *git.Repository, path string) *Repo {
return &Repo{
Repository: repo,
Path: path,
Status: &RepoStatus{},
}
}
// Fetch performs a git fetch on all remotes
func (r *Repo) Fetch() error {
remotes, err := r.Remotes()
if err != nil {
return errors.Wrapf(err, "failed getting remotes of repo %s", r.Path)
}
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
}
func sshKeyAuth() (transport.AuthMethod, error) {
privateKey := viper.GetString(cfg.KeyPrivateKey)
sshKey, err := ioutil.ReadFile(privateKey)
if err != nil {
return nil, errors.Wrapf(err, "failed to open ssh private key %s", privateKey)
}
signer, err := ssh.ParsePrivateKey([]byte(sshKey))
if err != nil {
return nil, errors.Wrapf(err, "failed to parse ssh private key %s", privateKey)
}
// TODO: can it ba a different user
auth := &go_git_ssh.PublicKeys{User: "git", Signer: signer}
return auth, nil
}
// CurrentBranchStatus returns the BranchStatus of a currently checked out branch.
func (r *Repo) CurrentBranchStatus() *BranchStatus {
if r.Status.CurrentBranch == StatusDetached || r.Status.CurrentBranch == StatusUnknown {
return nil
}
for _, b := range r.Status.Branches {
if b.Name == r.Status.CurrentBranch {
return b
}
}
return nil
}

View File

@@ -1,294 +0,0 @@
package repo
import (
"net/url"
"io/ioutil"
"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"
)
const (
testUser = "Test User"
testEmail = "testuser@example.com"
)
func newRepoEmpty(t *testing.T) *Repo {
dir := newTempDir(t)
repo, err := git.PlainInit(dir, false)
checkFatal(t, err)
return New(repo, dir)
}
func newRepoWithUntracked(t *testing.T) *Repo {
r := newRepoEmpty(t)
r.writeFile(t, "README", "I'm a README file")
return r
}
func newRepoWithStaged(t *testing.T) *Repo {
r := newRepoEmpty(t)
r.writeFile(t, "README", "I'm a README file")
r.addFile(t, "README")
return r
}
func newRepoWithCommit(t *testing.T) *Repo {
r := newRepoEmpty(t)
r.writeFile(t, "README", "I'm a README file")
r.addFile(t, "README")
r.newCommit(t, "Initial commit")
return r
}
func newRepoWithModified(t *testing.T) *Repo {
r := newRepoEmpty(t)
r.writeFile(t, "README", "I'm a README file")
r.addFile(t, "README")
r.newCommit(t, "Initial commit")
r.writeFile(t, "README", "I'm modified")
return r
}
func newRepoWithIgnored(t *testing.T) *Repo {
r := newRepoEmpty(t)
r.writeFile(t, ".gitignore", "ignoreme")
r.addFile(t, ".gitignore")
r.newCommit(t, "Initial commit")
r.writeFile(t, "ignoreme", "I'm being ignored")
return r
}
func newRepoWithLocalBranch(t *testing.T) *Repo {
r := newRepoWithCommit(t)
r.newBranch(t, "local")
return r
}
func newRepoWithClonedBranch(t *testing.T) *Repo {
origin := newRepoWithCommit(t)
r := origin.clone(t, "master")
r.newBranch(t, "local")
r.checkoutBranch(t, "local")
return r
}
func newRepoWithDetachedHead(t *testing.T) *Repo {
r := newRepoWithCommit(t)
r.writeFile(t, "new", "I'm a new file")
r.addFile(t, "new")
hash := r.newCommit(t, "new commit")
r.checkoutHash(t, hash)
return r
}
func newRepoWithBranchAhead(t *testing.T) *Repo {
origin := newRepoWithCommit(t)
r := origin.clone(t, "master")
r.writeFile(t, "new", "I'm a new file")
r.addFile(t, "new")
r.newCommit(t, "new commit")
return r
}
func newRepoWithBranchBehind(t *testing.T) *Repo {
origin := newRepoWithCommit(t)
r := origin.clone(t, "master")
origin.writeFile(t, "origin.new", "I'm a new file on origin")
origin.addFile(t, "origin.new")
origin.newCommit(t, "new origin commit")
r.fetch(t)
return r
}
// generate repo with 2 commits ahead and 3 behind the origin
func newRepoWithBranchAheadAndBehind(t *testing.T) *Repo {
origin := newRepoWithCommit(t)
r := origin.clone(t, "master")
r.writeFile(t, "local.new", "local 1")
r.addFile(t, "local.new")
r.newCommit(t, "1st local commit")
r.writeFile(t, "local.new", "local 2")
r.addFile(t, "local.new")
r.newCommit(t, "2nd local commit")
origin.writeFile(t, "origin.new", "origin 1")
origin.addFile(t, "origin.new")
origin.newCommit(t, "1st origin commit")
origin.writeFile(t, "origin.new", "origin 2")
origin.addFile(t, "origin.new")
origin.newCommit(t, "2nd origin commit")
origin.writeFile(t, "origin.new", "origin 3")
origin.addFile(t, "origin.new")
origin.newCommit(t, "3rd origin commit")
r.fetch(t)
return r
}
func newRepoWithCheckedOutBranch(t *testing.T) *Repo {
origin := newRepoWithCommit(t)
origin.newBranch(t, "feature/branch1")
r := origin.clone(t, "feature/branch1")
return r
}
func newRepoWithCheckedOutTag(t *testing.T) *Repo {
origin := newRepoWithCommit(t)
origin.newTag(t, "v1.0.0")
r := origin.clone(t, "refs/tags/v1.0.0")
return r
}
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 *Repo) writeFile(t *testing.T, name string, content string) {
wt, err := r.Worktree()
checkFatal(t, errors.Wrap(err, "Failed getting worktree"))
file, err := wt.Filesystem.OpenFile(name, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644)
checkFatal(t, errors.Wrap(err, "Failed opening a file"))
_, err = file.Write([]byte(content))
checkFatal(t, errors.Wrap(err, "Failed writing a file"))
}
func (r *Repo) addFile(t *testing.T, name string) {
wt, err := r.Worktree()
checkFatal(t, errors.Wrap(err, "Failed getting worktree"))
_, err = wt.Add(name)
checkFatal(t, errors.Wrap(err, "Failed adding file to index"))
}
func (r *Repo) newCommit(t *testing.T, msg string) plumbing.Hash {
wt, err := r.Worktree()
checkFatal(t, errors.Wrap(err, "Failed getting worktree"))
opts := &git.CommitOptions{
Author: &object.Signature{
Name: testUser,
Email: testEmail,
When: time.Date(2000, 01, 01, 16, 00, 00, 0, time.UTC),
},
}
hash, err := wt.Commit(msg, opts)
checkFatal(t, errors.Wrap(err, "Failed creating commit"))
return hash
}
func (r *Repo) newBranch(t *testing.T, name string) {
head, err := r.Head()
checkFatal(t, err)
ref := plumbing.NewHashReference(plumbing.NewBranchReferenceName(name), head.Hash())
err = r.Storer.SetReference(ref)
checkFatal(t, err)
}
func (r *Repo) newTag(t *testing.T, name string) {
head, err := r.Head()
checkFatal(t, err)
ref := plumbing.NewHashReference(plumbing.NewTagReferenceName(name), head.Hash())
err = r.Storer.SetReference(ref)
checkFatal(t, err)
}
func (r *Repo) clone(t *testing.T, branch string) *Repo {
dir := newTempDir(t)
repoURL, err := url.Parse("file://" + r.Path)
checkFatal(t, err)
cloneOpts := &CloneOpts{
URL: repoURL,
Path: dir,
Branch: branch,
Quiet: true,
}
repo, err := Clone(cloneOpts)
checkFatal(t, err)
return repo
}
func (r *Repo) fetch(t *testing.T) {
err := r.Fetch()
checkFatal(t, err)
}
func (r *Repo) checkoutBranch(t *testing.T, name string) {
wt, err := r.Worktree()
checkFatal(t, errors.Wrap(err, "Failed getting worktree"))
opts := &git.CheckoutOptions{
Branch: plumbing.NewBranchReferenceName(name),
}
err = wt.Checkout(opts)
checkFatal(t, errors.Wrap(err, "Failed checking out branch"))
}
func (r *Repo) checkoutHash(t *testing.T, hash plumbing.Hash) {
wt, err := r.Worktree()
checkFatal(t, errors.Wrap(err, "Failed getting worktree"))
opts := &git.CheckoutOptions{
Hash: hash,
}
err = wt.Checkout(opts)
checkFatal(t, errors.Wrap(err, "Failed checking out hash"))
}
func checkFatal(t *testing.T, err error) {
if err != nil {
t.Fatalf("%+v", err)
}
}

View File

@@ -1,266 +0,0 @@
package repo
import (
"git-get/pkg/cfg"
"sort"
"strings"
"github.com/go-git/go-git/v5/plumbing/revlist"
"github.com/spf13/viper"
"github.com/go-git/go-billy/v5/osfs"
"github.com/go-git/go-git/v5/plumbing/format/gitignore"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
"github.com/pkg/errors"
)
const (
StatusUnknown = "unknown"
StatusDetached = "detached HEAD"
StatusNoUpstream = "no upstream"
StatusAhead = "ahead"
StatusBehind = "behind"
StatusOk = "ok"
StatusUncommitted = "uncommitted"
StatusUntracked = "untracked"
)
type RepoStatus struct {
HasUntrackedFiles bool
HasUncommittedChanges bool
CurrentBranch string
Branches []*BranchStatus
}
type BranchStatus struct {
Name string
Upstream string
Ahead int
Behind int
}
func (r *Repo) LoadStatus() error {
// Fetch from remotes if executed with --fetch flag. Ignore the "already up-to-date" errors.
if viper.GetBool(cfg.KeyFetch) {
err := r.Fetch()
if err != nil && !errors.Is(err, git.NoErrAlreadyUpToDate) {
return errors.Wrapf(err, "failed running git fetch on a repo %s", r.Path)
}
}
wt, err := r.Worktree()
if err != nil {
return errors.Wrapf(err, "failed getting worktree %s", r.Path)
}
// worktree.Status doesn't load gitignore patterns that are defined outside of .gitignore file using excludesfile.
// We need to load them explicitly here
// TODO: variables are not expanded so if excludesfile is declared like "~/gitignore_global" or "$HOME/gitignore_global", this will fail to open it
globalPatterns, err := gitignore.LoadGlobalPatterns(osfs.New(""))
if err != nil {
return errors.Wrap(err, "failed loading global gitignore patterns")
}
wt.Excludes = append(wt.Excludes, globalPatterns...)
systemPatterns, err := gitignore.LoadSystemPatterns(osfs.New(""))
if err != nil {
return errors.Wrap(err, "failed loading system gitignore patterns")
}
wt.Excludes = append(wt.Excludes, systemPatterns...)
status, err := wt.Status()
if err != nil {
return errors.Wrapf(err, "failed getting status of worktree %s", r.Path)
}
r.Status.HasUncommittedChanges = hasUncommitted(status)
r.Status.HasUntrackedFiles = hasUntracked(status)
r.Status.CurrentBranch = currentBranch(r)
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 currentBranch(r *Repo) string {
head, err := r.Head()
if err != nil {
return StatusUnknown
}
if head.Name().Short() == plumbing.HEAD.String() {
return StatusDetached
}
return head.Name().Short()
}
func (r *Repo) loadBranchesStatus() error {
iter, err := r.Branches()
if err != nil {
return errors.Wrapf(err, "failed getting branches iterator for repo %s", r.Path)
}
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.Wrapf(err, "failed iterating over branches of repo %s", r.Path)
}
// Sort branches by name (but with "master" always at the top). It's useful to have them sorted for printing and testing.
sort.Slice(r.Status.Branches, func(i, j int) bool {
if r.Status.Branches[i].Name == cfg.DefBranch {
return true
}
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
}
ahead, behind, err := r.aheadBehind(branch, upstream)
if err != nil {
return nil, err
}
bs.Upstream = upstream
bs.Ahead = ahead
bs.Behind = behind
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 upstream 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.Config()
if err != nil {
return "", errors.Wrapf(err, "failed getting config of repo %s", r.Path)
}
// 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) aheadBehind(localBranch string, upstreamBranch string) (ahead int, behind int, err error) {
localHash, err := r.ResolveRevision(plumbing.Revision(localBranch))
if err != nil {
return 0, 0, errors.Wrapf(err, "failed resolving revision %s", localBranch)
}
upstreamHash, err := r.ResolveRevision(plumbing.Revision(upstreamBranch))
if err != nil {
return 0, 0, errors.Wrapf(err, "failed resolving revision %s", upstreamBranch)
}
behind, err = r.revlistCount(*localHash, *upstreamHash)
if err != nil {
return 0, 0, errors.Wrapf(err, "failed counting commits behind %s", upstreamBranch)
}
ahead, err = r.revlistCount(*upstreamHash, *localHash)
if err != nil {
return 0, 0, errors.Wrapf(err, "failed counting commits ahead of %s", upstreamBranch)
}
return ahead, behind, nil
}
// revlistCount counts the number of commits between two hashes.
// https://github.com/src-d/go-git/issues/757#issuecomment-452697701
// TODO: See if this can be optimized. Running the loop twice feels wrong.
func (r *Repo) revlistCount(hash1, hash2 plumbing.Hash) (int, error) {
ref1hist, err := revlist.Objects(r.Storer, []plumbing.Hash{hash1}, nil)
if err != nil {
return 0, err
}
ref2hist, err := revlist.Objects(r.Storer, []plumbing.Hash{hash2}, ref1hist)
if err != nil {
return 0, err
}
count := 0
for _, h := range ref2hist {
if _, err = r.CommitObject(h); err == nil {
count++
}
}
return count, nil
}

View File

@@ -1,195 +0,0 @@
package repo
import (
"reflect"
"testing"
)
func TestStatus(t *testing.T) {
var tests = []struct {
makeTestRepo func(*testing.T) *Repo
want *RepoStatus
}{
{newRepoEmpty, &RepoStatus{
HasUntrackedFiles: false,
HasUncommittedChanges: false,
CurrentBranch: StatusUnknown,
Branches: nil,
}},
{newRepoWithUntracked, &RepoStatus{
HasUntrackedFiles: true,
HasUncommittedChanges: false,
CurrentBranch: StatusUnknown,
Branches: nil,
}},
{newRepoWithStaged, &RepoStatus{
HasUntrackedFiles: false,
HasUncommittedChanges: true,
CurrentBranch: StatusUnknown,
Branches: nil,
}},
{newRepoWithCommit, &RepoStatus{
HasUntrackedFiles: false,
HasUncommittedChanges: false,
CurrentBranch: "master",
Branches: []*BranchStatus{
{
Name: "master",
Upstream: "",
Behind: 0,
Ahead: 0,
},
},
}},
{newRepoWithModified, &RepoStatus{
HasUntrackedFiles: false,
HasUncommittedChanges: true,
CurrentBranch: "master",
Branches: []*BranchStatus{
{
Name: "master",
Upstream: "",
Behind: 0,
Ahead: 0,
},
},
}},
{newRepoWithIgnored, &RepoStatus{
HasUntrackedFiles: false,
HasUncommittedChanges: false,
CurrentBranch: "master",
Branches: []*BranchStatus{
{
Name: "master",
Upstream: "",
Behind: 0,
Ahead: 0,
},
},
}},
{newRepoWithLocalBranch, &RepoStatus{
HasUntrackedFiles: false,
HasUncommittedChanges: false,
CurrentBranch: "master",
Branches: []*BranchStatus{
{
Name: "master",
Upstream: "",
Behind: 0,
Ahead: 0,
}, {
Name: "local",
Upstream: "",
Behind: 0,
Ahead: 0,
},
},
}},
{newRepoWithClonedBranch, &RepoStatus{
HasUntrackedFiles: false,
HasUncommittedChanges: false,
CurrentBranch: "local",
Branches: []*BranchStatus{
{
Name: "master",
Upstream: "origin/master",
Behind: 0,
Ahead: 0,
}, {
Name: "local",
Upstream: "",
Behind: 0,
Ahead: 0,
},
},
}},
{newRepoWithDetachedHead, &RepoStatus{
HasUntrackedFiles: false,
HasUncommittedChanges: false,
CurrentBranch: StatusDetached,
Branches: []*BranchStatus{
{
Name: "master",
Upstream: "",
Behind: 0,
Ahead: 0,
},
},
}},
{newRepoWithBranchAhead, &RepoStatus{
HasUntrackedFiles: false,
HasUncommittedChanges: false,
CurrentBranch: "master",
Branches: []*BranchStatus{
{
Name: "master",
Upstream: "origin/master",
Behind: 0,
Ahead: 1,
},
},
}},
{newRepoWithBranchBehind, &RepoStatus{
HasUntrackedFiles: false,
HasUncommittedChanges: false,
CurrentBranch: "master",
Branches: []*BranchStatus{
{
Name: "master",
Upstream: "origin/master",
Behind: 1,
Ahead: 0,
},
},
}},
{newRepoWithBranchAheadAndBehind, &RepoStatus{
HasUntrackedFiles: false,
HasUncommittedChanges: false,
CurrentBranch: "master",
Branches: []*BranchStatus{
{
Name: "master",
Upstream: "origin/master",
Behind: 3,
Ahead: 2,
},
},
}},
{newRepoWithCheckedOutBranch, &RepoStatus{
HasUntrackedFiles: false,
HasUncommittedChanges: false,
CurrentBranch: "feature/branch1",
Branches: []*BranchStatus{
{
Name: "feature/branch1",
Upstream: "origin/feature/branch1",
Behind: 0,
Ahead: 0,
},
},
}},
{newRepoWithCheckedOutTag, &RepoStatus{
HasUntrackedFiles: false,
HasUncommittedChanges: false,
// TODO: is this correct? Can we show tag name instead of "detached HEAD"?
CurrentBranch: StatusDetached,
Branches: nil,
}},
}
for i, test := range tests {
repo := test.makeTestRepo(t)
err := repo.LoadStatus()
checkFatal(t, err)
if !reflect.DeepEqual(repo.Status, test.want) {
t.Errorf("Failed test case %d, got: %+v; want: %+v", i, repo.Status, test.want)
}
}
}
// TODO: test branch status when tracking a local branch
// TODO: test head pointing to a tag
// TODO: newRepoWithGlobalGitignore
// TODO: newRepoWithGlobalGitignoreSymlink