From 8c132cdafa9b405b0d4f691ecc8abb0d6d824e9d Mon Sep 17 00:00:00 2001 From: Grzegorz Dlugoszewski Date: Wed, 24 Jun 2020 23:54:44 +0200 Subject: [PATCH] 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 --- .github/workflows/build.yml | 4 +- README.md | 73 +++------ cmd/get/main.go | 7 +- cmd/list/main.go | 12 +- go.mod | 8 +- go.sum | 40 ----- pkg/cfg/config.go | 80 ++------- pkg/cfg/config_test.go | 232 +++++++++++--------------- pkg/get.go | 10 +- pkg/git/config.go | 20 +++ pkg/git/config_test.go | 93 +++++++++++ pkg/git/repo.go | 245 +++++++++++++++++++++++++++ pkg/git/repo_helpers_test.go | 245 +++++++++++++++++++++++++++ pkg/git/repo_test.go | 309 +++++++++++++++++++++++++++++++++++ pkg/io/io.go | 121 ++++++++++++++ pkg/list.go | 150 +++++------------ pkg/print/dump.go | 29 ++-- pkg/print/flat.go | 39 +++-- pkg/print/print.go | 200 +++++++++++++++-------- pkg/print/smart.go | 98 ----------- pkg/print/tree.go | 64 +++++--- pkg/print/tree_test.go | 108 ------------ pkg/repo/repo.go | 158 ------------------ pkg/repo/repo_test.go | 294 --------------------------------- pkg/repo/status.go | 266 ------------------------------ pkg/repo/status_test.go | 195 ---------------------- 26 files changed, 1452 insertions(+), 1648 deletions(-) create mode 100644 pkg/git/config.go create mode 100644 pkg/git/config_test.go create mode 100644 pkg/git/repo.go create mode 100644 pkg/git/repo_helpers_test.go create mode 100644 pkg/git/repo_test.go create mode 100644 pkg/io/io.go delete mode 100644 pkg/print/smart.go delete mode 100644 pkg/print/tree_test.go delete mode 100644 pkg/repo/repo.go delete mode 100644 pkg/repo/repo_test.go delete mode 100644 pkg/repo/status.go delete mode 100644 pkg/repo/status_test.go diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 95a2bb1..cfbab55 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -14,5 +14,7 @@ jobs: uses: actions/setup-go@v2 with: go-version: 1.14 + - name: Set up Git + run: git config --global user.email "grdl@example.com" && git config --global user.name "grdl" - name: Run go test - run: go test ./... -v \ No newline at end of file + run: CGO_ENABLED=0 GOOS=linux go test ./... -v \ No newline at end of file diff --git a/README.md b/README.md index 40efdcb..89e2f85 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,8 @@ * [Installation](#installation) * [Usage](#usage) * [git get](#git-get-1) - * [git-list](#git-list) + * [git list](#git-list) + * [dump file](#dump-file) * [Configuration](#configuration) * [Env variables](#env-variables) * [.gitconfig file](#.gitconfig-file) @@ -44,47 +45,19 @@ Each release contains two binaries: `git-get` and `git-list`. When put on PATH, git get [flags] Flags: - -b, --branch string Branch (or tag) to checkout after cloning. Tag name needs to be prefixed with 'refs/tags/'. (default "master") + -b, --branch string Branch (or tag) to checkout after cloning. -d, --dump string Path to a dump file listing repos to clone. Ignored when argument is used. -h, --help Print this help and exit. -t, --host string Host to use when doesn't have a specified host. (default "github.com") - -p, --privateKey string Path to SSH private key. (default "~/.ssh/id_rsa") -r, --root string Path to repos root where repositories are cloned. (default "~/repositories") -v, --version Print version and exit. ``` -The `` argument can be any valid URL supported by git. Such as: -``` -https://github.com/grdl/git-get -git@github.com:grdl/git-get.git -ssh://user@server/repository.git -file:///project/repository.git -``` +The `` argument can be any valid URL supported by git. It also accepts a short `USER/REPO` format. In that case `git-get` will automatically use the configured host (github.com by default). -#### Short URLs +For example, `git get grdl/git-get` will clone `https://github.com/grdl/git-get`. -`` can be a short path without any protocol or host. In that case `git-get` will automatically use the `https` protocol and the configured host (github.com by default). -For example `git get grdl/git-get` will clone `https://github.com/grdl/git-get.git`. -#### Dump file - -`git get` is dotfiles friendly. Using `--dump` flag, it accepts a file with a list of repositories and clones all of them. - -Dump file format is simply: -- Each repo URL on a separate line. -- Each URL can have a suffix with a branch or tag name to check out after cloning. Without that suffix, `master` is used. -- Tag name should be prefixed with `refs/tags/`. - -Example dump file content: -``` -https://github.com/grdl/git-get refs/tags/v1.0.0 -git@github.com:grdl/another-repository.git -``` - -You can generate a dump file with all your currently cloned repos by running: -``` -git list --out dump > repos.dump -``` ### git list @@ -96,7 +69,6 @@ Flags: -f, --fetch First fetch from remotes before listing repositories. -h, --help Print this help and exit. -o, --out string Output format. Allowed values: [dump, flat, smart, tree]. (default "tree") - -p, --privateKey string Path to SSH private key. (default "~/.ssh/id_rsa") -r, --root string Path to repos root where repositories are cloned. (default "~/repositories") -v, --version Print version and exit. ``` @@ -132,17 +104,24 @@ https://github.com/grdl/homebrew-tap master https://github.com/grdl/testsite master ``` -- **smart** (experimental) - similar to the tree view but saves space by automatically folding paths with only a single child. In theory it's supposed to be more readable but fails to prove it in practice so far :wink: +### Dump file + +`git get` is dotfiles friendly. Using `--dump` flag, it accepts a file with a list of repositories and clones all of them. + +Dump file format is simply: +- Each repo URL on a separate line. +- Each URL can have a suffix with a branch or tag name to check out after cloning. Without that suffix, repository HEAD is used (usually it's `master`). + +Example dump file content: ``` -❯ git list -o smart -/home/grdl/repositories -github.com/grdl/ - git-get master 1 ahead [ untracked ] - development ok - homebrew-tap master ok - testsite master ok +https://github.com/grdl/git-get v1.0.0 +git@github.com:grdl/another-repository.git ``` +You can generate a dump file with all your currently cloned repos by running: +``` +git list --out dump > repos.dump +``` ## Configuration @@ -168,19 +147,15 @@ export GITGET_ROOT=/path/to/my/repos ### .gitconfig file -You can define a `[gitget]` section inside your `.gitconfig` file and set the configuration flags there. A common and recommended pattern is to set `root` and `host` variables there if you don't want to use the defaults. Here's an example of a working snippet from `.gitconfig` file: +You can define a `[gitget]` section inside your global `.gitconfig` file and set the configuration flags there. A common and recommended pattern is to set `root` and `host` variables there if you don't want to use the defaults. + +Here's an example of a working snippet from `.gitconfig` file: ``` [gitget] root = /path/to/my/repos - host = git.example.com + host = gitlab.com ``` -`git-get` looks for the `.gitconfig` file in the following locations: -- `$XDG_CONFIG_HOME/git/config` -- `~/.gitconfig` -- `~/.config/git/config` -- `/etc/gitconfig` - ## Contributing diff --git a/cmd/get/main.go b/cmd/get/main.go index 2e3d792..1c5090d 100644 --- a/cmd/get/main.go +++ b/cmd/get/main.go @@ -3,6 +3,7 @@ package main import ( "git-get/pkg" "git-get/pkg/cfg" + "git-get/pkg/git" "github.com/spf13/cobra" "github.com/spf13/viper" @@ -24,10 +25,9 @@ var cmd = &cobra.Command{ } func init() { - cmd.PersistentFlags().StringP(cfg.KeyBranch, "b", cfg.DefBranch, "Branch (or tag) to checkout after cloning. Tag name needs to be prefixed with 'refs/tags/'.") + cmd.PersistentFlags().StringP(cfg.KeyBranch, "b", "", "Branch (or tag) to checkout after cloning.") cmd.PersistentFlags().StringP(cfg.KeyDefaultHost, "t", cfg.DefDefaultHost, "Host to use when doesn't have a specified host.") cmd.PersistentFlags().StringP(cfg.KeyDump, "d", "", "Path to a dump file listing repos to clone. Ignored when argument is used.") - cmd.PersistentFlags().StringP(cfg.KeyPrivateKey, "p", "", "Path to SSH private key. (default \"~/.ssh/id_rsa\")") cmd.PersistentFlags().StringP(cfg.KeyReposRoot, "r", "", "Path to repos root where repositories are cloned. (default \"~/repositories\")") cmd.PersistentFlags().BoolP("help", "h", false, "Print this help and exit.") cmd.PersistentFlags().BoolP("version", "v", false, "Print version and exit.") @@ -35,12 +35,11 @@ func init() { viper.BindPFlag(cfg.KeyBranch, cmd.PersistentFlags().Lookup(cfg.KeyBranch)) viper.BindPFlag(cfg.KeyDefaultHost, cmd.PersistentFlags().Lookup(cfg.KeyDefaultHost)) viper.BindPFlag(cfg.KeyDump, cmd.PersistentFlags().Lookup(cfg.KeyDump)) - viper.BindPFlag(cfg.KeyPrivateKey, cmd.PersistentFlags().Lookup(cfg.KeyReposRoot)) viper.BindPFlag(cfg.KeyReposRoot, cmd.PersistentFlags().Lookup(cfg.KeyReposRoot)) } func run(cmd *cobra.Command, args []string) error { - cfg.Init() + cfg.Init(&git.ConfigGlobal{}) var url string if len(args) > 0 { diff --git a/cmd/list/main.go b/cmd/list/main.go index 157e50b..04ec8f5 100644 --- a/cmd/list/main.go +++ b/cmd/list/main.go @@ -4,6 +4,7 @@ import ( "fmt" "git-get/pkg" "git-get/pkg/cfg" + "git-get/pkg/git" "strings" "github.com/spf13/cobra" @@ -22,26 +23,23 @@ var cmd = &cobra.Command{ func init() { cmd.PersistentFlags().BoolP(cfg.KeyFetch, "f", false, "First fetch from remotes before listing repositories.") cmd.PersistentFlags().StringP(cfg.KeyOutput, "o", cfg.DefOutput, fmt.Sprintf("Output format. Allowed values: [%s].", strings.Join(cfg.AllowedOut, ", "))) - cmd.PersistentFlags().StringP(cfg.KeyPrivateKey, "p", "", "Path to SSH private key. (default \"~/.ssh/id_rsa\")") cmd.PersistentFlags().StringP(cfg.KeyReposRoot, "r", "", "Path to repos root where repositories are cloned. (default \"~/repositories\")") cmd.PersistentFlags().BoolP("help", "h", false, "Print this help and exit.") cmd.PersistentFlags().BoolP("version", "v", false, "Print version and exit.") viper.BindPFlag(cfg.KeyFetch, cmd.PersistentFlags().Lookup(cfg.KeyFetch)) viper.BindPFlag(cfg.KeyOutput, cmd.PersistentFlags().Lookup(cfg.KeyOutput)) - viper.BindPFlag(cfg.KeyPrivateKey, cmd.PersistentFlags().Lookup(cfg.KeyReposRoot)) viper.BindPFlag(cfg.KeyReposRoot, cmd.PersistentFlags().Lookup(cfg.KeyReposRoot)) } func run(cmd *cobra.Command, args []string) error { - cfg.Init() + cfg.Init(&git.ConfigGlobal{}) config := &pkg.ListCfg{ - Fetch: viper.GetBool(cfg.KeyFetch), - Output: viper.GetString(cfg.KeyOutput), - PrivateKey: viper.GetString(cfg.KeyPrivateKey), - Root: viper.GetString(cfg.KeyReposRoot), + Fetch: viper.GetBool(cfg.KeyFetch), + Output: viper.GetString(cfg.KeyOutput), + Root: viper.GetString(cfg.KeyReposRoot), } return pkg.List(config) diff --git a/go.mod b/go.mod index 28c1896..36aa2d4 100644 --- a/go.mod +++ b/go.mod @@ -3,13 +3,15 @@ module git-get go 1.14 require ( - github.com/go-git/go-billy/v5 v5.0.0 - github.com/go-git/go-git/v5 v5.1.0 github.com/karrick/godirwalk v1.15.6 + github.com/kr/text v0.2.0 // indirect github.com/mitchellh/go-homedir v1.1.0 + github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect github.com/pkg/errors v0.9.1 github.com/spf13/cobra v1.0.0 github.com/spf13/viper v1.7.0 + github.com/stretchr/testify v1.4.0 // indirect github.com/xlab/treeprint v1.0.0 - golang.org/x/crypto v0.0.0-20200302210943-78000ba7a073 + golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527 // indirect + gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect ) diff --git a/go.sum b/go.sum index ba79212..733e558 100644 --- a/go.sum +++ b/go.sum @@ -15,18 +15,12 @@ github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= -github.com/alcortesm/tgz v0.0.0-20161220082320-9c5fe88206d7 h1:uSoVVbwJiQipAclBbw+8quDsfcvFjOpI5iCf4p/cqCs= -github.com/alcortesm/tgz v0.0.0-20161220082320-9c5fe88206d7/go.mod h1:6zEj6s6u/ghQa61ZWa/C2Aw3RkjiTBOix7dkqa1VLIs= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= -github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239 h1:kFOfPq6dUM1hTo4JG6LR5AXSUEsOjtdm0kw0FtQtMJA= -github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c= github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= -github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= -github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= @@ -48,24 +42,10 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= -github.com/emirpasic/gods v1.12.0 h1:QAUIPSaCu4G+POclxeqb3F+WPpdKqFGlw36+yOzGlrg= -github.com/emirpasic/gods v1.12.0/go.mod h1:YfzfFFoVP/catgzJb4IKIqXjX78Ha8FMSDh3ymbK86o= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= -github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568 h1:BHsljHzVlRcyQhjrss6TZTdY2VfCqZPbv5k3iBFa2ZQ= -github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc= github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= -github.com/gliderlabs/ssh v0.2.2 h1:6zsha5zo/TWhRhwqCD3+EarCAgZ2yN28ipRnGPnwkI0= -github.com/gliderlabs/ssh v0.2.2/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aevW3Awn0= -github.com/go-git/gcfg v1.5.0 h1:Q5ViNfGF8zFgyJWPqYwA7qGFoMTEiBmdlkcfRmpIMa4= -github.com/go-git/gcfg v1.5.0/go.mod h1:5m20vg6GwYabIxaOonVkTdrILxQMpEShl1xiMF4ua+E= -github.com/go-git/go-billy/v5 v5.0.0 h1:7NQHvd9FVid8VL4qVUMm8XifBK+2xCoZ2lSk0agRrHM= -github.com/go-git/go-billy/v5 v5.0.0/go.mod h1:pmpqyWchKfYfrkb/UVH4otLvyi/5gJlGI4Hb3ZqZ3W0= -github.com/go-git/go-git-fixtures/v4 v4.0.1 h1:q+IFMfLx200Q3scvt2hN79JsEzy4AmBTp/pqnefH+Bc= -github.com/go-git/go-git-fixtures/v4 v4.0.1/go.mod h1:m+ICp2rF3jDhFgEZ/8yziagdT1C+ZpZcrJjappBCDSw= -github.com/go-git/go-git/v5 v5.1.0 h1:HxJn9g/E7eYvKW3Fm7Jt4ee8LXfPOm/H1cdDu8vEssk= -github.com/go-git/go-git/v5 v5.1.0/go.mod h1:ZKfuPUoY1ZqIG4QG9BDBh3G4gLM5zvPuSJAozQrZuyM= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= @@ -120,13 +100,8 @@ github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ= github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I= github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc= -github.com/imdario/mergo v0.3.9 h1:UauaLniWCFHWd+Jp9oCEkTBj8VO/9DKg3PV3VCNMDIg= -github.com/imdario/mergo v0.3.9/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA= github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= -github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= -github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= -github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= @@ -135,8 +110,6 @@ github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfV github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= github.com/karrick/godirwalk v1.15.6 h1:Yf2mmR8TJy+8Fa0SuQVto5SYap6IF7lNVX4Jdl8G1qA= github.com/karrick/godirwalk v1.15.6/go.mod h1:j4mkqPuvaLI8mp1DroR3P6ad7cyYd4c1qeJ3RV7ULlk= -github.com/kevinburke/ssh_config v0.0.0-20190725054713-01f96b0aa0cd h1:Coekwdh0v2wtGp9Gmz1Ze3eVRAWJMLokvN3QjdzCHLY= -github.com/kevinburke/ssh_config v0.0.0-20190725054713-01f96b0aa0cd/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= @@ -195,8 +168,6 @@ github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFR github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= -github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= -github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM= @@ -229,8 +200,6 @@ github.com/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc= -github.com/xanzy/ssh-agent v0.2.1 h1:TCbipTQL2JiiCprBWx9frJ2eJlCYT00NmctrHxVAr70= -github.com/xanzy/ssh-agent v0.2.1/go.mod h1:mLlQY/MoOhWBj+gOGMQkOeiEvkx+8pJSI+0Bx9h2kr4= github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= github.com/xlab/treeprint v1.0.0 h1:J0TkWtiuYgtdlrkkrDLISYBQ92M+X5m4LrIIMKrbDTs= github.com/xlab/treeprint v1.0.0/go.mod h1:IoImgRak9i3zJyuxOKUP1v4UZd1tMoKkq/Cimt1uhCg= @@ -243,12 +212,9 @@ go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/ go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= -golang.org/x/crypto v0.0.0-20190219172222-a4c6cb3142f2/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20200302210943-78000ba7a073 h1:xMPOj6Pz6UipU1wXLkrtqpHbR0AVFnyPEQq/wRWz9lM= -golang.org/x/crypto v0.0.0-20200302210943-78000ba7a073/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -282,8 +248,6 @@ golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn golang.org/x/net v0.0.0-20190522155817-f3200d17e092/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200301022130-244492dfa37a h1:GuSPYbZzB5/dcLNCwLQLsg3obCJtX9IJhpXkvY7kzk0= -golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -300,7 +264,6 @@ golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5h golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a h1:1BGLXjeY4akVXGgbC9HugT3Jv3hCI0z56oJR5vAMgBU= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190221075227-b4e8571b14e0/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -360,15 +323,12 @@ google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ij gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/ini.v1 v1.51.0 h1:AQvPpx3LzTDM0AjnIRlVFwFFGC+npRopjZxLJj6gdno= gopkg.in/ini.v1 v1.51.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo= -gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= -gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= diff --git a/pkg/cfg/config.go b/pkg/cfg/config.go index c3587f3..a5539e9 100644 --- a/pkg/cfg/config.go +++ b/pkg/cfg/config.go @@ -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. diff --git a/pkg/cfg/config_test.go b/pkg/cfg/config_test.go index 6d05cca..f08c52b 100644 --- a/pkg/cfg/config_test.go +++ b/pkg/cfg/config_test.go @@ -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{}) } diff --git a/pkg/get.go b/pkg/get.go index c1e33a8..3257462 100644 --- a/pkg/get.go +++ b/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 } diff --git a/pkg/git/config.go b/pkg/git/config.go new file mode 100644 index 0000000..6203b0c --- /dev/null +++ b/pkg/git/config.go @@ -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] +} diff --git a/pkg/git/config_test.go b/pkg/git/config_test.go new file mode 100644 index 0000000..93d18aa --- /dev/null +++ b/pkg/git/config_test.go @@ -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 +} diff --git a/pkg/git/repo.go b/pkg/git/repo.go new file mode 100644 index 0000000..ac9e9b3 --- /dev/null +++ b/pkg/git/repo.go @@ -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 +} diff --git a/pkg/git/repo_helpers_test.go b/pkg/git/repo_helpers_test.go new file mode 100644 index 0000000..f303528 --- /dev/null +++ b/pkg/git/repo_helpers_test.go @@ -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 \"") + runGitCmd(r.T, cmd) +} + +func (r *testRepo) branch(name string) { + cmd := gitCmd(r.path, "branch", name) + runGitCmd(r.T, cmd) +} + +func (r *testRepo) tag(name string) { + cmd := gitCmd(r.path, "tag", "-a", name, "-m", name) + runGitCmd(r.T, cmd) +} + +func (r *testRepo) checkout(name string) { + cmd := gitCmd(r.path, "checkout", name) + runGitCmd(r.T, cmd) +} + +func (r *testRepo) clone() *testRepo { + dir, err := io.TempDir() + checkFatal(r.T, err) + + url, err := url.Parse(fmt.Sprintf("file://%s/.git", r.path)) + checkFatal(r.T, err) + + opts := &CloneOpts{ + URL: url, + Quiet: true, + Path: dir, + } + + repo, err := Clone(opts) + checkFatal(r.T, err) + + tr := &testRepo{ + Repo: repo, + T: r.T, + } + + tr.T.Cleanup(tr.cleanup) + return tr +} + +func runGitCmd(t *testing.T, cmd *exec.Cmd) { + err := cmd.Run() + checkFatal(t, cmdError(cmd, err)) +} + +func checkFatal(t *testing.T, err error) { + if err != nil { + t.Fatalf("%+v", err) + } +} diff --git a/pkg/git/repo_test.go b/pkg/git/repo_test.go new file mode 100644 index 0000000..635dadd --- /dev/null +++ b/pkg/git/repo_test.go @@ -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) + } + }) + } +} diff --git a/pkg/io/io.go b/pkg/io/io.go new file mode 100644 index 0000000..83d4eed --- /dev/null +++ b/pkg/io/io.go @@ -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 +} diff --git a/pkg/list.go b/pkg/list.go index 8ab4c7c..a8aec8e 100644 --- a/pkg/list.go +++ b/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 -} diff --git a/pkg/print/dump.go b/pkg/print/dump.go index ec291aa..106c983 100644 --- a/pkg/print/dump.go +++ b/pkg/print/dump.go @@ -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) } diff --git a/pkg/print/flat.go b/pkg/print/flat.go index b8737db..7599e17 100644 --- a/pkg/print/flat.go +++ b/pkg/print/flat.go @@ -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)) } } diff --git a/pkg/print/print.go b/pkg/print/print.go index 84d34fe..d0743e7 100644 --- a/pkg/print/print.go +++ b/pkg/print/print.go @@ -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()) } diff --git a/pkg/print/smart.go b/pkg/print/smart.go deleted file mode 100644 index 515ee3e..0000000 --- a/pkg/print/smart.go +++ /dev/null @@ -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 -} diff --git a/pkg/print/tree.go b/pkg/print/tree.go index 2f6fda7..976af92 100644 --- a/pkg/print/tree.go +++ b/pkg/print/tree.go @@ -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 { diff --git a/pkg/print/tree_test.go b/pkg/print/tree_test.go deleted file mode 100644 index ec275fa..0000000 --- a/pkg/print/tree_test.go +++ /dev/null @@ -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) - } - } -} diff --git a/pkg/repo/repo.go b/pkg/repo/repo.go deleted file mode 100644 index 12c5820..0000000 --- a/pkg/repo/repo.go +++ /dev/null @@ -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 -} diff --git a/pkg/repo/repo_test.go b/pkg/repo/repo_test.go deleted file mode 100644 index 8bcb59c..0000000 --- a/pkg/repo/repo_test.go +++ /dev/null @@ -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) - } -} diff --git a/pkg/repo/status.go b/pkg/repo/status.go deleted file mode 100644 index 98401ab..0000000 --- a/pkg/repo/status.go +++ /dev/null @@ -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 -} diff --git a/pkg/repo/status_test.go b/pkg/repo/status_test.go deleted file mode 100644 index 1ee7072..0000000 --- a/pkg/repo/status_test.go +++ /dev/null @@ -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