6
0
mirror of https://github.com/grdl/git-get.git synced 2026-02-04 13:56:46 +00:00

Remove gogit and major refactoring (#2)

* Fix typo in readme

* Reimplement all git methods without go-git

* Rename repo pkg to git, add gitconfig methods

* Improve tests for configuration reading

* Rename package file to io and move RepoFinder there

* Refactor printers

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

* Update readme and go.mod

* Add author to git commit in tests

Otherwise tests will fail in CI.

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

* Add better error message, revert installing git

* Ensure commit message is in quotes

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

View File

@@ -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
run: CGO_ENABLED=0 GOOS=linux go test ./... -v

View File

@@ -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 <REPO> [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 <REPO> argument is used.
-h, --help Print this help and exit.
-t, --host string Host to use when <REPO> 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 `<REPO>` 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 `<REPO>` 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`.
`<REPO>` 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

View File

@@ -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 <REPO> doesn't have a specified host.")
cmd.PersistentFlags().StringP(cfg.KeyDump, "d", "", "Path to a dump file listing repos to clone. Ignored when <REPO> 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 {

View File

@@ -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)

8
go.mod
View File

@@ -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
)

40
go.sum
View File

@@ -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=

View File

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

View File

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

View File

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

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

@@ -0,0 +1,20 @@
package git
import "os/exec"
// ConfigGlobal represents a global gitconfig file.
type ConfigGlobal struct{}
// Get reads a value from global gitconfig file. Returns empty string when key is missing.
func (c *ConfigGlobal) Get(key string) string {
cmd := exec.Command("git", "config", "--global", key)
out, err := cmd.Output()
// In case of error return an empty string, the missing value will fall back to a default.
if err != nil {
return ""
}
lines := lines(out)
return lines[0]
}

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

@@ -0,0 +1,93 @@
package git
import (
"testing"
)
// cfgStub represents a gitconfig file but instead of using a global one, it creates a temporary git repo and uses its local gitconfig.
type cfgStub struct {
repo *testRepo
}
func newCfgStub(t *testing.T) *cfgStub {
r := testRepoEmpty(t)
return &cfgStub{
repo: r,
}
}
func (c *cfgStub) Get(key string) string {
cmd := gitCmd(c.repo.path, "config", "--local", key)
out, err := cmd.Output()
if err != nil {
return ""
}
lines := lines(out)
return lines[0]
}
func TestGitConfig(t *testing.T) {
tests := []struct {
name string
configMaker func(t *testing.T) *cfgStub
key string
want string
}{
{
name: "empty",
configMaker: makeConfigEmpty,
key: "gitget.host",
want: "",
},
{
name: "valid",
configMaker: makeConfigValid,
key: "gitget.host",
want: "github.com",
}, {
name: "only section name",
configMaker: makeConfigValid,
key: "gitget",
want: "",
}, {
name: "missing key",
configMaker: makeConfigValid,
key: "gitget.missingkey",
want: "",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
cfg := test.configMaker(t)
got := cfg.Get(test.key)
if got != test.want {
t.Errorf("expected %q; got %q", test.want, got)
}
})
}
}
func makeConfigEmpty(t *testing.T) *cfgStub {
c := newCfgStub(t)
c.repo.writeFile(".git/config", "")
return c
}
func makeConfigValid(t *testing.T) *cfgStub {
c := newCfgStub(t)
gitconfig := `
[user]
name = grdl
[gitget]
host = github.com
`
c.repo.writeFile(".git/config", gitconfig)
return c
}

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

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

View File

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,266 +0,0 @@
package repo
import (
"git-get/pkg/cfg"
"sort"
"strings"
"github.com/go-git/go-git/v5/plumbing/revlist"
"github.com/spf13/viper"
"github.com/go-git/go-billy/v5/osfs"
"github.com/go-git/go-git/v5/plumbing/format/gitignore"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
"github.com/pkg/errors"
)
const (
StatusUnknown = "unknown"
StatusDetached = "detached HEAD"
StatusNoUpstream = "no upstream"
StatusAhead = "ahead"
StatusBehind = "behind"
StatusOk = "ok"
StatusUncommitted = "uncommitted"
StatusUntracked = "untracked"
)
type RepoStatus struct {
HasUntrackedFiles bool
HasUncommittedChanges bool
CurrentBranch string
Branches []*BranchStatus
}
type BranchStatus struct {
Name string
Upstream string
Ahead int
Behind int
}
func (r *Repo) LoadStatus() error {
// Fetch from remotes if executed with --fetch flag. Ignore the "already up-to-date" errors.
if viper.GetBool(cfg.KeyFetch) {
err := r.Fetch()
if err != nil && !errors.Is(err, git.NoErrAlreadyUpToDate) {
return errors.Wrapf(err, "failed running git fetch on a repo %s", r.Path)
}
}
wt, err := r.Worktree()
if err != nil {
return errors.Wrapf(err, "failed getting worktree %s", r.Path)
}
// worktree.Status doesn't load gitignore patterns that are defined outside of .gitignore file using excludesfile.
// We need to load them explicitly here
// TODO: variables are not expanded so if excludesfile is declared like "~/gitignore_global" or "$HOME/gitignore_global", this will fail to open it
globalPatterns, err := gitignore.LoadGlobalPatterns(osfs.New(""))
if err != nil {
return errors.Wrap(err, "failed loading global gitignore patterns")
}
wt.Excludes = append(wt.Excludes, globalPatterns...)
systemPatterns, err := gitignore.LoadSystemPatterns(osfs.New(""))
if err != nil {
return errors.Wrap(err, "failed loading system gitignore patterns")
}
wt.Excludes = append(wt.Excludes, systemPatterns...)
status, err := wt.Status()
if err != nil {
return errors.Wrapf(err, "failed getting status of worktree %s", r.Path)
}
r.Status.HasUncommittedChanges = hasUncommitted(status)
r.Status.HasUntrackedFiles = hasUntracked(status)
r.Status.CurrentBranch = currentBranch(r)
err = r.loadBranchesStatus()
if err != nil {
return err
}
return nil
}
// hasUntracked returns true if there are any untracked files in the worktree
func hasUntracked(status git.Status) bool {
for _, fs := range status {
if fs.Worktree == git.Untracked || fs.Staging == git.Untracked {
return true
}
}
return false
}
// hasUncommitted returns true if there are any uncommitted (but tracked) files in the worktree
func hasUncommitted(status git.Status) bool {
// If repo is clean it means every file in worktree and staging has 'Unmodified' state
if status.IsClean() {
return false
}
// If repo is not clean, check if any file has state different than 'Untracked' - it means they are tracked and have uncommitted modifications
for _, fs := range status {
if fs.Worktree != git.Untracked || fs.Staging != git.Untracked {
return true
}
}
return false
}
func currentBranch(r *Repo) string {
head, err := r.Head()
if err != nil {
return StatusUnknown
}
if head.Name().Short() == plumbing.HEAD.String() {
return StatusDetached
}
return head.Name().Short()
}
func (r *Repo) loadBranchesStatus() error {
iter, err := r.Branches()
if err != nil {
return errors.Wrapf(err, "failed getting branches iterator for repo %s", r.Path)
}
err = iter.ForEach(func(reference *plumbing.Reference) error {
bs, err := r.newBranchStatus(reference.Name().Short())
if err != nil {
return err
}
r.Status.Branches = append(r.Status.Branches, bs)
return nil
})
if err != nil {
return errors.Wrapf(err, "failed iterating over branches of repo %s", r.Path)
}
// Sort branches by name (but with "master" always at the top). It's useful to have them sorted for printing and testing.
sort.Slice(r.Status.Branches, func(i, j int) bool {
if r.Status.Branches[i].Name == cfg.DefBranch {
return true
}
return strings.Compare(r.Status.Branches[i].Name, r.Status.Branches[j].Name) < 0
})
return nil
}
func (r *Repo) newBranchStatus(branch string) (*BranchStatus, error) {
bs := &BranchStatus{
Name: branch,
}
upstream, err := r.upstream(branch)
if err != nil {
return nil, err
}
if upstream == "" {
return bs, nil
}
ahead, behind, err := r.aheadBehind(branch, upstream)
if err != nil {
return nil, err
}
bs.Upstream = upstream
bs.Ahead = ahead
bs.Behind = behind
return bs, nil
}
// upstream finds if a given branch tracks an upstream.
// Returns found upstream branch name (eg, origin/master) or empty string if branch doesn't track an upstream.
//
// Information about upstream is taken from .git/config file.
// If a branch has an upstream, there's a [branch] section in the file with two fields:
// "remote" - name of the remote containing upstream branch (or "." if upstream is a local branch)
// "merge" - full ref name of the upstream branch (eg, ref/heads/master)
func (r *Repo) upstream(branch string) (string, error) {
cfg, err := r.Config()
if err != nil {
return "", errors.Wrapf(err, "failed getting config of repo %s", r.Path)
}
// Check if our branch exists in "branch" config sections. If not, it doesn't have an upstream configured.
bcfg := cfg.Branches[branch]
if bcfg == nil {
return "", nil
}
remote := bcfg.Remote
if remote == "" {
return "", nil
}
merge := bcfg.Merge.Short()
if merge == "" {
return "", nil
}
return remote + "/" + merge, nil
}
func (r *Repo) aheadBehind(localBranch string, upstreamBranch string) (ahead int, behind int, err error) {
localHash, err := r.ResolveRevision(plumbing.Revision(localBranch))
if err != nil {
return 0, 0, errors.Wrapf(err, "failed resolving revision %s", localBranch)
}
upstreamHash, err := r.ResolveRevision(plumbing.Revision(upstreamBranch))
if err != nil {
return 0, 0, errors.Wrapf(err, "failed resolving revision %s", upstreamBranch)
}
behind, err = r.revlistCount(*localHash, *upstreamHash)
if err != nil {
return 0, 0, errors.Wrapf(err, "failed counting commits behind %s", upstreamBranch)
}
ahead, err = r.revlistCount(*upstreamHash, *localHash)
if err != nil {
return 0, 0, errors.Wrapf(err, "failed counting commits ahead of %s", upstreamBranch)
}
return ahead, behind, nil
}
// revlistCount counts the number of commits between two hashes.
// https://github.com/src-d/go-git/issues/757#issuecomment-452697701
// TODO: See if this can be optimized. Running the loop twice feels wrong.
func (r *Repo) revlistCount(hash1, hash2 plumbing.Hash) (int, error) {
ref1hist, err := revlist.Objects(r.Storer, []plumbing.Hash{hash1}, nil)
if err != nil {
return 0, err
}
ref2hist, err := revlist.Objects(r.Storer, []plumbing.Hash{hash2}, ref1hist)
if err != nil {
return 0, err
}
count := 0
for _, h := range ref2hist {
if _, err = r.CommitObject(h); err == nil {
count++
}
}
return count, nil
}

View File

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