mirror of
https://github.com/grdl/git-get.git
synced 2026-02-04 15:39: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:
committed by
GitHub
parent
2ef739ea49
commit
8c132cdafa
4
.github/workflows/build.yml
vendored
4
.github/workflows/build.yml
vendored
@@ -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
|
||||
73
README.md
73
README.md
@@ -10,7 +10,8 @@
|
||||
* [Installation](#installation)
|
||||
* [Usage](#usage)
|
||||
* [git get](#git-get-1)
|
||||
* [git-list](#git-list)
|
||||
* [git list](#git-list)
|
||||
* [dump file](#dump-file)
|
||||
* [Configuration](#configuration)
|
||||
* [Env variables](#env-variables)
|
||||
* [.gitconfig file](#.gitconfig-file)
|
||||
@@ -44,47 +45,19 @@ Each release contains two binaries: `git-get` and `git-list`. When put on PATH,
|
||||
git get <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
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
8
go.mod
@@ -3,13 +3,15 @@ module git-get
|
||||
go 1.14
|
||||
|
||||
require (
|
||||
github.com/go-git/go-billy/v5 v5.0.0
|
||||
github.com/go-git/go-git/v5 v5.1.0
|
||||
github.com/karrick/godirwalk v1.15.6
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/mitchellh/go-homedir v1.1.0
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect
|
||||
github.com/pkg/errors v0.9.1
|
||||
github.com/spf13/cobra v1.0.0
|
||||
github.com/spf13/viper v1.7.0
|
||||
github.com/stretchr/testify v1.4.0 // indirect
|
||||
github.com/xlab/treeprint v1.0.0
|
||||
golang.org/x/crypto v0.0.0-20200302210943-78000ba7a073
|
||||
golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect
|
||||
)
|
||||
|
||||
40
go.sum
40
go.sum
@@ -15,18 +15,12 @@ github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
|
||||
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
|
||||
github.com/alcortesm/tgz v0.0.0-20161220082320-9c5fe88206d7 h1:uSoVVbwJiQipAclBbw+8quDsfcvFjOpI5iCf4p/cqCs=
|
||||
github.com/alcortesm/tgz v0.0.0-20161220082320-9c5fe88206d7/go.mod h1:6zEj6s6u/ghQa61ZWa/C2Aw3RkjiTBOix7dkqa1VLIs=
|
||||
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
|
||||
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
|
||||
github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239 h1:kFOfPq6dUM1hTo4JG6LR5AXSUEsOjtdm0kw0FtQtMJA=
|
||||
github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c=
|
||||
github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o=
|
||||
github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
|
||||
github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY=
|
||||
github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
|
||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
|
||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
|
||||
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
|
||||
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
|
||||
github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
|
||||
@@ -48,24 +42,10 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
|
||||
github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no=
|
||||
github.com/emirpasic/gods v1.12.0 h1:QAUIPSaCu4G+POclxeqb3F+WPpdKqFGlw36+yOzGlrg=
|
||||
github.com/emirpasic/gods v1.12.0/go.mod h1:YfzfFFoVP/catgzJb4IKIqXjX78Ha8FMSDh3ymbK86o=
|
||||
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
|
||||
github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568 h1:BHsljHzVlRcyQhjrss6TZTdY2VfCqZPbv5k3iBFa2ZQ=
|
||||
github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc=
|
||||
github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I=
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
|
||||
github.com/gliderlabs/ssh v0.2.2 h1:6zsha5zo/TWhRhwqCD3+EarCAgZ2yN28ipRnGPnwkI0=
|
||||
github.com/gliderlabs/ssh v0.2.2/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aevW3Awn0=
|
||||
github.com/go-git/gcfg v1.5.0 h1:Q5ViNfGF8zFgyJWPqYwA7qGFoMTEiBmdlkcfRmpIMa4=
|
||||
github.com/go-git/gcfg v1.5.0/go.mod h1:5m20vg6GwYabIxaOonVkTdrILxQMpEShl1xiMF4ua+E=
|
||||
github.com/go-git/go-billy/v5 v5.0.0 h1:7NQHvd9FVid8VL4qVUMm8XifBK+2xCoZ2lSk0agRrHM=
|
||||
github.com/go-git/go-billy/v5 v5.0.0/go.mod h1:pmpqyWchKfYfrkb/UVH4otLvyi/5gJlGI4Hb3ZqZ3W0=
|
||||
github.com/go-git/go-git-fixtures/v4 v4.0.1 h1:q+IFMfLx200Q3scvt2hN79JsEzy4AmBTp/pqnefH+Bc=
|
||||
github.com/go-git/go-git-fixtures/v4 v4.0.1/go.mod h1:m+ICp2rF3jDhFgEZ/8yziagdT1C+ZpZcrJjappBCDSw=
|
||||
github.com/go-git/go-git/v5 v5.1.0 h1:HxJn9g/E7eYvKW3Fm7Jt4ee8LXfPOm/H1cdDu8vEssk=
|
||||
github.com/go-git/go-git/v5 v5.1.0/go.mod h1:ZKfuPUoY1ZqIG4QG9BDBh3G4gLM5zvPuSJAozQrZuyM=
|
||||
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
|
||||
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
|
||||
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
|
||||
@@ -120,13 +100,8 @@ github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO
|
||||
github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ=
|
||||
github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I=
|
||||
github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc=
|
||||
github.com/imdario/mergo v0.3.9 h1:UauaLniWCFHWd+Jp9oCEkTBj8VO/9DKg3PV3VCNMDIg=
|
||||
github.com/imdario/mergo v0.3.9/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA=
|
||||
github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM=
|
||||
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
|
||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
|
||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
|
||||
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||
github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo=
|
||||
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
|
||||
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
|
||||
@@ -135,8 +110,6 @@ github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfV
|
||||
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
|
||||
github.com/karrick/godirwalk v1.15.6 h1:Yf2mmR8TJy+8Fa0SuQVto5SYap6IF7lNVX4Jdl8G1qA=
|
||||
github.com/karrick/godirwalk v1.15.6/go.mod h1:j4mkqPuvaLI8mp1DroR3P6ad7cyYd4c1qeJ3RV7ULlk=
|
||||
github.com/kevinburke/ssh_config v0.0.0-20190725054713-01f96b0aa0cd h1:Coekwdh0v2wtGp9Gmz1Ze3eVRAWJMLokvN3QjdzCHLY=
|
||||
github.com/kevinburke/ssh_config v0.0.0-20190725054713-01f96b0aa0cd/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
|
||||
github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||
@@ -195,8 +168,6 @@ github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFR
|
||||
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
|
||||
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
|
||||
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
|
||||
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
|
||||
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
|
||||
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
|
||||
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM=
|
||||
@@ -229,8 +200,6 @@ github.com/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s
|
||||
github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw=
|
||||
github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
|
||||
github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc=
|
||||
github.com/xanzy/ssh-agent v0.2.1 h1:TCbipTQL2JiiCprBWx9frJ2eJlCYT00NmctrHxVAr70=
|
||||
github.com/xanzy/ssh-agent v0.2.1/go.mod h1:mLlQY/MoOhWBj+gOGMQkOeiEvkx+8pJSI+0Bx9h2kr4=
|
||||
github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU=
|
||||
github.com/xlab/treeprint v1.0.0 h1:J0TkWtiuYgtdlrkkrDLISYBQ92M+X5m4LrIIMKrbDTs=
|
||||
github.com/xlab/treeprint v1.0.0/go.mod h1:IoImgRak9i3zJyuxOKUP1v4UZd1tMoKkq/Cimt1uhCg=
|
||||
@@ -243,12 +212,9 @@ go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/
|
||||
go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
|
||||
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.0.0-20190219172222-a4c6cb3142f2/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200302210943-78000ba7a073 h1:xMPOj6Pz6UipU1wXLkrtqpHbR0AVFnyPEQq/wRWz9lM=
|
||||
golang.org/x/crypto v0.0.0-20200302210943-78000ba7a073/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
|
||||
@@ -282,8 +248,6 @@ golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn
|
||||
golang.org/x/net v0.0.0-20190522155817-f3200d17e092/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200301022130-244492dfa37a h1:GuSPYbZzB5/dcLNCwLQLsg3obCJtX9IJhpXkvY7kzk0=
|
||||
golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
@@ -300,7 +264,6 @@ golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5h
|
||||
golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a h1:1BGLXjeY4akVXGgbC9HugT3Jv3hCI0z56oJR5vAMgBU=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190221075227-b4e8571b14e0/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
@@ -360,15 +323,12 @@ google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ij
|
||||
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
|
||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
||||
gopkg.in/ini.v1 v1.51.0 h1:AQvPpx3LzTDM0AjnIRlVFwFFGC+npRopjZxLJj6gdno=
|
||||
gopkg.in/ini.v1 v1.51.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||
gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo=
|
||||
gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
|
||||
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
|
||||
gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74=
|
||||
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// Package cfg provides common configuration to all commands.
|
||||
// It contains config key names, default values and provides methods to read values from global gitconfig file.
|
||||
package cfg
|
||||
|
||||
import (
|
||||
@@ -5,8 +7,6 @@ import (
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/go-git/go-git/v5/config"
|
||||
plumbing "github.com/go-git/go-git/v5/plumbing/format/config"
|
||||
"github.com/mitchellh/go-homedir"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
@@ -17,29 +17,25 @@ const GitgetPrefix = "gitget"
|
||||
// CLI flag keys and their default values.
|
||||
const (
|
||||
KeyBranch = "branch"
|
||||
DefBranch = "master"
|
||||
KeyDump = "dump"
|
||||
KeyDefaultHost = "host"
|
||||
DefDefaultHost = "github.com"
|
||||
KeyFetch = "fetch"
|
||||
KeyOutput = "out"
|
||||
DefOutput = OutTree
|
||||
KeyPrivateKey = "privateKey"
|
||||
DefPrivateKey = "id_rsa"
|
||||
KeyReposRoot = "root"
|
||||
DefReposRoot = "repositories"
|
||||
)
|
||||
|
||||
// Values for the --out flag.
|
||||
const (
|
||||
OutDump = "dump"
|
||||
OutFlat = "flat"
|
||||
OutSmart = "smart"
|
||||
OutTree = "tree"
|
||||
OutDump = "dump"
|
||||
OutFlat = "flat"
|
||||
OutTree = "tree"
|
||||
)
|
||||
|
||||
// AllowedOut are allowed values for the --out flag.
|
||||
var AllowedOut = []string{OutDump, OutFlat, OutSmart, OutTree}
|
||||
var AllowedOut = []string{OutDump, OutFlat, OutTree}
|
||||
|
||||
// Version metadata set by ldflags during the build.
|
||||
var (
|
||||
@@ -58,79 +54,39 @@ func Version() string {
|
||||
return fmt.Sprintf("%s - revision %s built at %s", version, commit[:6], date)
|
||||
}
|
||||
|
||||
// gitconfig provides methods for looking up configiration values inside .gitconfig file
|
||||
type gitconfig struct {
|
||||
*config.Config
|
||||
// Gitconfig represents gitconfig file
|
||||
type Gitconfig interface {
|
||||
Get(key string) string
|
||||
}
|
||||
|
||||
// Init initializes viper config registry. Values are looked up in the following order: cli flag, env variable, gitconfig file, default value
|
||||
// Init initializes viper config registry. Values are looked up in the following order: cli flag, env variable, gitconfig file, default value.
|
||||
// Viper doesn't support gitconfig file format so it can't find missing values there automatically. They need to be specified in setMissingValues func.
|
||||
//
|
||||
// Because it reads the cli flags it needs to be called after the cmd.Execute().
|
||||
func Init() {
|
||||
func Init(cfg Gitconfig) {
|
||||
viper.SetEnvPrefix(strings.ToUpper(GitgetPrefix))
|
||||
viper.AutomaticEnv()
|
||||
|
||||
cfg := loadGitconfig()
|
||||
setMissingValues(cfg)
|
||||
}
|
||||
|
||||
// loadGitconfig loads configuration from a gitconfig file.
|
||||
// We ignore errors when gitconfig file can't be found, opened or parsed. In those cases viper will provide default config values.
|
||||
func loadGitconfig() *gitconfig {
|
||||
// TODO: load system scope
|
||||
cfg, _ := config.LoadConfig(config.GlobalScope)
|
||||
|
||||
return &gitconfig{
|
||||
Config: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
// setMissingValues checks if config values are provided by flags or env vars. If not, it tries loading them from gitconfig file.
|
||||
// If that fails, the default values are used.
|
||||
func setMissingValues(cfg *gitconfig) {
|
||||
func setMissingValues(cfg Gitconfig) {
|
||||
if isUnsetOrEmpty(KeyReposRoot) {
|
||||
viper.Set(KeyReposRoot, cfg.get(KeyReposRoot, path.Join(home(), DefReposRoot)))
|
||||
viper.Set(KeyReposRoot, getOrDef(cfg, KeyReposRoot, path.Join(home(), DefReposRoot)))
|
||||
}
|
||||
|
||||
if isUnsetOrEmpty(KeyDefaultHost) {
|
||||
viper.Set(KeyDefaultHost, cfg.get(KeyDefaultHost, DefDefaultHost))
|
||||
}
|
||||
|
||||
if isUnsetOrEmpty(KeyPrivateKey) {
|
||||
viper.Set(KeyPrivateKey, cfg.get(KeyPrivateKey, path.Join(home(), ".ssh", DefPrivateKey)))
|
||||
viper.Set(KeyDefaultHost, getOrDef(cfg, KeyDefaultHost, DefDefaultHost))
|
||||
}
|
||||
}
|
||||
|
||||
// get looks up the value for a given key in gitconfig file.
|
||||
// It returns the default value when gitconfig is missing, or it doesn't contain a gitget section,
|
||||
// or if the section is empty, or if it doesn't contain a valid value for the key.
|
||||
func (c *gitconfig) get(key string, def string) string {
|
||||
if c == nil || c.Config == nil {
|
||||
return def
|
||||
func getOrDef(cfg Gitconfig, key string, def string) string {
|
||||
if val := cfg.Get(key); val != "" {
|
||||
return val
|
||||
}
|
||||
|
||||
gitget := c.findGitconfigSection(GitgetPrefix)
|
||||
if gitget == nil {
|
||||
return def
|
||||
}
|
||||
|
||||
opt := gitget.Option(key)
|
||||
if strings.TrimSpace(opt) == "" {
|
||||
return def
|
||||
}
|
||||
|
||||
return opt
|
||||
}
|
||||
|
||||
func (c *gitconfig) findGitconfigSection(name string) *plumbing.Section {
|
||||
for _, s := range c.Raw.Sections {
|
||||
if strings.ToLower(s.Name) == strings.ToLower(name) {
|
||||
return s
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
return def
|
||||
}
|
||||
|
||||
// home returns path to a home directory or empty string if can't be found.
|
||||
|
||||
@@ -3,163 +3,115 @@ package cfg
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/go-git/go-git/v5/config"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
var (
|
||||
envDefaultHost = strings.ToUpper(fmt.Sprintf("%s_%s", GitgetPrefix, KeyDefaultHost))
|
||||
envReposRoot = strings.ToUpper(fmt.Sprintf("%s_%s", GitgetPrefix, KeyReposRoot))
|
||||
envVarName = strings.ToUpper(fmt.Sprintf("%s_%s", GitgetPrefix, KeyDefaultHost))
|
||||
fromGitconfig = "value.from.gitconfig"
|
||||
fromEnv = "value.from.env"
|
||||
fromFlag = "value.from.flag"
|
||||
)
|
||||
|
||||
func newConfigWithFullGitconfig() *gitconfig {
|
||||
cfg := config.NewConfig()
|
||||
|
||||
gitget := cfg.Raw.Section(GitgetPrefix)
|
||||
gitget.AddOption(KeyReposRoot, "file.root")
|
||||
gitget.AddOption(KeyDefaultHost, "file.host")
|
||||
|
||||
return &gitconfig{
|
||||
Config: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
func newConfigWithEmptyGitgetSection() *gitconfig {
|
||||
cfg := config.NewConfig()
|
||||
|
||||
_ = cfg.Raw.Section(GitgetPrefix)
|
||||
|
||||
return &gitconfig{
|
||||
Config: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
func newConfigWithEmptyValues() *gitconfig {
|
||||
cfg := config.NewConfig()
|
||||
|
||||
gitget := cfg.Raw.Section(GitgetPrefix)
|
||||
gitget.AddOption(KeyReposRoot, "")
|
||||
gitget.AddOption(KeyDefaultHost, " ")
|
||||
|
||||
return &gitconfig{
|
||||
Config: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
func newConfigWithoutGitgetSection() *gitconfig {
|
||||
cfg := config.NewConfig()
|
||||
|
||||
return &gitconfig{
|
||||
Config: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
func newConfigWithEmptyGitconfig() *gitconfig {
|
||||
return &gitconfig{
|
||||
Config: nil,
|
||||
}
|
||||
}
|
||||
|
||||
func newConfigWithEnvVars() *gitconfig {
|
||||
_ = os.Setenv(envDefaultHost, "env.host")
|
||||
_ = os.Setenv(envReposRoot, "env.root")
|
||||
|
||||
return &gitconfig{
|
||||
Config: nil,
|
||||
}
|
||||
}
|
||||
|
||||
func newConfigWithGitconfigAndEnvVars() *gitconfig {
|
||||
cfg := config.NewConfig()
|
||||
|
||||
gitget := cfg.Raw.Section(GitgetPrefix)
|
||||
gitget.AddOption(KeyReposRoot, "file.root")
|
||||
gitget.AddOption(KeyDefaultHost, "file.host")
|
||||
|
||||
_ = os.Setenv(envDefaultHost, "env.host")
|
||||
_ = os.Setenv(envReposRoot, "env.root")
|
||||
|
||||
return &gitconfig{
|
||||
Config: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
func newConfigWithEmptySectionAndEnvVars() *gitconfig {
|
||||
cfg := config.NewConfig()
|
||||
|
||||
_ = cfg.Raw.Section(GitgetPrefix)
|
||||
|
||||
_ = os.Setenv(envDefaultHost, "env.host")
|
||||
_ = os.Setenv(envReposRoot, "env.root")
|
||||
|
||||
return &gitconfig{
|
||||
Config: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
func newConfigWithMixed() *gitconfig {
|
||||
cfg := config.NewConfig()
|
||||
|
||||
gitget := cfg.Raw.Section(GitgetPrefix)
|
||||
gitget.AddOption(KeyReposRoot, "file.root")
|
||||
gitget.AddOption(KeyDefaultHost, "file.host")
|
||||
|
||||
_ = os.Setenv(envDefaultHost, "env.host")
|
||||
|
||||
return &gitconfig{
|
||||
Config: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfig(t *testing.T) {
|
||||
defReposRoot := path.Join(home(), DefReposRoot)
|
||||
|
||||
var tests = []struct {
|
||||
makeConfig func() *gitconfig
|
||||
wantReposRoot string
|
||||
wantDefaultHost string
|
||||
tests := []struct {
|
||||
name string
|
||||
configMaker func(*testing.T)
|
||||
key string
|
||||
want string
|
||||
}{
|
||||
{newConfigWithFullGitconfig, "file.root", "file.host"},
|
||||
{newConfigWithoutGitgetSection, defReposRoot, DefDefaultHost},
|
||||
{newConfigWithEmptyGitconfig, defReposRoot, DefDefaultHost},
|
||||
{newConfigWithEnvVars, "env.root", "env.host"},
|
||||
{newConfigWithGitconfigAndEnvVars, "env.root", "env.host"},
|
||||
{newConfigWithEmptySectionAndEnvVars, "env.root", "env.host"},
|
||||
{newConfigWithEmptyGitgetSection, defReposRoot, DefDefaultHost},
|
||||
{newConfigWithEmptyValues, defReposRoot, DefDefaultHost},
|
||||
{newConfigWithMixed, "file.root", "env.host"},
|
||||
{
|
||||
name: "no config",
|
||||
configMaker: testConfigEmpty,
|
||||
key: KeyDefaultHost,
|
||||
want: DefDefaultHost,
|
||||
},
|
||||
{
|
||||
name: "value only in gitconfig",
|
||||
configMaker: testConfigOnlyInGitconfig,
|
||||
key: KeyDefaultHost,
|
||||
want: fromGitconfig,
|
||||
},
|
||||
{
|
||||
name: "value only in env var",
|
||||
configMaker: testConfigOnlyInEnvVar,
|
||||
key: KeyDefaultHost,
|
||||
want: fromEnv,
|
||||
},
|
||||
{
|
||||
name: "value in gitconfig and env var",
|
||||
configMaker: testConfigInGitconfigAndEnvVar,
|
||||
key: KeyDefaultHost,
|
||||
want: fromEnv,
|
||||
},
|
||||
{
|
||||
name: "value in flag",
|
||||
configMaker: testConfigInFlag,
|
||||
key: KeyDefaultHost,
|
||||
want: fromFlag,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
viper.SetEnvPrefix(strings.ToUpper(GitgetPrefix))
|
||||
viper.AutomaticEnv()
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
test.configMaker(t)
|
||||
|
||||
cfg := test.makeConfig()
|
||||
setMissingValues(cfg)
|
||||
got := viper.GetString(test.key)
|
||||
if got != test.want {
|
||||
t.Errorf("expected %q; got %q", test.want, got)
|
||||
}
|
||||
|
||||
if viper.GetString(KeyDefaultHost) != test.wantDefaultHost {
|
||||
t.Errorf("Wrong %s value, got: %s; want: %s", KeyDefaultHost, viper.GetString(KeyDefaultHost), test.wantDefaultHost)
|
||||
}
|
||||
|
||||
if viper.GetString(KeyReposRoot) != test.wantReposRoot {
|
||||
t.Errorf("Wrong %s value, got: %s; want: %s", KeyReposRoot, viper.GetString(KeyReposRoot), test.wantReposRoot)
|
||||
}
|
||||
|
||||
// Unset env variables and reset viper registry after each test
|
||||
viper.Reset()
|
||||
err := os.Unsetenv(envDefaultHost)
|
||||
checkFatal(t, err)
|
||||
err = os.Unsetenv(envReposRoot)
|
||||
checkFatal(t, err)
|
||||
// Clear env variables and reset viper registry after each test so they impact other tests.
|
||||
os.Clearenv()
|
||||
viper.Reset()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func checkFatal(t *testing.T, err error) {
|
||||
if err != nil {
|
||||
t.Fatalf("%+v", err)
|
||||
}
|
||||
type gitconfigEmpty struct{}
|
||||
|
||||
func (c *gitconfigEmpty) Get(key string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
type gitconfigValid struct{}
|
||||
|
||||
func (c *gitconfigValid) Get(key string) string {
|
||||
return fromGitconfig
|
||||
}
|
||||
|
||||
func testConfigEmpty(t *testing.T) {
|
||||
Init(&gitconfigEmpty{})
|
||||
}
|
||||
|
||||
func testConfigOnlyInGitconfig(t *testing.T) {
|
||||
Init(&gitconfigValid{})
|
||||
}
|
||||
|
||||
func testConfigOnlyInEnvVar(t *testing.T) {
|
||||
os.Setenv(envVarName, fromEnv)
|
||||
|
||||
Init(&gitconfigEmpty{})
|
||||
}
|
||||
|
||||
func testConfigInGitconfigAndEnvVar(t *testing.T) {
|
||||
os.Setenv(envVarName, fromEnv)
|
||||
|
||||
Init(&gitconfigValid{})
|
||||
}
|
||||
|
||||
func testConfigInFlag(t *testing.T) {
|
||||
os.Setenv(envVarName, fromEnv)
|
||||
|
||||
cmd := cobra.Command{}
|
||||
cmd.PersistentFlags().String(KeyDefaultHost, DefDefaultHost, "")
|
||||
viper.BindPFlag(KeyDefaultHost, cmd.PersistentFlags().Lookup(KeyDefaultHost))
|
||||
|
||||
cmd.SetArgs([]string{"--" + KeyDefaultHost, fromFlag})
|
||||
cmd.Execute()
|
||||
Init(&gitconfigValid{})
|
||||
}
|
||||
|
||||
10
pkg/get.go
10
pkg/get.go
@@ -2,7 +2,7 @@ package pkg
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"git-get/pkg/repo"
|
||||
"git-get/pkg/git"
|
||||
"path"
|
||||
)
|
||||
|
||||
@@ -37,13 +37,13 @@ func cloneSingleRepo(c *GetCfg) error {
|
||||
return err
|
||||
}
|
||||
|
||||
cloneOpts := &repo.CloneOpts{
|
||||
cloneOpts := &git.CloneOpts{
|
||||
URL: url,
|
||||
Path: path.Join(c.Root, URLToPath(url)),
|
||||
Branch: c.Branch,
|
||||
}
|
||||
|
||||
_, err = repo.Clone(cloneOpts)
|
||||
_, err = git.Clone(cloneOpts)
|
||||
|
||||
return err
|
||||
}
|
||||
@@ -60,14 +60,14 @@ func cloneDumpFile(c *GetCfg) error {
|
||||
return err
|
||||
}
|
||||
|
||||
cloneOpts := &repo.CloneOpts{
|
||||
cloneOpts := &git.CloneOpts{
|
||||
URL: url,
|
||||
Path: path.Join(c.Root, URLToPath(url)),
|
||||
Branch: line.branch,
|
||||
IgnoreExisting: true,
|
||||
}
|
||||
|
||||
_, err = repo.Clone(cloneOpts)
|
||||
_, err = git.Clone(cloneOpts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
20
pkg/git/config.go
Normal file
20
pkg/git/config.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package git
|
||||
|
||||
import "os/exec"
|
||||
|
||||
// ConfigGlobal represents a global gitconfig file.
|
||||
type ConfigGlobal struct{}
|
||||
|
||||
// Get reads a value from global gitconfig file. Returns empty string when key is missing.
|
||||
func (c *ConfigGlobal) Get(key string) string {
|
||||
cmd := exec.Command("git", "config", "--global", key)
|
||||
out, err := cmd.Output()
|
||||
|
||||
// In case of error return an empty string, the missing value will fall back to a default.
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
lines := lines(out)
|
||||
return lines[0]
|
||||
}
|
||||
93
pkg/git/config_test.go
Normal file
93
pkg/git/config_test.go
Normal file
@@ -0,0 +1,93 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// cfgStub represents a gitconfig file but instead of using a global one, it creates a temporary git repo and uses its local gitconfig.
|
||||
type cfgStub struct {
|
||||
repo *testRepo
|
||||
}
|
||||
|
||||
func newCfgStub(t *testing.T) *cfgStub {
|
||||
r := testRepoEmpty(t)
|
||||
return &cfgStub{
|
||||
repo: r,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *cfgStub) Get(key string) string {
|
||||
cmd := gitCmd(c.repo.path, "config", "--local", key)
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
lines := lines(out)
|
||||
return lines[0]
|
||||
}
|
||||
|
||||
func TestGitConfig(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
configMaker func(t *testing.T) *cfgStub
|
||||
key string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "empty",
|
||||
configMaker: makeConfigEmpty,
|
||||
key: "gitget.host",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "valid",
|
||||
configMaker: makeConfigValid,
|
||||
key: "gitget.host",
|
||||
want: "github.com",
|
||||
}, {
|
||||
name: "only section name",
|
||||
configMaker: makeConfigValid,
|
||||
key: "gitget",
|
||||
want: "",
|
||||
}, {
|
||||
name: "missing key",
|
||||
configMaker: makeConfigValid,
|
||||
key: "gitget.missingkey",
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
cfg := test.configMaker(t)
|
||||
|
||||
got := cfg.Get(test.key)
|
||||
|
||||
if got != test.want {
|
||||
t.Errorf("expected %q; got %q", test.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func makeConfigEmpty(t *testing.T) *cfgStub {
|
||||
c := newCfgStub(t)
|
||||
c.repo.writeFile(".git/config", "")
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
func makeConfigValid(t *testing.T) *cfgStub {
|
||||
c := newCfgStub(t)
|
||||
|
||||
gitconfig := `
|
||||
[user]
|
||||
name = grdl
|
||||
[gitget]
|
||||
host = github.com
|
||||
`
|
||||
c.repo.writeFile(".git/config", gitconfig)
|
||||
|
||||
return c
|
||||
}
|
||||
245
pkg/git/repo.go
Normal file
245
pkg/git/repo.go
Normal file
@@ -0,0 +1,245 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"git-get/pkg/io"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
const (
|
||||
dotgit = ".git"
|
||||
untracked = "??" // Untracked files are marked as "??" in git status output.
|
||||
master = "master"
|
||||
head = "HEAD"
|
||||
)
|
||||
|
||||
// Repo represents a git repository on disk.
|
||||
type Repo struct {
|
||||
path string
|
||||
}
|
||||
|
||||
// CloneOpts specify detail about repository to clone.
|
||||
type CloneOpts struct {
|
||||
URL *url.URL
|
||||
Path string // TODO: should Path be a part of clone opts?
|
||||
Branch string
|
||||
Quiet bool
|
||||
IgnoreExisting bool
|
||||
}
|
||||
|
||||
// Open checks if given path can be accessed and returns a Repo instance pointing to it.
|
||||
func Open(path string) (*Repo, error) {
|
||||
_, err := io.Exists(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Repo{
|
||||
path: path,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Clone clones repository specified with CloneOpts.
|
||||
func Clone(opts *CloneOpts) (*Repo, error) {
|
||||
// TODO: not sure if this check should be here
|
||||
if opts.IgnoreExisting {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
args := []string{"clone", "--progress", "-v"}
|
||||
|
||||
if opts.Branch != "" {
|
||||
args = append(args, "--branch", opts.Branch, "--single-branch")
|
||||
}
|
||||
|
||||
if opts.Quiet {
|
||||
args = append(args, "--quiet")
|
||||
}
|
||||
|
||||
args = append(args, opts.URL.String())
|
||||
args = append(args, opts.Path)
|
||||
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
|
||||
err := cmd.Run()
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, "git clone failed")
|
||||
}
|
||||
|
||||
repo, err := Open(opts.Path)
|
||||
return repo, err
|
||||
}
|
||||
|
||||
// Fetch preforms a git fetch on all remotes
|
||||
func (r *Repo) Fetch() error {
|
||||
cmd := gitCmd(r.path, "fetch", "--all", "--quiet")
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
// Uncommitted returns the number of uncommitted files in the repository.
|
||||
// Only tracked files are not counted.
|
||||
func (r *Repo) Uncommitted() (int, error) {
|
||||
cmd := gitCmd(r.path, "status", "--ignore-submodules", "--porcelain")
|
||||
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return 0, cmdError(cmd, err)
|
||||
}
|
||||
|
||||
lines := lines(out)
|
||||
count := 0
|
||||
for _, line := range lines {
|
||||
// Don't count lines with untracked files and empty lines.
|
||||
if !strings.HasPrefix(line, untracked) && strings.TrimSpace(line) != "" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// Untracked returns the number of untracked files in the repository.
|
||||
func (r *Repo) Untracked() (int, error) {
|
||||
cmd := gitCmd(r.path, "status", "--ignore-submodules", "--untracked-files=all", "--porcelain")
|
||||
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return 0, cmdError(cmd, err)
|
||||
}
|
||||
|
||||
lines := lines(out)
|
||||
count := 0
|
||||
for _, line := range lines {
|
||||
if strings.HasPrefix(line, untracked) {
|
||||
count++
|
||||
}
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// CurrentBranch returns the short name currently checked-out branch for the repository.
|
||||
// If repo is in a detached head state, it will return "HEAD".
|
||||
func (r *Repo) CurrentBranch() (string, error) {
|
||||
cmd := gitCmd(r.path, "rev-parse", "--symbolic-full-name", "--abbrev-ref", "HEAD")
|
||||
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return "", cmdError(cmd, err)
|
||||
}
|
||||
|
||||
lines := lines(out)
|
||||
return lines[0], nil
|
||||
}
|
||||
|
||||
// Branches returns a list of local branches in the repository.
|
||||
func (r *Repo) Branches() ([]string, error) {
|
||||
cmd := gitCmd(r.path, "branch", "--format=%(refname:short)")
|
||||
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil, cmdError(cmd, err)
|
||||
}
|
||||
|
||||
lines := lines(out)
|
||||
|
||||
// TODO: Is detached head shown always on the first line? Maybe we don't need to iterate over everything.
|
||||
// Remove the line containing detached head.
|
||||
for i, line := range lines {
|
||||
if strings.Contains(line, "HEAD detached") {
|
||||
lines = append(lines[:i], lines[i+1:]...)
|
||||
}
|
||||
}
|
||||
|
||||
return lines, nil
|
||||
}
|
||||
|
||||
// Upstream returns the name of an upstream branch if a given branch is tracking one.
|
||||
// Otherwise it returns an empty string.
|
||||
func (r *Repo) Upstream(branch string) (string, error) {
|
||||
cmd := gitCmd(r.path, "rev-parse", "--abbrev-ref", "--symbolic-full-name", fmt.Sprintf("%s@{upstream}", branch))
|
||||
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
|
||||
// TODO: no upstream will also throw an error.
|
||||
return "", nil //cmdError(cmd, err)
|
||||
}
|
||||
|
||||
lines := lines(out)
|
||||
return lines[0], nil
|
||||
}
|
||||
|
||||
// AheadBehind returns the number of commits a given branch is ahead and/or behind the upstream.
|
||||
func (r *Repo) AheadBehind(branch string, upstream string) (int, int, error) {
|
||||
cmd := gitCmd(r.path, "rev-list", "--left-right", "--count", fmt.Sprintf("%s...%s", branch, upstream))
|
||||
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return 0, 0, cmdError(cmd, err)
|
||||
}
|
||||
|
||||
lines := lines(out)
|
||||
|
||||
// rev-list --left-right --count output is separated by a tab
|
||||
lr := strings.Split(lines[0], "\t")
|
||||
|
||||
ahead, err := strconv.Atoi(lr[0])
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
|
||||
behind, err := strconv.Atoi(lr[1])
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
|
||||
return ahead, behind, nil
|
||||
}
|
||||
|
||||
// Remote returns URL of remote repository.
|
||||
func (r *Repo) Remote() (string, error) {
|
||||
// https://stackoverflow.com/a/16880000/1085632
|
||||
cmd := gitCmd(r.path, "ls-remote", "--get-url")
|
||||
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return "", cmdError(cmd, err)
|
||||
}
|
||||
|
||||
lines := lines(out)
|
||||
|
||||
// TODO: needs testing. What happens when there are more than 1 remotes?
|
||||
return lines[0], nil
|
||||
}
|
||||
|
||||
// Path returns path to the repository.
|
||||
func (r *Repo) Path() string {
|
||||
return r.path
|
||||
}
|
||||
|
||||
func gitCmd(repoPath string, args ...string) *exec.Cmd {
|
||||
args = append([]string{"--work-tree", repoPath, "--git-dir", path.Join(repoPath, dotgit)}, args...)
|
||||
return exec.Command("git", args...)
|
||||
}
|
||||
|
||||
func lines(output []byte) []string {
|
||||
lines := strings.TrimSuffix(string(output), "\n")
|
||||
return strings.Split(lines, "\n")
|
||||
}
|
||||
|
||||
func cmdError(cmd *exec.Cmd, err error) error {
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "%s failed: %+v", strings.Join(cmd.Args, " "), err) // Show which git command failed (skip "--work-tree and --gitdir flags")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
245
pkg/git/repo_helpers_test.go
Normal file
245
pkg/git/repo_helpers_test.go
Normal file
@@ -0,0 +1,245 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"git-get/pkg/io"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// testRepo embeds testing.T into a Repo instance to simplify creation of test repos.
|
||||
// Any error thrown while creating a test repo will cause a t.Fatal call.
|
||||
type testRepo struct {
|
||||
*Repo
|
||||
*testing.T
|
||||
}
|
||||
|
||||
// TODO: this should be a method of a tempDir, not a repo
|
||||
// Automatically remove test repo when the test is over
|
||||
func (r *testRepo) cleanup() {
|
||||
err := os.RemoveAll(r.path)
|
||||
if err != nil {
|
||||
r.T.Errorf("failed removing test repo directory %s", r.path)
|
||||
}
|
||||
}
|
||||
|
||||
func testRepoEmpty(t *testing.T) *testRepo {
|
||||
dir, err := io.TempDir()
|
||||
checkFatal(t, err)
|
||||
|
||||
r, err := Open(dir)
|
||||
checkFatal(t, err)
|
||||
|
||||
tr := &testRepo{
|
||||
Repo: r,
|
||||
T: t,
|
||||
}
|
||||
|
||||
t.Cleanup(tr.cleanup)
|
||||
|
||||
tr.init()
|
||||
return tr
|
||||
}
|
||||
|
||||
func testRepoWithUntracked(t *testing.T) *testRepo {
|
||||
r := testRepoEmpty(t)
|
||||
r.writeFile("README.md", "I'm a readme file")
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
func testRepoWithStaged(t *testing.T) *testRepo {
|
||||
r := testRepoEmpty(t)
|
||||
r.writeFile("README.md", "I'm a readme file")
|
||||
r.stageFile("README.md")
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
func testRepoWithCommit(t *testing.T) *testRepo {
|
||||
r := testRepoEmpty(t)
|
||||
r.writeFile("README.md", "I'm a readme file")
|
||||
r.stageFile("README.md")
|
||||
r.commit("Initial commit")
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
func testRepoWithUncommittedAndUntracked(t *testing.T) *testRepo {
|
||||
r := testRepoEmpty(t)
|
||||
r.writeFile("README.md", "I'm a readme file")
|
||||
r.stageFile("README.md")
|
||||
r.commit("Initial commit")
|
||||
r.writeFile("README.md", "These changes won't be committed")
|
||||
r.writeFile("untracked.txt", "I'm untracked")
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
func testRepoWithBranch(t *testing.T) *testRepo {
|
||||
r := testRepoWithCommit(t)
|
||||
r.branch("feature/branch")
|
||||
r.checkout("feature/branch")
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
func testRepoWithTag(t *testing.T) *testRepo {
|
||||
r := testRepoWithCommit(t)
|
||||
r.tag("v0.0.1")
|
||||
r.checkout("v0.0.1")
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
func testRepoWithBranchWithUpstream(t *testing.T) *testRepo {
|
||||
origin := testRepoWithCommit(t)
|
||||
origin.branch("feature/branch")
|
||||
|
||||
r := origin.clone()
|
||||
r.checkout("feature/branch")
|
||||
return r
|
||||
}
|
||||
|
||||
func testRepoWithBranchWithoutUpstream(t *testing.T) *testRepo {
|
||||
origin := testRepoWithCommit(t)
|
||||
|
||||
r := origin.clone()
|
||||
r.branch("feature/branch")
|
||||
r.checkout("feature/branch")
|
||||
return r
|
||||
}
|
||||
|
||||
func testRepoWithBranchAhead(t *testing.T) *testRepo {
|
||||
origin := testRepoWithCommit(t)
|
||||
origin.branch("feature/branch")
|
||||
|
||||
r := origin.clone()
|
||||
r.checkout("feature/branch")
|
||||
|
||||
r.writeFile("local.new", "local.new")
|
||||
r.stageFile("local.new")
|
||||
r.commit("local.new")
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
func testRepoWithBranchBehind(t *testing.T) *testRepo {
|
||||
origin := testRepoWithCommit(t)
|
||||
origin.branch("feature/branch")
|
||||
origin.checkout("feature/branch")
|
||||
|
||||
r := origin.clone()
|
||||
r.checkout("feature/branch")
|
||||
|
||||
origin.writeFile("origin.new", "origin.new")
|
||||
origin.stageFile("origin.new")
|
||||
origin.commit("origin.new")
|
||||
|
||||
err := r.Fetch()
|
||||
checkFatal(r.T, err)
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
// returns a repo with 2 commits ahead and 1 behind
|
||||
func testRepoWithBranchAheadAndBehind(t *testing.T) *testRepo {
|
||||
origin := testRepoWithCommit(t)
|
||||
origin.branch("feature/branch")
|
||||
origin.checkout("feature/branch")
|
||||
|
||||
r := origin.clone()
|
||||
r.checkout("feature/branch")
|
||||
|
||||
origin.writeFile("origin.new", "origin.new")
|
||||
origin.stageFile("origin.new")
|
||||
origin.commit("origin.new")
|
||||
|
||||
r.writeFile("local.new", "local.new")
|
||||
r.stageFile("local.new")
|
||||
r.commit("local.new")
|
||||
|
||||
r.writeFile("local.new2", "local.new2")
|
||||
r.stageFile("local.new2")
|
||||
r.commit("local.new2")
|
||||
|
||||
err := r.Fetch()
|
||||
checkFatal(r.T, err)
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
func (r *testRepo) writeFile(filename string, content string) {
|
||||
path := path.Join(r.path, filename)
|
||||
err := io.Write(path, content)
|
||||
checkFatal(r.T, err)
|
||||
}
|
||||
|
||||
func (r *testRepo) init() {
|
||||
cmd := exec.Command("git", "init", "--quiet", r.path)
|
||||
runGitCmd(r.T, cmd)
|
||||
}
|
||||
|
||||
func (r *testRepo) stageFile(path string) {
|
||||
cmd := gitCmd(r.path, "add", path)
|
||||
runGitCmd(r.T, cmd)
|
||||
}
|
||||
|
||||
func (r *testRepo) commit(msg string) {
|
||||
cmd := gitCmd(r.path, "commit", "-m", fmt.Sprintf("%q", msg), "--author=\"user <user@example.com>\"")
|
||||
runGitCmd(r.T, cmd)
|
||||
}
|
||||
|
||||
func (r *testRepo) branch(name string) {
|
||||
cmd := gitCmd(r.path, "branch", name)
|
||||
runGitCmd(r.T, cmd)
|
||||
}
|
||||
|
||||
func (r *testRepo) tag(name string) {
|
||||
cmd := gitCmd(r.path, "tag", "-a", name, "-m", name)
|
||||
runGitCmd(r.T, cmd)
|
||||
}
|
||||
|
||||
func (r *testRepo) checkout(name string) {
|
||||
cmd := gitCmd(r.path, "checkout", name)
|
||||
runGitCmd(r.T, cmd)
|
||||
}
|
||||
|
||||
func (r *testRepo) clone() *testRepo {
|
||||
dir, err := io.TempDir()
|
||||
checkFatal(r.T, err)
|
||||
|
||||
url, err := url.Parse(fmt.Sprintf("file://%s/.git", r.path))
|
||||
checkFatal(r.T, err)
|
||||
|
||||
opts := &CloneOpts{
|
||||
URL: url,
|
||||
Quiet: true,
|
||||
Path: dir,
|
||||
}
|
||||
|
||||
repo, err := Clone(opts)
|
||||
checkFatal(r.T, err)
|
||||
|
||||
tr := &testRepo{
|
||||
Repo: repo,
|
||||
T: r.T,
|
||||
}
|
||||
|
||||
tr.T.Cleanup(tr.cleanup)
|
||||
return tr
|
||||
}
|
||||
|
||||
func runGitCmd(t *testing.T, cmd *exec.Cmd) {
|
||||
err := cmd.Run()
|
||||
checkFatal(t, cmdError(cmd, err))
|
||||
}
|
||||
|
||||
func checkFatal(t *testing.T, err error) {
|
||||
if err != nil {
|
||||
t.Fatalf("%+v", err)
|
||||
}
|
||||
}
|
||||
309
pkg/git/repo_test.go
Normal file
309
pkg/git/repo_test.go
Normal file
@@ -0,0 +1,309 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"git-get/pkg/io"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestOpen(t *testing.T) {
|
||||
_, err := Open("/paththatdoesnotexist/repo")
|
||||
|
||||
if err != io.ErrDirectoryAccess {
|
||||
t.Errorf("Opening a repo in non existing path should throw an error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUncommitted(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
repoMaker func(*testing.T) *testRepo
|
||||
want int
|
||||
}{
|
||||
{
|
||||
name: "empty",
|
||||
repoMaker: testRepoEmpty,
|
||||
want: 0,
|
||||
},
|
||||
{
|
||||
name: "single untracked",
|
||||
repoMaker: testRepoWithUntracked,
|
||||
want: 0,
|
||||
},
|
||||
{
|
||||
name: "single tracked ",
|
||||
repoMaker: testRepoWithStaged,
|
||||
want: 1,
|
||||
},
|
||||
{
|
||||
name: "committed",
|
||||
repoMaker: testRepoWithCommit,
|
||||
want: 0,
|
||||
},
|
||||
{
|
||||
name: "untracked and uncommitted",
|
||||
repoMaker: testRepoWithUncommittedAndUntracked,
|
||||
want: 1,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
r := test.repoMaker(t)
|
||||
got, err := r.Uncommitted()
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("got error %q", err)
|
||||
}
|
||||
|
||||
if got != test.want {
|
||||
t.Errorf("expected %d; got %d", test.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
func TestUntracked(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
repoMaker func(*testing.T) *testRepo
|
||||
want int
|
||||
}{
|
||||
{
|
||||
name: "empty",
|
||||
repoMaker: testRepoEmpty,
|
||||
want: 0,
|
||||
},
|
||||
{
|
||||
name: "single untracked",
|
||||
repoMaker: testRepoWithUntracked,
|
||||
want: 0,
|
||||
},
|
||||
{
|
||||
name: "single tracked ",
|
||||
repoMaker: testRepoWithStaged,
|
||||
want: 1,
|
||||
},
|
||||
{
|
||||
name: "committed",
|
||||
repoMaker: testRepoWithCommit,
|
||||
want: 0,
|
||||
},
|
||||
{
|
||||
name: "untracked and uncommitted",
|
||||
repoMaker: testRepoWithUncommittedAndUntracked,
|
||||
want: 1,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
r := test.repoMaker(t)
|
||||
got, err := r.Uncommitted()
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("got error %q", err)
|
||||
}
|
||||
|
||||
if got != test.want {
|
||||
t.Errorf("expected %d; got %d", test.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrentBranch(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
repoMaker func(*testing.T) *testRepo
|
||||
want string
|
||||
}{
|
||||
// TODO: maybe add wantErr to check if error is returned correctly?
|
||||
// {
|
||||
// name: "empty",
|
||||
// repoMaker: newTestRepo,
|
||||
// want: "",
|
||||
// },
|
||||
{
|
||||
name: "only master branch",
|
||||
repoMaker: testRepoWithCommit,
|
||||
want: master,
|
||||
},
|
||||
{
|
||||
name: "checked out new branch",
|
||||
repoMaker: testRepoWithBranch,
|
||||
want: "feature/branch",
|
||||
},
|
||||
{
|
||||
name: "checked out new tag",
|
||||
repoMaker: testRepoWithTag,
|
||||
want: head,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
r := test.repoMaker(t)
|
||||
got, err := r.CurrentBranch()
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("got error %q", err)
|
||||
}
|
||||
|
||||
if got != test.want {
|
||||
t.Errorf("expected %q; got %q", test.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
func TestBranches(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
repoMaker func(*testing.T) *testRepo
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "empty",
|
||||
repoMaker: testRepoEmpty,
|
||||
want: []string{""},
|
||||
},
|
||||
{
|
||||
name: "only master branch",
|
||||
repoMaker: testRepoWithCommit,
|
||||
want: []string{"master"},
|
||||
},
|
||||
{
|
||||
name: "new branch",
|
||||
repoMaker: testRepoWithBranch,
|
||||
want: []string{"feature/branch", "master"},
|
||||
},
|
||||
{
|
||||
name: "checked out new tag",
|
||||
repoMaker: testRepoWithTag,
|
||||
want: []string{"master"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
r := test.repoMaker(t)
|
||||
got, err := r.Branches()
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("got error %q", err)
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(got, test.want) {
|
||||
t.Errorf("expected %+v; got %+v", test.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
func TestUpstream(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
repoMaker func(*testing.T) *testRepo
|
||||
branch string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "empty",
|
||||
repoMaker: testRepoEmpty,
|
||||
branch: "master",
|
||||
want: "",
|
||||
},
|
||||
// TODO: add wantErr
|
||||
{
|
||||
name: "wrong branch name",
|
||||
repoMaker: testRepoWithCommit,
|
||||
branch: "wrong_branch_name",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "master with upstream",
|
||||
repoMaker: testRepoWithBranchWithUpstream,
|
||||
branch: "master",
|
||||
want: "origin/master",
|
||||
},
|
||||
{
|
||||
name: "branch with upstream",
|
||||
repoMaker: testRepoWithBranchWithUpstream,
|
||||
branch: "feature/branch",
|
||||
want: "origin/feature/branch",
|
||||
},
|
||||
{
|
||||
name: "branch without upstream",
|
||||
repoMaker: testRepoWithBranchWithoutUpstream,
|
||||
branch: "feature/branch",
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
r := test.repoMaker(t)
|
||||
got, _ := r.Upstream(test.branch)
|
||||
|
||||
// TODO:
|
||||
// if err != nil {
|
||||
// t.Errorf("got error %q", err)
|
||||
// }
|
||||
|
||||
if !reflect.DeepEqual(got, test.want) {
|
||||
t.Errorf("expected %+v; got %+v", test.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
func TestAheadBehind(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
repoMaker func(*testing.T) *testRepo
|
||||
branch string
|
||||
want []int
|
||||
}{
|
||||
{
|
||||
name: "fresh clone",
|
||||
repoMaker: testRepoWithBranchWithUpstream,
|
||||
branch: "master",
|
||||
want: []int{0, 0},
|
||||
},
|
||||
{
|
||||
name: "branch ahead",
|
||||
repoMaker: testRepoWithBranchAhead,
|
||||
branch: "feature/branch",
|
||||
want: []int{1, 0},
|
||||
},
|
||||
|
||||
{
|
||||
name: "branch behind",
|
||||
repoMaker: testRepoWithBranchBehind,
|
||||
branch: "feature/branch",
|
||||
want: []int{0, 1},
|
||||
},
|
||||
{
|
||||
name: "branch ahead and behind",
|
||||
repoMaker: testRepoWithBranchAheadAndBehind,
|
||||
branch: "feature/branch",
|
||||
want: []int{2, 1},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
r := test.repoMaker(t)
|
||||
upstream, err := r.Upstream(test.branch)
|
||||
if err != nil {
|
||||
t.Errorf("got error %q", err)
|
||||
}
|
||||
|
||||
ahead, behind, err := r.AheadBehind(test.branch, upstream)
|
||||
if err != nil {
|
||||
t.Errorf("got error %q", err)
|
||||
}
|
||||
|
||||
if ahead != test.want[0] || behind != test.want[1] {
|
||||
t.Errorf("expected %+v; got [%d, %d]", test.want, ahead, behind)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
121
pkg/io/io.go
Normal file
121
pkg/io/io.go
Normal file
@@ -0,0 +1,121 @@
|
||||
// Package io provides functions to read, write and search files and directories.
|
||||
package io
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"github.com/karrick/godirwalk"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
// ErrSkipNode is used as an error indicating that .git directory has been found.
|
||||
// It's handled by ErrorsCallback to tell the WalkCallback to skip this dir.
|
||||
var ErrSkipNode = errors.New(".git directory found, skipping this node")
|
||||
|
||||
// ErrDirectoryAccess indicated a direcotry doesn't exists or can't be accessed
|
||||
var ErrDirectoryAccess = errors.New("directory doesn't exist or can't be accessed")
|
||||
|
||||
// TempDir creates a temporary directory for test repos.
|
||||
func TempDir() (string, error) {
|
||||
dir, err := ioutil.TempDir("", "git-get-repo-")
|
||||
if err != nil {
|
||||
return "", errors.Wrap(err, "failed creating test repo directory")
|
||||
}
|
||||
|
||||
return dir, nil
|
||||
}
|
||||
|
||||
// Write writes string content into a file. If file doesn't exists, it will create it.
|
||||
func Write(path string, content string) error {
|
||||
file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "failed opening a file for writing %s", path)
|
||||
}
|
||||
|
||||
_, err = file.Write([]byte(content))
|
||||
if err != nil {
|
||||
errors.Wrapf(err, "Failed writing to a file %s", path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Exists returns true if a directory exists. If it doesn't or the directory can't be accessed it returns an error.
|
||||
func Exists(path string) (bool, error) {
|
||||
_, err := os.Stat(path)
|
||||
|
||||
if err == nil {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return false, ErrDirectoryAccess
|
||||
}
|
||||
}
|
||||
|
||||
// Directory exists but can't be accessed
|
||||
return true, ErrDirectoryAccess
|
||||
}
|
||||
|
||||
// RepoFinder finds paths to git repos inside given path.
|
||||
type RepoFinder struct {
|
||||
root string
|
||||
repos []string
|
||||
}
|
||||
|
||||
// NewRepoFinder returns a RepoFinder pointed at given root path.
|
||||
func NewRepoFinder(root string) *RepoFinder {
|
||||
return &RepoFinder{
|
||||
root: root,
|
||||
}
|
||||
}
|
||||
|
||||
// Find returns a sorted list of paths to git repos found inside a given root path.
|
||||
// Returns error if root repo path can't be found or accessed.
|
||||
func (r *RepoFinder) Find() ([]string, error) {
|
||||
if _, err := Exists(r.root); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
walkOpts := &godirwalk.Options{
|
||||
ErrorCallback: r.errorCb,
|
||||
Callback: r.walkCb,
|
||||
// Use Unsorted to improve speed because repos will be processed by goroutines in a random order anyway.
|
||||
Unsorted: true,
|
||||
}
|
||||
|
||||
err := godirwalk.Walk(r.root, walkOpts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(r.repos) == 0 {
|
||||
return nil, fmt.Errorf("no git repos found in root path %s", r.root)
|
||||
}
|
||||
|
||||
sort.Strings(r.repos)
|
||||
|
||||
return r.repos, nil
|
||||
}
|
||||
|
||||
func (r *RepoFinder) walkCb(path string, ent *godirwalk.Dirent) error {
|
||||
if ent.IsDir() && ent.Name() == ".git" {
|
||||
r.repos = append(r.repos, strings.TrimSuffix(path, ".git"))
|
||||
return ErrSkipNode
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *RepoFinder) errorCb(_ string, err error) godirwalk.ErrorAction {
|
||||
// Skip .git directory and directories we don't have permissions to access
|
||||
// TODO: Will syscall.EACCES work on windows?
|
||||
if errors.Is(err, ErrSkipNode) || errors.Is(err, syscall.EACCES) {
|
||||
return godirwalk.SkipNode
|
||||
}
|
||||
return godirwalk.Halt
|
||||
}
|
||||
150
pkg/list.go
150
pkg/list.go
@@ -3,140 +3,72 @@ package pkg
|
||||
import (
|
||||
"fmt"
|
||||
"git-get/pkg/cfg"
|
||||
"git-get/pkg/git"
|
||||
"git-get/pkg/io"
|
||||
"git-get/pkg/print"
|
||||
"git-get/pkg/repo"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"github.com/karrick/godirwalk"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
// errSkipNode is used as an error indicating that .git directory has been found.
|
||||
// It's handled by ErrorsCallback to tell the WalkCallback to skip this dir.
|
||||
var errSkipNode = errors.New(".git directory found, skipping this node")
|
||||
|
||||
var repos []string
|
||||
|
||||
// ListCfg provides configuration for the List command.
|
||||
type ListCfg struct {
|
||||
Fetch bool
|
||||
Output string
|
||||
PrivateKey string
|
||||
Root string
|
||||
Fetch bool
|
||||
Output string
|
||||
Root string
|
||||
}
|
||||
|
||||
// List executes the "git list" command.
|
||||
func List(c *ListCfg) error {
|
||||
paths, err := findRepos(c.Root)
|
||||
paths, err := io.NewRepoFinder(c.Root).Find()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
repos, err := openAll(paths)
|
||||
if err != nil {
|
||||
return err
|
||||
// TODO: we should open, fetch and read status of each repo in separate goroutine
|
||||
var repos []git.Repo
|
||||
for _, path := range paths {
|
||||
repo, err := git.Open(path)
|
||||
if err != nil {
|
||||
// TODO: how should we handle it?
|
||||
continue
|
||||
}
|
||||
|
||||
if c.Fetch {
|
||||
err := repo.Fetch()
|
||||
if err != nil {
|
||||
// TODO: handle error
|
||||
}
|
||||
}
|
||||
|
||||
repos = append(repos, *repo)
|
||||
}
|
||||
|
||||
var printer print.Printer
|
||||
switch c.Output {
|
||||
case cfg.OutFlat:
|
||||
printer = &print.FlatPrinter{}
|
||||
printables := make([]print.Repo, len(repos))
|
||||
for i := range repos {
|
||||
printables[i] = &repos[i]
|
||||
}
|
||||
fmt.Println(print.NewFlatPrinter().Print(printables))
|
||||
|
||||
case cfg.OutTree:
|
||||
printer = &print.TreePrinter{}
|
||||
case cfg.OutSmart:
|
||||
printer = &print.SmartPrinter{}
|
||||
printables := make([]print.Repo, len(repos))
|
||||
for i := range repos {
|
||||
printables[i] = &repos[i]
|
||||
}
|
||||
fmt.Println(print.NewTreePrinter().Print(c.Root, printables))
|
||||
|
||||
case cfg.OutDump:
|
||||
printer = &print.DumpPrinter{}
|
||||
printables := make([]print.DumpRepo, len(repos))
|
||||
for i := range repos {
|
||||
printables[i] = &repos[i]
|
||||
}
|
||||
fmt.Println(print.NewDumpPrinter().Print(printables))
|
||||
|
||||
default:
|
||||
return fmt.Errorf("invalid --out flag; allowed values: [%s]", strings.Join(cfg.AllowedOut, ", "))
|
||||
}
|
||||
|
||||
fmt.Println(printer.Print(c.Root, repos))
|
||||
return nil
|
||||
}
|
||||
|
||||
func findRepos(root string) ([]string, error) {
|
||||
repos = []string{}
|
||||
|
||||
if _, err := os.Stat(root); err != nil {
|
||||
return nil, fmt.Errorf("repos root %s doesn't exist or can't be accessed", root)
|
||||
}
|
||||
|
||||
walkOpts := &godirwalk.Options{
|
||||
ErrorCallback: errorCb,
|
||||
Callback: walkCb,
|
||||
// Use Unsorted to improve speed because repos will be processed by goroutines in a random order anyway.
|
||||
Unsorted: true,
|
||||
}
|
||||
|
||||
err := godirwalk.Walk(root, walkOpts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(repos) == 0 {
|
||||
return nil, fmt.Errorf("no git repos found in root path %s", root)
|
||||
}
|
||||
|
||||
return repos, nil
|
||||
}
|
||||
|
||||
func walkCb(path string, ent *godirwalk.Dirent) error {
|
||||
if ent.IsDir() && ent.Name() == ".git" {
|
||||
repos = append(repos, strings.TrimSuffix(path, ".git"))
|
||||
return errSkipNode
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func errorCb(_ string, err error) godirwalk.ErrorAction {
|
||||
// Skip .git directory and directories we don't have permissions to access
|
||||
// TODO: Will syscall.EACCES work on windows?
|
||||
if errors.Is(err, errSkipNode) || errors.Is(err, syscall.EACCES) {
|
||||
return godirwalk.SkipNode
|
||||
}
|
||||
return godirwalk.Halt
|
||||
}
|
||||
|
||||
func openAll(paths []string) ([]*repo.Repo, error) {
|
||||
var repos []*repo.Repo
|
||||
reposChan := make(chan *repo.Repo)
|
||||
|
||||
for _, path := range paths {
|
||||
go func(path string) {
|
||||
repo, err := repo.Open(path)
|
||||
|
||||
if err != nil {
|
||||
// TODO handle error
|
||||
fmt.Println(err)
|
||||
}
|
||||
|
||||
err = repo.LoadStatus()
|
||||
if err != nil {
|
||||
// TODO handle error
|
||||
fmt.Println(err)
|
||||
}
|
||||
// when error happened we just sent a nil
|
||||
reposChan <- repo
|
||||
}(path)
|
||||
}
|
||||
|
||||
for repo := range reposChan {
|
||||
repos = append(repos, repo)
|
||||
|
||||
// TODO: is this the right way to close the channel? What if we have non-unique paths?
|
||||
if len(repos) == len(paths) {
|
||||
close(reposChan)
|
||||
}
|
||||
}
|
||||
|
||||
// sort the final array to make printing easier
|
||||
sort.Slice(repos, func(i, j int) bool {
|
||||
return strings.Compare(repos[i].Path, repos[j].Path) < 0
|
||||
})
|
||||
|
||||
return repos, nil
|
||||
}
|
||||
|
||||
@@ -1,31 +1,40 @@
|
||||
package print
|
||||
|
||||
import (
|
||||
"git-get/pkg/repo"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// DumpPrinter implements Printer interface and provides method for printing list of repos in dump file format.
|
||||
// DumpRepo is a git repository printable into a dump file.
|
||||
type DumpRepo interface {
|
||||
Path() string
|
||||
Remote() (string, error)
|
||||
CurrentBranch() (string, error)
|
||||
}
|
||||
|
||||
// DumpPrinter prints a list of repos in a dump file format.
|
||||
type DumpPrinter struct{}
|
||||
|
||||
// NewDumpPrinter creates a DumpPrinter.
|
||||
func NewDumpPrinter() *DumpPrinter {
|
||||
return &DumpPrinter{}
|
||||
}
|
||||
|
||||
// Print generates a list of repos URLs. Each line contains a URL and, if applicable, a currently checked out branch name.
|
||||
// It's a way to dump all repositories managed by git-get and is supposed to be consumed by `git get --dump`.
|
||||
func (p *DumpPrinter) Print(_ string, repos []*repo.Repo) string {
|
||||
func (p *DumpPrinter) Print(repos []DumpRepo) string {
|
||||
var str strings.Builder
|
||||
|
||||
for i, r := range repos {
|
||||
remotes, err := r.Remotes()
|
||||
if err != nil || len(remotes) == 0 {
|
||||
url, err := r.Remote()
|
||||
if err != nil {
|
||||
continue
|
||||
// TODO: handle error?
|
||||
}
|
||||
|
||||
// TODO: Needs work. Right now we're just assuming the first remote is the origin one and the one from which the current branch is checked out.
|
||||
url := remotes[0].Config().URLs[0]
|
||||
current := r.Status.CurrentBranch
|
||||
|
||||
str.WriteString(url)
|
||||
|
||||
if current != repo.StatusDetached && current != repo.StatusUnknown {
|
||||
current, err := r.CurrentBranch()
|
||||
if err != nil || current != detached {
|
||||
str.WriteString(" " + current)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,32 +2,49 @@ package print
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"git-get/pkg/repo"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// FlatPrinter implements Printer interface and provides method for printing list of repos in flat format.
|
||||
// FlatPrinter prints a list of repos in a flat format.
|
||||
type FlatPrinter struct{}
|
||||
|
||||
// NewFlatPrinter creates a FlatPrinter.
|
||||
func NewFlatPrinter() *FlatPrinter {
|
||||
return &FlatPrinter{}
|
||||
}
|
||||
|
||||
// Print generates a flat list of repositories and their statuses - each repo in new line with full path.
|
||||
func (p *FlatPrinter) Print(root string, repos []*repo.Repo) string {
|
||||
func (p *FlatPrinter) Print(repos []Repo) string {
|
||||
var str strings.Builder
|
||||
|
||||
for _, r := range repos {
|
||||
path := strings.TrimPrefix(r.Path, root)
|
||||
path = strings.Trim(path, string(filepath.Separator))
|
||||
str.WriteString(fmt.Sprintf("\n%s %s", r.Path(), printCurrentBranchLine(r)))
|
||||
|
||||
str.WriteString(fmt.Sprintf("\n%s %s", path, printWorktreeStatus(r)))
|
||||
branches, err := r.Branches()
|
||||
if err != nil {
|
||||
str.WriteString(printErr(err))
|
||||
continue
|
||||
}
|
||||
|
||||
for _, branch := range r.Status.Branches {
|
||||
current, err := r.CurrentBranch()
|
||||
if err != nil {
|
||||
str.WriteString(printErr(err))
|
||||
continue
|
||||
}
|
||||
|
||||
for _, branch := range branches {
|
||||
// Don't print the status of the current branch. It was already printed above.
|
||||
if branch.Name == r.Status.CurrentBranch {
|
||||
if branch == current {
|
||||
continue
|
||||
}
|
||||
|
||||
indent := strings.Repeat(" ", len(path))
|
||||
str.WriteString(fmt.Sprintf("\n%s %s", indent, printBranchStatus(branch)))
|
||||
status, err := printBranchStatus(r, branch)
|
||||
if err != nil {
|
||||
status = printErr(err)
|
||||
}
|
||||
|
||||
indent := strings.Repeat(" ", len(r.Path()))
|
||||
str.WriteString(fmt.Sprintf("\n%s %s %s", indent, printBranchName(branch), status))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,85 +2,147 @@ package print
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"git-get/pkg/repo"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Printer prints list of repos and their statuses
|
||||
type Printer interface {
|
||||
Print(root string, repos []*repo.Repo) string
|
||||
}
|
||||
|
||||
// TODO: not sure if this works on windows. See https://github.com/mattn/go-colorable
|
||||
const (
|
||||
ColorRed = "\033[1;31m%s\033[0m"
|
||||
ColorGreen = "\033[1;32m%s\033[0m"
|
||||
ColorBlue = "\033[1;34m%s\033[0m"
|
||||
ColorYellow = "\033[1;33m%s\033[0m"
|
||||
colorRed = "\033[1;31m%s\033[0m"
|
||||
colorGreen = "\033[1;32m%s\033[0m"
|
||||
colorBlue = "\033[1;34m%s\033[0m"
|
||||
colorYellow = "\033[1;33m%s\033[0m"
|
||||
)
|
||||
|
||||
func printWorktreeStatus(r *repo.Repo) string {
|
||||
clean := true
|
||||
var status []string
|
||||
const (
|
||||
untracked = "untracked"
|
||||
uncommitted = "uncommitted"
|
||||
ahead = "ahead"
|
||||
behind = "behind"
|
||||
noUpstream = "no upstream"
|
||||
ok = "ok"
|
||||
detached = "detached"
|
||||
head = "HEAD"
|
||||
)
|
||||
|
||||
// if current branch status can't be found it's probably a detached head
|
||||
// TODO: what if current HEAD points to a tag?
|
||||
if current := r.CurrentBranchStatus(); current == nil {
|
||||
status = append(status, fmt.Sprintf(ColorYellow, r.Status.CurrentBranch))
|
||||
// Repo is a git repository
|
||||
// TODO: maybe branch should be a separate interface
|
||||
type Repo interface {
|
||||
Path() string
|
||||
Branches() ([]string, error)
|
||||
CurrentBranch() (string, error)
|
||||
Upstream(branch string) (string, error)
|
||||
AheadBehind(branch string, upstream string) (int, int, error)
|
||||
Uncommitted() (int, error)
|
||||
Untracked() (int, error)
|
||||
}
|
||||
|
||||
// // Printer provides a way to print a list of repos and their statuses
|
||||
// type Printer interface {
|
||||
// Print(root string, repos []Repo) string
|
||||
// }
|
||||
|
||||
// prints status of currently checked out branch and the work tree.
|
||||
// The format is: branch_name branch_status [ worktree_status ]
|
||||
// Eg: master 1 head 2 behind [ 1 uncomitted ]
|
||||
func printCurrentBranchLine(r Repo) string {
|
||||
var res []string
|
||||
|
||||
current, err := r.CurrentBranch()
|
||||
if err != nil {
|
||||
return printErr(err)
|
||||
}
|
||||
|
||||
// if current head is detached don't print its status
|
||||
if current == head {
|
||||
return fmt.Sprintf(colorYellow, detached)
|
||||
}
|
||||
|
||||
status, err := printBranchStatus(r, current)
|
||||
if err != nil {
|
||||
return printErr(err)
|
||||
}
|
||||
|
||||
worktree, err := printWorkTreeStatus(r)
|
||||
if err != nil {
|
||||
return printErr(err)
|
||||
}
|
||||
|
||||
res = append(res, printBranchName(current))
|
||||
|
||||
// if worktree is not clean and branch is ok then it shouldn't be ok
|
||||
if worktree != "" && strings.Contains(status, ok) {
|
||||
res = append(res, worktree)
|
||||
} else {
|
||||
status = append(status, printBranchStatus(current))
|
||||
res = append(res, status)
|
||||
res = append(res, worktree)
|
||||
}
|
||||
|
||||
// TODO: this is ugly
|
||||
// unset clean flag to use it to render braces around worktree status and remove "ok" from branch status if it's there
|
||||
if r.Status.HasUncommittedChanges || r.Status.HasUntrackedFiles {
|
||||
clean = false
|
||||
}
|
||||
|
||||
if !clean {
|
||||
status[len(status)-1] = strings.TrimSuffix(status[len(status)-1], repo.StatusOk)
|
||||
status = append(status, "[")
|
||||
}
|
||||
|
||||
if r.Status.HasUntrackedFiles {
|
||||
status = append(status, fmt.Sprintf(ColorRed, repo.StatusUntracked))
|
||||
}
|
||||
|
||||
if r.Status.HasUncommittedChanges {
|
||||
status = append(status, fmt.Sprintf(ColorRed, repo.StatusUncommitted))
|
||||
}
|
||||
|
||||
if !clean {
|
||||
status = append(status, "]")
|
||||
}
|
||||
|
||||
return strings.Join(status, " ")
|
||||
return strings.Join(res, " ")
|
||||
}
|
||||
|
||||
func printBranchStatus(branch *repo.BranchStatus) string {
|
||||
// ok indicates that the branch has upstream and is not ahead or behind it
|
||||
ok := true
|
||||
var status []string
|
||||
|
||||
status = append(status, fmt.Sprintf(ColorBlue, branch.Name))
|
||||
|
||||
if branch.Upstream == "" {
|
||||
ok = false
|
||||
status = append(status, fmt.Sprintf(ColorYellow, repo.StatusNoUpstream))
|
||||
}
|
||||
|
||||
if branch.Behind != 0 {
|
||||
ok = false
|
||||
status = append(status, fmt.Sprintf(ColorYellow, fmt.Sprintf("%d %s", branch.Behind, repo.StatusBehind)))
|
||||
}
|
||||
|
||||
if branch.Ahead != 0 {
|
||||
ok = false
|
||||
status = append(status, fmt.Sprintf(ColorYellow, fmt.Sprintf("%d %s", branch.Ahead, repo.StatusAhead)))
|
||||
}
|
||||
|
||||
if ok {
|
||||
status = append(status, fmt.Sprintf(ColorGreen, repo.StatusOk))
|
||||
}
|
||||
|
||||
return strings.Join(status, " ")
|
||||
func printBranchName(branch string) string {
|
||||
return fmt.Sprintf(colorBlue, branch)
|
||||
}
|
||||
|
||||
func printBranchStatus(r Repo, branch string) (string, error) {
|
||||
var res []string
|
||||
upstream, err := r.Upstream(branch)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if upstream == "" {
|
||||
return fmt.Sprintf(colorYellow, noUpstream), nil
|
||||
}
|
||||
|
||||
a, b, err := r.AheadBehind(branch, upstream)
|
||||
if err != nil {
|
||||
return printErr(err), nil
|
||||
}
|
||||
|
||||
if a == 0 && b == 0 {
|
||||
return fmt.Sprintf(colorGreen, ok), nil
|
||||
}
|
||||
|
||||
if a != 0 {
|
||||
res = append(res, fmt.Sprintf(colorYellow, fmt.Sprintf("%d %s", a, ahead)))
|
||||
}
|
||||
if b != 0 {
|
||||
res = append(res, fmt.Sprintf(colorYellow, fmt.Sprintf("%d %s", b, behind)))
|
||||
}
|
||||
|
||||
return strings.Join(res, " "), nil
|
||||
}
|
||||
|
||||
func printWorkTreeStatus(r Repo) (string, error) {
|
||||
uc, err := r.Uncommitted()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
ut, err := r.Untracked()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if uc == 0 && ut == 0 {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
var res []string
|
||||
res = append(res, "[")
|
||||
if uc != 0 {
|
||||
res = append(res, fmt.Sprintf(colorRed, fmt.Sprintf("%d %s", uc, uncommitted)))
|
||||
}
|
||||
if ut != 0 {
|
||||
res = append(res, fmt.Sprintf(colorRed, fmt.Sprintf("%d %s", ut, untracked)))
|
||||
}
|
||||
|
||||
res = append(res, "]")
|
||||
|
||||
return strings.Join(res, " "), nil
|
||||
}
|
||||
|
||||
func printErr(err error) string {
|
||||
return fmt.Sprintf(colorRed, err.Error())
|
||||
}
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
package print
|
||||
|
||||
import (
|
||||
"git-get/pkg/repo"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// SmartPrinter implements Printer interface and provides methods for printing repos and their statuses.
|
||||
// It's "smart" because it automatically folds branches which only have a single child and indents branches with many children.
|
||||
type SmartPrinter struct {
|
||||
// length is the size (number of chars) of the currently processed line.
|
||||
// It's used to correctly indent the lines with branches status.
|
||||
length int
|
||||
}
|
||||
|
||||
// Print generates a list of repositories and their statuses.
|
||||
func (p *SmartPrinter) Print(root string, repos []*repo.Repo) string {
|
||||
tree := buildTree(root, repos)
|
||||
|
||||
return p.printSmartTree(tree)
|
||||
}
|
||||
|
||||
// printSmartTree recursively traverses the tree and prints its nodes.
|
||||
// If a node contains multiple children, they are be printed in new lines and indented.
|
||||
// If a node contains only a single child, it is printed in the same line using path separator.
|
||||
// For better readability the first level (repos hosts) is not indented.
|
||||
//
|
||||
// Example:
|
||||
// Following paths:
|
||||
// /repos/github.com/user/repo1
|
||||
// /repos/github.com/user/repo2
|
||||
// /repos/github.com/another/repo
|
||||
//
|
||||
// will render a tree:
|
||||
// /repos/
|
||||
// github.com/
|
||||
// user/
|
||||
// repo1
|
||||
// repo2
|
||||
// another/repo
|
||||
//
|
||||
func (p *SmartPrinter) printSmartTree(node *Node) string {
|
||||
if node.children == nil {
|
||||
// If node is a leaf, print repo name and its status and finish processing this node.
|
||||
value := node.val
|
||||
|
||||
// TODO: Ugly
|
||||
// If this is called from tests the repo will be nil and we should return just the name without the status.
|
||||
if node.repo.Repository == nil {
|
||||
return value
|
||||
}
|
||||
|
||||
value += " " + printWorktreeStatus(node.repo)
|
||||
|
||||
// Print the status of each branch on a new line, indented to match the position of the current branch name.
|
||||
indent := "\n" + strings.Repeat(" ", p.length+len(node.val))
|
||||
for _, branch := range node.repo.Status.Branches {
|
||||
// Don't print the status of the current branch. It was already printed above.
|
||||
if branch.Name == node.repo.Status.CurrentBranch {
|
||||
continue
|
||||
}
|
||||
|
||||
value += indent + printBranchStatus(branch)
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
val := node.val + string(filepath.Separator)
|
||||
|
||||
shift := ""
|
||||
if node.parent == nil {
|
||||
// If node is a root, print its children on a new line without indentation.
|
||||
shift = "\n"
|
||||
} else if len(node.children) == 1 {
|
||||
// If node has only a single child, print it on the same line as its parent.
|
||||
// Setting node's depth to the same as parent's ensures that its children will be indented only once even if
|
||||
// node's path has multiple levels above.
|
||||
node.depth = node.parent.depth
|
||||
|
||||
p.length += len(val)
|
||||
} else {
|
||||
// If node has multiple children, print each of them on a new line
|
||||
// and indent them once relative to the parent
|
||||
node.depth = node.parent.depth + 1
|
||||
shift = "\n" + strings.Repeat(" ", node.depth)
|
||||
p.length = 0
|
||||
}
|
||||
|
||||
for _, child := range node.children {
|
||||
p.length += len(shift)
|
||||
val += shift + p.printSmartTree(child)
|
||||
p.length = 0
|
||||
}
|
||||
|
||||
return val
|
||||
}
|
||||
@@ -1,20 +1,29 @@
|
||||
package print
|
||||
|
||||
import (
|
||||
"git-get/pkg/repo"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/xlab/treeprint"
|
||||
)
|
||||
|
||||
// TreePrinter implements Printer interface and provides methods for printing repos and their statuses.
|
||||
type TreePrinter struct{}
|
||||
// TreePrinter prints list of repos in a directory tree format.
|
||||
type TreePrinter struct {
|
||||
}
|
||||
|
||||
// NewTreePrinter creates a TreePrinter.
|
||||
func NewTreePrinter() *TreePrinter {
|
||||
return &TreePrinter{}
|
||||
}
|
||||
|
||||
// Print generates a tree view of repos and their statuses.
|
||||
func (p *TreePrinter) Print(root string, repos []*repo.Repo) string {
|
||||
tree := buildTree(root, repos)
|
||||
func (p *TreePrinter) Print(root string, repos []Repo) string {
|
||||
if len(repos) == 0 {
|
||||
return fmt.Sprintf("There are no git repos under %s", root)
|
||||
}
|
||||
|
||||
tree := buildTree(root, repos)
|
||||
tp := treeprint.New()
|
||||
tp.SetValue(root)
|
||||
|
||||
@@ -23,16 +32,15 @@ func (p *TreePrinter) Print(root string, repos []*repo.Repo) string {
|
||||
return tp.String()
|
||||
}
|
||||
|
||||
// Node represents a node (ie. path fragment) in a repos tree.
|
||||
// Node represents a path fragment in repos tree.
|
||||
type Node struct {
|
||||
val string
|
||||
depth int // depth is a nesting depth used when rendering a smart tree, not a depth level of a tree node.
|
||||
parent *Node
|
||||
children []*Node
|
||||
repo *repo.Repo
|
||||
repo Repo
|
||||
}
|
||||
|
||||
// Root creates a new root of a tree
|
||||
// Root creates a new root of a tree.
|
||||
func Root(val string) *Node {
|
||||
root := &Node{
|
||||
val: val,
|
||||
@@ -40,7 +48,7 @@ func Root(val string) *Node {
|
||||
return root
|
||||
}
|
||||
|
||||
// Add adds a child node
|
||||
// Add adds a child node with given value to a current node.
|
||||
func (n *Node) Add(val string) *Node {
|
||||
if n.children == nil {
|
||||
n.children = make([]*Node, 0)
|
||||
@@ -73,11 +81,11 @@ func (n *Node) GetChild(val string) *Node {
|
||||
// buildTree builds a directory tree of paths to repositories.
|
||||
// Each node represents a directory in the repo path.
|
||||
// Each leaf (final node) contains a pointer to the repo.
|
||||
func buildTree(root string, repos []*repo.Repo) *Node {
|
||||
func buildTree(root string, repos []Repo) *Node {
|
||||
tree := Root(root)
|
||||
|
||||
for _, r := range repos {
|
||||
path := strings.TrimPrefix(r.Path, root)
|
||||
path := strings.TrimPrefix(r.Path(), root)
|
||||
path = strings.Trim(path, string(filepath.Separator))
|
||||
subs := strings.Split(path, string(filepath.Separator))
|
||||
|
||||
@@ -106,16 +114,34 @@ func buildTree(root string, repos []*repo.Repo) *Node {
|
||||
|
||||
func (p *TreePrinter) printTree(node *Node, tp treeprint.Tree) {
|
||||
if node.children == nil {
|
||||
tp.SetValue(node.val + " " + printWorktreeStatus(node.repo))
|
||||
r := node.repo
|
||||
tp.SetValue(node.val + " " + printCurrentBranchLine(r))
|
||||
|
||||
for _, branch := range node.repo.Status.Branches {
|
||||
// Don't print the status of the current branch. It was already printed above.
|
||||
if branch.Name == node.repo.Status.CurrentBranch {
|
||||
continue
|
||||
}
|
||||
tp.AddNode(printBranchStatus(branch))
|
||||
branches, err := r.Branches()
|
||||
if err != nil {
|
||||
tp.AddNode(printErr(err))
|
||||
return
|
||||
}
|
||||
|
||||
current, err := r.CurrentBranch()
|
||||
if err != nil {
|
||||
tp.AddNode(printErr(err))
|
||||
return
|
||||
}
|
||||
|
||||
for _, branch := range branches {
|
||||
// Don't print the status of the current branch. It was already printed above.
|
||||
if branch == current {
|
||||
continue
|
||||
}
|
||||
|
||||
status, err := printBranchStatus(r, branch)
|
||||
if err != nil {
|
||||
tp.AddNode(printErr(err))
|
||||
continue
|
||||
}
|
||||
tp.AddNode(printBranchName(branch) + " " + status)
|
||||
}
|
||||
}
|
||||
|
||||
for _, child := range node.children {
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
package print
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"git-get/pkg/repo"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTree(t *testing.T) {
|
||||
var tests = []struct {
|
||||
paths []string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
[]string{
|
||||
"root/github.com/grdl/repo1",
|
||||
}, `
|
||||
root/
|
||||
github.com/grdl/repo1
|
||||
`,
|
||||
},
|
||||
{
|
||||
[]string{
|
||||
"root/github.com/grdl/repo1",
|
||||
"root/github.com/grdl/repo2",
|
||||
}, `
|
||||
root/
|
||||
github.com/grdl/
|
||||
repo1
|
||||
repo2
|
||||
`,
|
||||
},
|
||||
{
|
||||
[]string{
|
||||
"root/gitlab.com/grdl/repo1",
|
||||
"root/github.com/grdl/repo1",
|
||||
}, `
|
||||
root/
|
||||
gitlab.com/grdl/repo1
|
||||
github.com/grdl/repo1
|
||||
`,
|
||||
},
|
||||
{
|
||||
[]string{
|
||||
"root/gitlab.com/grdl/repo1",
|
||||
"root/gitlab.com/grdl/repo2",
|
||||
"root/gitlab.com/other/repo1",
|
||||
"root/github.com/grdl/repo1",
|
||||
"root/github.com/grdl/nested/repo2",
|
||||
}, `
|
||||
root/
|
||||
gitlab.com/
|
||||
grdl/
|
||||
repo1
|
||||
repo2
|
||||
other/repo1
|
||||
github.com/grdl/
|
||||
repo1
|
||||
nested/repo2
|
||||
`,
|
||||
},
|
||||
{
|
||||
[]string{
|
||||
"root/gitlab.com/grdl/nested/repo1",
|
||||
"root/gitlab.com/grdl/nested/repo2",
|
||||
"root/gitlab.com/other/repo1",
|
||||
}, `
|
||||
root/
|
||||
gitlab.com/
|
||||
grdl/nested/
|
||||
repo1
|
||||
repo2
|
||||
other/repo1
|
||||
`,
|
||||
},
|
||||
{
|
||||
[]string{
|
||||
"root/gitlab.com/grdl/double/nested/repo1",
|
||||
"root/gitlab.com/grdl/nested/repo2",
|
||||
"root/gitlab.com/other/repo1",
|
||||
}, `
|
||||
root/
|
||||
gitlab.com/
|
||||
grdl/
|
||||
double/nested/repo1
|
||||
nested/repo2
|
||||
other/repo1
|
||||
`,
|
||||
},
|
||||
}
|
||||
|
||||
for i, test := range tests {
|
||||
var repos []*repo.Repo
|
||||
for _, path := range test.paths {
|
||||
repos = append(repos, repo.New(nil, path)) //&Repo{path: path})
|
||||
}
|
||||
|
||||
printer := SmartPrinter{}
|
||||
// Leading and trailing newlines are added to test cases for readability. We also need to add them to the rendering result.
|
||||
got := fmt.Sprintf("\n%s\n", printer.Print("root", repos))
|
||||
|
||||
// Rendered tree uses spaces for indentation but the test cases use tabs.
|
||||
if got != strings.ReplaceAll(test.want, "\t", " ") {
|
||||
t.Errorf("Failed test case %d, got: %+v; want: %+v", i, got, test.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
158
pkg/repo/repo.go
158
pkg/repo/repo.go
@@ -1,158 +0,0 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"git-get/pkg/cfg"
|
||||
|
||||
"github.com/go-git/go-git/v5/plumbing"
|
||||
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"net/url"
|
||||
"os"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/spf13/viper"
|
||||
"golang.org/x/crypto/ssh"
|
||||
|
||||
"github.com/go-git/go-git/v5"
|
||||
"github.com/go-git/go-git/v5/plumbing/transport"
|
||||
go_git_ssh "github.com/go-git/go-git/v5/plumbing/transport/ssh"
|
||||
)
|
||||
|
||||
type Repo struct {
|
||||
*git.Repository
|
||||
Path string
|
||||
Status *RepoStatus
|
||||
}
|
||||
|
||||
// CloneOpts specify details about repository to clone.
|
||||
type CloneOpts struct {
|
||||
URL *url.URL
|
||||
Path string // TODO: should Path be a part of clone opts?
|
||||
Branch string
|
||||
Quiet bool
|
||||
IgnoreExisting bool
|
||||
}
|
||||
|
||||
// Clone clones repository specified in CloneOpts.
|
||||
func Clone(opts *CloneOpts) (*Repo, error) {
|
||||
var progress io.Writer
|
||||
if !opts.Quiet {
|
||||
progress = os.Stdout
|
||||
fmt.Printf("Cloning into '%s'...\n", opts.Path)
|
||||
}
|
||||
|
||||
// TODO: can this be cleaner?
|
||||
var auth transport.AuthMethod
|
||||
var err error
|
||||
if opts.URL.Scheme == "ssh" {
|
||||
if auth, err = sshKeyAuth(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if opts.Branch == "" {
|
||||
opts.Branch = cfg.DefBranch
|
||||
}
|
||||
|
||||
// If branch name is actually a tag (ie. is prefixed with refs/tags) - check out that tag.
|
||||
// Otherwise, assume it's a branch name and check it out.
|
||||
refName := plumbing.ReferenceName(opts.Branch)
|
||||
if !refName.IsTag() {
|
||||
refName = plumbing.NewBranchReferenceName(opts.Branch)
|
||||
}
|
||||
|
||||
gitOpts := &git.CloneOptions{
|
||||
URL: opts.URL.String(),
|
||||
Auth: auth,
|
||||
RemoteName: git.DefaultRemoteName,
|
||||
ReferenceName: refName,
|
||||
SingleBranch: false,
|
||||
NoCheckout: false,
|
||||
Depth: 0,
|
||||
RecurseSubmodules: git.NoRecurseSubmodules,
|
||||
Progress: progress,
|
||||
Tags: git.AllTags,
|
||||
}
|
||||
|
||||
repo, err := git.PlainClone(opts.Path, false, gitOpts)
|
||||
if err != nil {
|
||||
|
||||
if opts.IgnoreExisting && errors.Is(err, git.ErrRepositoryAlreadyExists) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return nil, errors.Wrapf(err, "failed cloning %s", opts.URL.String())
|
||||
}
|
||||
|
||||
return New(repo, opts.Path), nil
|
||||
}
|
||||
|
||||
// Open opens a repository on a given path.
|
||||
func Open(path string) (*Repo, error) {
|
||||
repo, err := git.PlainOpen(path)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, "failed opening repo %s", path)
|
||||
}
|
||||
|
||||
return New(repo, path), nil
|
||||
}
|
||||
|
||||
// New returns a new Repo instance from a given go-git Repository.
|
||||
func New(repo *git.Repository, path string) *Repo {
|
||||
return &Repo{
|
||||
Repository: repo,
|
||||
Path: path,
|
||||
Status: &RepoStatus{},
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch performs a git fetch on all remotes
|
||||
func (r *Repo) Fetch() error {
|
||||
remotes, err := r.Remotes()
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "failed getting remotes of repo %s", r.Path)
|
||||
}
|
||||
|
||||
for _, remote := range remotes {
|
||||
err = remote.Fetch(&git.FetchOptions{})
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "failed fetching remote %s", remote.Config().Name)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func sshKeyAuth() (transport.AuthMethod, error) {
|
||||
privateKey := viper.GetString(cfg.KeyPrivateKey)
|
||||
sshKey, err := ioutil.ReadFile(privateKey)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, "failed to open ssh private key %s", privateKey)
|
||||
}
|
||||
|
||||
signer, err := ssh.ParsePrivateKey([]byte(sshKey))
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, "failed to parse ssh private key %s", privateKey)
|
||||
}
|
||||
|
||||
// TODO: can it ba a different user
|
||||
auth := &go_git_ssh.PublicKeys{User: "git", Signer: signer}
|
||||
return auth, nil
|
||||
}
|
||||
|
||||
// CurrentBranchStatus returns the BranchStatus of a currently checked out branch.
|
||||
func (r *Repo) CurrentBranchStatus() *BranchStatus {
|
||||
if r.Status.CurrentBranch == StatusDetached || r.Status.CurrentBranch == StatusUnknown {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, b := range r.Status.Branches {
|
||||
if b.Name == r.Status.CurrentBranch {
|
||||
return b
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,294 +0,0 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-git/go-git/v5/plumbing"
|
||||
|
||||
"github.com/go-git/go-git/v5/plumbing/object"
|
||||
|
||||
"github.com/go-git/go-git/v5"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
const (
|
||||
testUser = "Test User"
|
||||
testEmail = "testuser@example.com"
|
||||
)
|
||||
|
||||
func newRepoEmpty(t *testing.T) *Repo {
|
||||
dir := newTempDir(t)
|
||||
|
||||
repo, err := git.PlainInit(dir, false)
|
||||
checkFatal(t, err)
|
||||
|
||||
return New(repo, dir)
|
||||
}
|
||||
|
||||
func newRepoWithUntracked(t *testing.T) *Repo {
|
||||
r := newRepoEmpty(t)
|
||||
r.writeFile(t, "README", "I'm a README file")
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
func newRepoWithStaged(t *testing.T) *Repo {
|
||||
r := newRepoEmpty(t)
|
||||
r.writeFile(t, "README", "I'm a README file")
|
||||
r.addFile(t, "README")
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
func newRepoWithCommit(t *testing.T) *Repo {
|
||||
r := newRepoEmpty(t)
|
||||
r.writeFile(t, "README", "I'm a README file")
|
||||
r.addFile(t, "README")
|
||||
r.newCommit(t, "Initial commit")
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
func newRepoWithModified(t *testing.T) *Repo {
|
||||
r := newRepoEmpty(t)
|
||||
r.writeFile(t, "README", "I'm a README file")
|
||||
r.addFile(t, "README")
|
||||
r.newCommit(t, "Initial commit")
|
||||
r.writeFile(t, "README", "I'm modified")
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
func newRepoWithIgnored(t *testing.T) *Repo {
|
||||
r := newRepoEmpty(t)
|
||||
r.writeFile(t, ".gitignore", "ignoreme")
|
||||
r.addFile(t, ".gitignore")
|
||||
r.newCommit(t, "Initial commit")
|
||||
r.writeFile(t, "ignoreme", "I'm being ignored")
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
func newRepoWithLocalBranch(t *testing.T) *Repo {
|
||||
r := newRepoWithCommit(t)
|
||||
r.newBranch(t, "local")
|
||||
return r
|
||||
}
|
||||
|
||||
func newRepoWithClonedBranch(t *testing.T) *Repo {
|
||||
origin := newRepoWithCommit(t)
|
||||
|
||||
r := origin.clone(t, "master")
|
||||
r.newBranch(t, "local")
|
||||
r.checkoutBranch(t, "local")
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
func newRepoWithDetachedHead(t *testing.T) *Repo {
|
||||
r := newRepoWithCommit(t)
|
||||
|
||||
r.writeFile(t, "new", "I'm a new file")
|
||||
r.addFile(t, "new")
|
||||
hash := r.newCommit(t, "new commit")
|
||||
|
||||
r.checkoutHash(t, hash)
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
func newRepoWithBranchAhead(t *testing.T) *Repo {
|
||||
origin := newRepoWithCommit(t)
|
||||
|
||||
r := origin.clone(t, "master")
|
||||
r.writeFile(t, "new", "I'm a new file")
|
||||
r.addFile(t, "new")
|
||||
r.newCommit(t, "new commit")
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
func newRepoWithBranchBehind(t *testing.T) *Repo {
|
||||
origin := newRepoWithCommit(t)
|
||||
|
||||
r := origin.clone(t, "master")
|
||||
|
||||
origin.writeFile(t, "origin.new", "I'm a new file on origin")
|
||||
origin.addFile(t, "origin.new")
|
||||
origin.newCommit(t, "new origin commit")
|
||||
|
||||
r.fetch(t)
|
||||
return r
|
||||
}
|
||||
|
||||
// generate repo with 2 commits ahead and 3 behind the origin
|
||||
func newRepoWithBranchAheadAndBehind(t *testing.T) *Repo {
|
||||
origin := newRepoWithCommit(t)
|
||||
|
||||
r := origin.clone(t, "master")
|
||||
r.writeFile(t, "local.new", "local 1")
|
||||
r.addFile(t, "local.new")
|
||||
r.newCommit(t, "1st local commit")
|
||||
|
||||
r.writeFile(t, "local.new", "local 2")
|
||||
r.addFile(t, "local.new")
|
||||
r.newCommit(t, "2nd local commit")
|
||||
|
||||
origin.writeFile(t, "origin.new", "origin 1")
|
||||
origin.addFile(t, "origin.new")
|
||||
origin.newCommit(t, "1st origin commit")
|
||||
|
||||
origin.writeFile(t, "origin.new", "origin 2")
|
||||
origin.addFile(t, "origin.new")
|
||||
origin.newCommit(t, "2nd origin commit")
|
||||
|
||||
origin.writeFile(t, "origin.new", "origin 3")
|
||||
origin.addFile(t, "origin.new")
|
||||
origin.newCommit(t, "3rd origin commit")
|
||||
|
||||
r.fetch(t)
|
||||
return r
|
||||
}
|
||||
|
||||
func newRepoWithCheckedOutBranch(t *testing.T) *Repo {
|
||||
origin := newRepoWithCommit(t)
|
||||
origin.newBranch(t, "feature/branch1")
|
||||
|
||||
r := origin.clone(t, "feature/branch1")
|
||||
return r
|
||||
}
|
||||
|
||||
func newRepoWithCheckedOutTag(t *testing.T) *Repo {
|
||||
origin := newRepoWithCommit(t)
|
||||
origin.newTag(t, "v1.0.0")
|
||||
|
||||
r := origin.clone(t, "refs/tags/v1.0.0")
|
||||
return r
|
||||
}
|
||||
|
||||
func newTempDir(t *testing.T) string {
|
||||
dir, err := ioutil.TempDir("", "git-get-repo-")
|
||||
checkFatal(t, errors.Wrap(err, "Failed creating test repo directory"))
|
||||
|
||||
// Automatically remove repo when test is over
|
||||
t.Cleanup(func() {
|
||||
err := os.RemoveAll(dir)
|
||||
if err != nil {
|
||||
t.Errorf("failed cleaning up repo")
|
||||
}
|
||||
})
|
||||
|
||||
return dir
|
||||
}
|
||||
|
||||
func (r *Repo) writeFile(t *testing.T, name string, content string) {
|
||||
wt, err := r.Worktree()
|
||||
checkFatal(t, errors.Wrap(err, "Failed getting worktree"))
|
||||
|
||||
file, err := wt.Filesystem.OpenFile(name, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644)
|
||||
checkFatal(t, errors.Wrap(err, "Failed opening a file"))
|
||||
|
||||
_, err = file.Write([]byte(content))
|
||||
checkFatal(t, errors.Wrap(err, "Failed writing a file"))
|
||||
}
|
||||
|
||||
func (r *Repo) addFile(t *testing.T, name string) {
|
||||
wt, err := r.Worktree()
|
||||
checkFatal(t, errors.Wrap(err, "Failed getting worktree"))
|
||||
|
||||
_, err = wt.Add(name)
|
||||
checkFatal(t, errors.Wrap(err, "Failed adding file to index"))
|
||||
}
|
||||
|
||||
func (r *Repo) newCommit(t *testing.T, msg string) plumbing.Hash {
|
||||
wt, err := r.Worktree()
|
||||
checkFatal(t, errors.Wrap(err, "Failed getting worktree"))
|
||||
|
||||
opts := &git.CommitOptions{
|
||||
Author: &object.Signature{
|
||||
Name: testUser,
|
||||
Email: testEmail,
|
||||
When: time.Date(2000, 01, 01, 16, 00, 00, 0, time.UTC),
|
||||
},
|
||||
}
|
||||
|
||||
hash, err := wt.Commit(msg, opts)
|
||||
checkFatal(t, errors.Wrap(err, "Failed creating commit"))
|
||||
return hash
|
||||
}
|
||||
|
||||
func (r *Repo) newBranch(t *testing.T, name string) {
|
||||
head, err := r.Head()
|
||||
checkFatal(t, err)
|
||||
|
||||
ref := plumbing.NewHashReference(plumbing.NewBranchReferenceName(name), head.Hash())
|
||||
|
||||
err = r.Storer.SetReference(ref)
|
||||
checkFatal(t, err)
|
||||
}
|
||||
|
||||
func (r *Repo) newTag(t *testing.T, name string) {
|
||||
head, err := r.Head()
|
||||
checkFatal(t, err)
|
||||
|
||||
ref := plumbing.NewHashReference(plumbing.NewTagReferenceName(name), head.Hash())
|
||||
|
||||
err = r.Storer.SetReference(ref)
|
||||
checkFatal(t, err)
|
||||
}
|
||||
|
||||
func (r *Repo) clone(t *testing.T, branch string) *Repo {
|
||||
dir := newTempDir(t)
|
||||
repoURL, err := url.Parse("file://" + r.Path)
|
||||
checkFatal(t, err)
|
||||
|
||||
cloneOpts := &CloneOpts{
|
||||
URL: repoURL,
|
||||
Path: dir,
|
||||
Branch: branch,
|
||||
Quiet: true,
|
||||
}
|
||||
|
||||
repo, err := Clone(cloneOpts)
|
||||
checkFatal(t, err)
|
||||
|
||||
return repo
|
||||
}
|
||||
|
||||
func (r *Repo) fetch(t *testing.T) {
|
||||
err := r.Fetch()
|
||||
checkFatal(t, err)
|
||||
}
|
||||
|
||||
func (r *Repo) checkoutBranch(t *testing.T, name string) {
|
||||
wt, err := r.Worktree()
|
||||
checkFatal(t, errors.Wrap(err, "Failed getting worktree"))
|
||||
|
||||
opts := &git.CheckoutOptions{
|
||||
Branch: plumbing.NewBranchReferenceName(name),
|
||||
}
|
||||
err = wt.Checkout(opts)
|
||||
checkFatal(t, errors.Wrap(err, "Failed checking out branch"))
|
||||
}
|
||||
|
||||
func (r *Repo) checkoutHash(t *testing.T, hash plumbing.Hash) {
|
||||
wt, err := r.Worktree()
|
||||
checkFatal(t, errors.Wrap(err, "Failed getting worktree"))
|
||||
|
||||
opts := &git.CheckoutOptions{
|
||||
Hash: hash,
|
||||
}
|
||||
err = wt.Checkout(opts)
|
||||
checkFatal(t, errors.Wrap(err, "Failed checking out hash"))
|
||||
}
|
||||
|
||||
func checkFatal(t *testing.T, err error) {
|
||||
if err != nil {
|
||||
t.Fatalf("%+v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,266 +0,0 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"git-get/pkg/cfg"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/go-git/go-git/v5/plumbing/revlist"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
|
||||
"github.com/go-git/go-billy/v5/osfs"
|
||||
|
||||
"github.com/go-git/go-git/v5/plumbing/format/gitignore"
|
||||
|
||||
"github.com/go-git/go-git/v5"
|
||||
"github.com/go-git/go-git/v5/plumbing"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
const (
|
||||
StatusUnknown = "unknown"
|
||||
StatusDetached = "detached HEAD"
|
||||
StatusNoUpstream = "no upstream"
|
||||
StatusAhead = "ahead"
|
||||
StatusBehind = "behind"
|
||||
StatusOk = "ok"
|
||||
StatusUncommitted = "uncommitted"
|
||||
StatusUntracked = "untracked"
|
||||
)
|
||||
|
||||
type RepoStatus struct {
|
||||
HasUntrackedFiles bool
|
||||
HasUncommittedChanges bool
|
||||
CurrentBranch string
|
||||
Branches []*BranchStatus
|
||||
}
|
||||
|
||||
type BranchStatus struct {
|
||||
Name string
|
||||
Upstream string
|
||||
Ahead int
|
||||
Behind int
|
||||
}
|
||||
|
||||
func (r *Repo) LoadStatus() error {
|
||||
// Fetch from remotes if executed with --fetch flag. Ignore the "already up-to-date" errors.
|
||||
if viper.GetBool(cfg.KeyFetch) {
|
||||
err := r.Fetch()
|
||||
if err != nil && !errors.Is(err, git.NoErrAlreadyUpToDate) {
|
||||
return errors.Wrapf(err, "failed running git fetch on a repo %s", r.Path)
|
||||
}
|
||||
}
|
||||
|
||||
wt, err := r.Worktree()
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "failed getting worktree %s", r.Path)
|
||||
}
|
||||
|
||||
// worktree.Status doesn't load gitignore patterns that are defined outside of .gitignore file using excludesfile.
|
||||
// We need to load them explicitly here
|
||||
// TODO: variables are not expanded so if excludesfile is declared like "~/gitignore_global" or "$HOME/gitignore_global", this will fail to open it
|
||||
globalPatterns, err := gitignore.LoadGlobalPatterns(osfs.New(""))
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed loading global gitignore patterns")
|
||||
}
|
||||
wt.Excludes = append(wt.Excludes, globalPatterns...)
|
||||
|
||||
systemPatterns, err := gitignore.LoadSystemPatterns(osfs.New(""))
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed loading system gitignore patterns")
|
||||
}
|
||||
wt.Excludes = append(wt.Excludes, systemPatterns...)
|
||||
|
||||
status, err := wt.Status()
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "failed getting status of worktree %s", r.Path)
|
||||
}
|
||||
|
||||
r.Status.HasUncommittedChanges = hasUncommitted(status)
|
||||
r.Status.HasUntrackedFiles = hasUntracked(status)
|
||||
r.Status.CurrentBranch = currentBranch(r)
|
||||
|
||||
err = r.loadBranchesStatus()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// hasUntracked returns true if there are any untracked files in the worktree
|
||||
func hasUntracked(status git.Status) bool {
|
||||
for _, fs := range status {
|
||||
if fs.Worktree == git.Untracked || fs.Staging == git.Untracked {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// hasUncommitted returns true if there are any uncommitted (but tracked) files in the worktree
|
||||
func hasUncommitted(status git.Status) bool {
|
||||
// If repo is clean it means every file in worktree and staging has 'Unmodified' state
|
||||
if status.IsClean() {
|
||||
return false
|
||||
}
|
||||
|
||||
// If repo is not clean, check if any file has state different than 'Untracked' - it means they are tracked and have uncommitted modifications
|
||||
for _, fs := range status {
|
||||
if fs.Worktree != git.Untracked || fs.Staging != git.Untracked {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func currentBranch(r *Repo) string {
|
||||
head, err := r.Head()
|
||||
if err != nil {
|
||||
return StatusUnknown
|
||||
}
|
||||
|
||||
if head.Name().Short() == plumbing.HEAD.String() {
|
||||
return StatusDetached
|
||||
}
|
||||
|
||||
return head.Name().Short()
|
||||
}
|
||||
|
||||
func (r *Repo) loadBranchesStatus() error {
|
||||
iter, err := r.Branches()
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "failed getting branches iterator for repo %s", r.Path)
|
||||
}
|
||||
|
||||
err = iter.ForEach(func(reference *plumbing.Reference) error {
|
||||
bs, err := r.newBranchStatus(reference.Name().Short())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
r.Status.Branches = append(r.Status.Branches, bs)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "failed iterating over branches of repo %s", r.Path)
|
||||
}
|
||||
|
||||
// Sort branches by name (but with "master" always at the top). It's useful to have them sorted for printing and testing.
|
||||
sort.Slice(r.Status.Branches, func(i, j int) bool {
|
||||
if r.Status.Branches[i].Name == cfg.DefBranch {
|
||||
return true
|
||||
}
|
||||
|
||||
return strings.Compare(r.Status.Branches[i].Name, r.Status.Branches[j].Name) < 0
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Repo) newBranchStatus(branch string) (*BranchStatus, error) {
|
||||
bs := &BranchStatus{
|
||||
Name: branch,
|
||||
}
|
||||
|
||||
upstream, err := r.upstream(branch)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if upstream == "" {
|
||||
return bs, nil
|
||||
}
|
||||
|
||||
ahead, behind, err := r.aheadBehind(branch, upstream)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
bs.Upstream = upstream
|
||||
bs.Ahead = ahead
|
||||
bs.Behind = behind
|
||||
|
||||
return bs, nil
|
||||
}
|
||||
|
||||
// upstream finds if a given branch tracks an upstream.
|
||||
// Returns found upstream branch name (eg, origin/master) or empty string if branch doesn't track an upstream.
|
||||
//
|
||||
// Information about upstream is taken from .git/config file.
|
||||
// If a branch has an upstream, there's a [branch] section in the file with two fields:
|
||||
// "remote" - name of the remote containing upstream branch (or "." if upstream is a local branch)
|
||||
// "merge" - full ref name of the upstream branch (eg, ref/heads/master)
|
||||
func (r *Repo) upstream(branch string) (string, error) {
|
||||
cfg, err := r.Config()
|
||||
if err != nil {
|
||||
return "", errors.Wrapf(err, "failed getting config of repo %s", r.Path)
|
||||
}
|
||||
|
||||
// Check if our branch exists in "branch" config sections. If not, it doesn't have an upstream configured.
|
||||
bcfg := cfg.Branches[branch]
|
||||
if bcfg == nil {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
remote := bcfg.Remote
|
||||
if remote == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
merge := bcfg.Merge.Short()
|
||||
if merge == "" {
|
||||
return "", nil
|
||||
}
|
||||
return remote + "/" + merge, nil
|
||||
}
|
||||
|
||||
func (r *Repo) aheadBehind(localBranch string, upstreamBranch string) (ahead int, behind int, err error) {
|
||||
localHash, err := r.ResolveRevision(plumbing.Revision(localBranch))
|
||||
if err != nil {
|
||||
return 0, 0, errors.Wrapf(err, "failed resolving revision %s", localBranch)
|
||||
}
|
||||
|
||||
upstreamHash, err := r.ResolveRevision(plumbing.Revision(upstreamBranch))
|
||||
if err != nil {
|
||||
return 0, 0, errors.Wrapf(err, "failed resolving revision %s", upstreamBranch)
|
||||
}
|
||||
|
||||
behind, err = r.revlistCount(*localHash, *upstreamHash)
|
||||
if err != nil {
|
||||
return 0, 0, errors.Wrapf(err, "failed counting commits behind %s", upstreamBranch)
|
||||
}
|
||||
|
||||
ahead, err = r.revlistCount(*upstreamHash, *localHash)
|
||||
if err != nil {
|
||||
return 0, 0, errors.Wrapf(err, "failed counting commits ahead of %s", upstreamBranch)
|
||||
}
|
||||
|
||||
return ahead, behind, nil
|
||||
}
|
||||
|
||||
// revlistCount counts the number of commits between two hashes.
|
||||
// https://github.com/src-d/go-git/issues/757#issuecomment-452697701
|
||||
// TODO: See if this can be optimized. Running the loop twice feels wrong.
|
||||
func (r *Repo) revlistCount(hash1, hash2 plumbing.Hash) (int, error) {
|
||||
ref1hist, err := revlist.Objects(r.Storer, []plumbing.Hash{hash1}, nil)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
ref2hist, err := revlist.Objects(r.Storer, []plumbing.Hash{hash2}, ref1hist)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
count := 0
|
||||
for _, h := range ref2hist {
|
||||
if _, err = r.CommitObject(h); err == nil {
|
||||
count++
|
||||
}
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
@@ -1,195 +0,0 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestStatus(t *testing.T) {
|
||||
var tests = []struct {
|
||||
makeTestRepo func(*testing.T) *Repo
|
||||
want *RepoStatus
|
||||
}{
|
||||
{newRepoEmpty, &RepoStatus{
|
||||
HasUntrackedFiles: false,
|
||||
HasUncommittedChanges: false,
|
||||
CurrentBranch: StatusUnknown,
|
||||
Branches: nil,
|
||||
}},
|
||||
{newRepoWithUntracked, &RepoStatus{
|
||||
HasUntrackedFiles: true,
|
||||
HasUncommittedChanges: false,
|
||||
CurrentBranch: StatusUnknown,
|
||||
Branches: nil,
|
||||
}},
|
||||
{newRepoWithStaged, &RepoStatus{
|
||||
HasUntrackedFiles: false,
|
||||
HasUncommittedChanges: true,
|
||||
CurrentBranch: StatusUnknown,
|
||||
Branches: nil,
|
||||
}},
|
||||
{newRepoWithCommit, &RepoStatus{
|
||||
HasUntrackedFiles: false,
|
||||
HasUncommittedChanges: false,
|
||||
CurrentBranch: "master",
|
||||
Branches: []*BranchStatus{
|
||||
{
|
||||
Name: "master",
|
||||
Upstream: "",
|
||||
Behind: 0,
|
||||
Ahead: 0,
|
||||
},
|
||||
},
|
||||
}},
|
||||
{newRepoWithModified, &RepoStatus{
|
||||
HasUntrackedFiles: false,
|
||||
HasUncommittedChanges: true,
|
||||
CurrentBranch: "master",
|
||||
Branches: []*BranchStatus{
|
||||
{
|
||||
Name: "master",
|
||||
Upstream: "",
|
||||
Behind: 0,
|
||||
Ahead: 0,
|
||||
},
|
||||
},
|
||||
}},
|
||||
{newRepoWithIgnored, &RepoStatus{
|
||||
HasUntrackedFiles: false,
|
||||
HasUncommittedChanges: false,
|
||||
CurrentBranch: "master",
|
||||
Branches: []*BranchStatus{
|
||||
{
|
||||
Name: "master",
|
||||
Upstream: "",
|
||||
Behind: 0,
|
||||
Ahead: 0,
|
||||
},
|
||||
},
|
||||
}},
|
||||
{newRepoWithLocalBranch, &RepoStatus{
|
||||
HasUntrackedFiles: false,
|
||||
HasUncommittedChanges: false,
|
||||
CurrentBranch: "master",
|
||||
Branches: []*BranchStatus{
|
||||
{
|
||||
Name: "master",
|
||||
Upstream: "",
|
||||
Behind: 0,
|
||||
Ahead: 0,
|
||||
}, {
|
||||
Name: "local",
|
||||
Upstream: "",
|
||||
Behind: 0,
|
||||
Ahead: 0,
|
||||
},
|
||||
},
|
||||
}},
|
||||
{newRepoWithClonedBranch, &RepoStatus{
|
||||
HasUntrackedFiles: false,
|
||||
HasUncommittedChanges: false,
|
||||
CurrentBranch: "local",
|
||||
Branches: []*BranchStatus{
|
||||
{
|
||||
Name: "master",
|
||||
Upstream: "origin/master",
|
||||
Behind: 0,
|
||||
Ahead: 0,
|
||||
}, {
|
||||
Name: "local",
|
||||
Upstream: "",
|
||||
Behind: 0,
|
||||
Ahead: 0,
|
||||
},
|
||||
},
|
||||
}},
|
||||
{newRepoWithDetachedHead, &RepoStatus{
|
||||
HasUntrackedFiles: false,
|
||||
HasUncommittedChanges: false,
|
||||
CurrentBranch: StatusDetached,
|
||||
Branches: []*BranchStatus{
|
||||
{
|
||||
Name: "master",
|
||||
Upstream: "",
|
||||
Behind: 0,
|
||||
Ahead: 0,
|
||||
},
|
||||
},
|
||||
}},
|
||||
{newRepoWithBranchAhead, &RepoStatus{
|
||||
HasUntrackedFiles: false,
|
||||
HasUncommittedChanges: false,
|
||||
CurrentBranch: "master",
|
||||
Branches: []*BranchStatus{
|
||||
{
|
||||
Name: "master",
|
||||
Upstream: "origin/master",
|
||||
Behind: 0,
|
||||
Ahead: 1,
|
||||
},
|
||||
},
|
||||
}},
|
||||
{newRepoWithBranchBehind, &RepoStatus{
|
||||
HasUntrackedFiles: false,
|
||||
HasUncommittedChanges: false,
|
||||
CurrentBranch: "master",
|
||||
Branches: []*BranchStatus{
|
||||
{
|
||||
Name: "master",
|
||||
Upstream: "origin/master",
|
||||
Behind: 1,
|
||||
Ahead: 0,
|
||||
},
|
||||
},
|
||||
}},
|
||||
{newRepoWithBranchAheadAndBehind, &RepoStatus{
|
||||
HasUntrackedFiles: false,
|
||||
HasUncommittedChanges: false,
|
||||
CurrentBranch: "master",
|
||||
Branches: []*BranchStatus{
|
||||
{
|
||||
Name: "master",
|
||||
Upstream: "origin/master",
|
||||
Behind: 3,
|
||||
Ahead: 2,
|
||||
},
|
||||
},
|
||||
}},
|
||||
{newRepoWithCheckedOutBranch, &RepoStatus{
|
||||
HasUntrackedFiles: false,
|
||||
HasUncommittedChanges: false,
|
||||
CurrentBranch: "feature/branch1",
|
||||
Branches: []*BranchStatus{
|
||||
{
|
||||
Name: "feature/branch1",
|
||||
Upstream: "origin/feature/branch1",
|
||||
Behind: 0,
|
||||
Ahead: 0,
|
||||
},
|
||||
},
|
||||
}},
|
||||
{newRepoWithCheckedOutTag, &RepoStatus{
|
||||
HasUntrackedFiles: false,
|
||||
HasUncommittedChanges: false,
|
||||
// TODO: is this correct? Can we show tag name instead of "detached HEAD"?
|
||||
CurrentBranch: StatusDetached,
|
||||
Branches: nil,
|
||||
}},
|
||||
}
|
||||
|
||||
for i, test := range tests {
|
||||
repo := test.makeTestRepo(t)
|
||||
|
||||
err := repo.LoadStatus()
|
||||
checkFatal(t, err)
|
||||
|
||||
if !reflect.DeepEqual(repo.Status, test.want) {
|
||||
t.Errorf("Failed test case %d, got: %+v; want: %+v", i, repo.Status, test.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: test branch status when tracking a local branch
|
||||
// TODO: test head pointing to a tag
|
||||
// TODO: newRepoWithGlobalGitignore
|
||||
// TODO: newRepoWithGlobalGitignoreSymlink
|
||||
Reference in New Issue
Block a user