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:
committed by
GitHub
parent
2ef739ea49
commit
8c132cdafa
@@ -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.
|
||||
|
||||
@@ -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{})
|
||||
}
|
||||
|
||||
10
pkg/get.go
10
pkg/get.go
@@ -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
20
pkg/git/config.go
Normal 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
93
pkg/git/config_test.go
Normal 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
245
pkg/git/repo.go
Normal 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
|
||||
}
|
||||
245
pkg/git/repo_helpers_test.go
Normal file
245
pkg/git/repo_helpers_test.go
Normal 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
309
pkg/git/repo_test.go
Normal 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
121
pkg/io/io.go
Normal 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
|
||||
}
|
||||
150
pkg/list.go
150
pkg/list.go
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
158
pkg/repo/repo.go
158
pkg/repo/repo.go
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user