├── .github └── workflows │ ├── build.yml │ ├── codeql-analysis.yml │ └── release.yml ├── .gitignore ├── .goreleaser.yml ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── cmd ├── get │ └── main.go └── list │ └── main.go ├── docs ├── example.svg ├── out_dump.png ├── out_flat.png └── out_tree.png ├── go.mod ├── go.sum └── pkg ├── cfg ├── config.go └── config_test.go ├── dump.go ├── dump_test.go ├── get.go ├── git ├── config.go ├── config_test.go ├── finder.go ├── finder_test.go ├── repo.go ├── repo_test.go ├── status.go └── test │ ├── helpers.go │ └── testrepos.go ├── list.go ├── print ├── dump.go ├── flat.go ├── print.go └── tree.go ├── run └── run.go ├── url.go └── url_test.go /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | - push 5 | - pull_request 6 | 7 | jobs: 8 | test: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout 12 | uses: actions/checkout@v2 13 | - name: Set up Go 14 | uses: actions/setup-go@v2 15 | with: 16 | go-version: 1.16 17 | - name: Set up Git 18 | run: git config --global user.email "grdl@example.com" && git config --global user.name "grdl" 19 | - name: Run go test 20 | run: CGO_ENABLED=0 GOOS=linux go test ./... -v 21 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ master ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ master ] 20 | schedule: 21 | - cron: '34 21 * * 1' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'go' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] 37 | # Learn more: 38 | # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed 39 | 40 | steps: 41 | - name: Checkout repository 42 | uses: actions/checkout@v2 43 | 44 | # Initializes the CodeQL tools for scanning. 45 | - name: Initialize CodeQL 46 | uses: github/codeql-action/init@v1 47 | with: 48 | languages: ${{ matrix.language }} 49 | # If you wish to specify custom queries, you can do so here or in a config file. 50 | # By default, queries listed here will override any specified in a config file. 51 | # Prefix the list here with "+" to use these queries and those in the config file. 52 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 53 | 54 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 55 | # If this step fails, then you should remove it and run the build manually (see below) 56 | - name: Autobuild 57 | uses: github/codeql-action/autobuild@v1 58 | 59 | # ℹ️ Command-line programs to run using the OS shell. 60 | # 📚 https://git.io/JvXDl 61 | 62 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 63 | # and modify them (or add more) to build your code if your project 64 | # uses a compiled language 65 | 66 | #- run: | 67 | # make bootstrap 68 | # make release 69 | 70 | - name: Perform CodeQL Analysis 71 | uses: github/codeql-action/analyze@v1 72 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | jobs: 9 | goreleaser: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v2 14 | - name: Set up Go 15 | uses: actions/setup-go@v2 16 | with: 17 | go-version: 1.16 18 | - name: Run GoReleaser 19 | uses: goreleaser/goreleaser-action@v2 20 | with: 21 | version: v0.168.0 22 | args: release --rm-dist 23 | env: 24 | GITHUB_TOKEN: ${{ secrets.GORELEASER_TOKEN }} 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | __debug_bin -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | before: 2 | hooks: 3 | - go mod download 4 | 5 | builds: 6 | - id: git-get 7 | main: ./cmd/get/main.go 8 | binary: git-get 9 | ldflags: 10 | - -s -w 11 | - -X git-get/pkg/cfg.version={{.Version}} 12 | - -X git-get/pkg/cfg.commit={{.Commit}} 13 | - -X git-get/pkg/cfg.date={{.Date}} 14 | goos: 15 | - linux 16 | - darwin 17 | - windows 18 | - id: git-list 19 | main: ./cmd/list/main.go 20 | binary: git-list 21 | ldflags: 22 | - -s -w 23 | - -X git-get/pkg/cfg.version={{.Version}} 24 | - -X git-get/pkg/cfg.commit={{.Commit}} 25 | - -X git-get/pkg/cfg.date={{.Date}} 26 | goos: 27 | - linux 28 | - darwin 29 | - windows 30 | 31 | archives: 32 | - id: archive 33 | builds: 34 | - git-get 35 | - git-list 36 | replacements: 37 | darwin: macOS 38 | linux: linux 39 | windows: windows 40 | 386: i386 41 | amd64: x86_64 42 | format_overrides: 43 | - goos: windows 44 | format: zip 45 | # Don't include any additional files into the archives (such as README, CHANGELOG etc). 46 | files: 47 | - none* 48 | 49 | checksum: 50 | name_template: 'checksums.txt' 51 | 52 | changelog: 53 | skip: true 54 | 55 | release: 56 | github: 57 | owner: grdl 58 | name: git-get 59 | 60 | 61 | brews: 62 | - name: git-get 63 | tap: 64 | owner: grdl 65 | name: homebrew-tap 66 | commit_author: 67 | name: Grzegorz Dlugoszewski 68 | email: git-get@grdl.dev 69 | folder: Formula 70 | homepage: https://github.com/grdl/git-get/ 71 | description: Better way to clone, organize and manage multiple git repositories 72 | test: | 73 | system "git-get --version" 74 | install: | 75 | bin.install "git-get", "git-list" 76 | 77 | nfpms: 78 | - license: MIT 79 | maintainer: grdl 80 | homepage: https://github.com/grdl/git-get 81 | bindir: /usr/local/bin 82 | dependencies: 83 | - git 84 | description: Better way to clone, organize and manage multiple git repositories 85 | formats: 86 | - deb 87 | - rpm 88 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## [0.5.0] - 2021-06-03 8 | ### Changed 9 | - [#15](https://github.com/grdl/git-get/pull/15) Bump Go version to 1.16. 10 | 11 | 12 | ## [0.4.0] - 2020-09-02 13 | ### Added 14 | - `--scheme` flag for `git get` to set the default scheme to use when scheme is missing from the URL. 15 | 16 | ### Changed 17 | - Default scheme is now `ssh` instead of `https`. 18 | 19 | 20 | ## [0.3.0] - 2020-07-31 21 | ### Added 22 | - More meaningful error messages. 23 | - Show list of errors which ocurred when trying to load repository status. 24 | 25 | ### Fixed 26 | - Remove empty directories after a failed `git get` command. 27 | 28 | 29 | ## [0.2.0] - 2020-07-08 30 | ### Changed 31 | - `git list` won't traverse nested git repositories anymore. This significantly improves performance when listing repos with vendored dependencies (eg, node_modules). 32 | 33 | 34 | ## [0.1.0] - 2020-07-07 35 | ### Added 36 | - `--skip-host` flag to skip creating a host directory when cloning 37 | 38 | 39 | ## [0.0.7] - 2020-07-02 40 | ### Fixed 41 | - Missing fetch call on `git list`. 42 | 43 | 44 | ## [0.0.6] - 2020-07-01 45 | ### Added 46 | - `.deb` and `.rpm` releases. 47 | 48 | ### Fixed 49 | - Tree view indentation. 50 | - Missing stdout of git commands. 51 | - Incorrect gitconfig file loading. 52 | 53 | 54 | ## [0.0.5] - 2020-06-30 55 | ### Changed 56 | - Remove dependency on [go-git](https://github.com/go-git/go-git) and major refactor to fix performance issues on big repos. 57 | 58 | ### Fixed 59 | - Correctly expand `--root` pointing to a path containing home variable (eg, `~/my-repos`). 60 | - Correctly process paths on Windows. 61 | 62 | 63 | ## [0.0.4] - 2020-06-19 64 | ### Added 65 | - `--dump` flag that allows to clone multiple repos listed in a dump file. 66 | - New `dump` output option for `git list` to generate a dump file. 67 | - Readme with documentation. 68 | - Description of CLI flags and usage when running `--help`. 69 | 70 | ### Changed 71 | - Split `git-get` and `git-list` into separate binaries. 72 | - Refactor code structure by bringing the `pkg` dir back. 73 | 74 | 75 | ## [0.0.3] - 2020-06-11 76 | ### Added 77 | - Homebrew release configuration in goreleaser. 78 | - Different ways to print `git list` output: flat, simple tree and smart tree. 79 | - `--brach` flag that specifies which branch to check out after cloning. 80 | - `--fetch` flag that tells `git list` to fetch from remotes before printing repos status. 81 | - Count number of commits a branch is ahead or behind the upstream. 82 | - SSH key authentication. 83 | - Detect if branch has a detached HEAD. 84 | 85 | ### Changed 86 | - Refactor configuration provider using [viper](https://github.com/spf13/viper). 87 | - Keep `master` branch on top of sorted branches names. 88 | 89 | ### Fixed 90 | - Fix panic when trying to walk directories we don't have permissions to access. 91 | 92 | 93 | ## [0.0.1] - 2020-06-01 94 | ### Added 95 | - Initial release using [goreleaser](https://github.com/goreleaser/goreleaser). 96 | 97 | 98 | [0.5.0]: https://github.com/grdl/git-get/compare/v0.4.0...v0.5.0 99 | [0.4.0]: https://github.com/grdl/git-get/compare/v0.3.0...v0.4.0 100 | [0.3.0]: https://github.com/grdl/git-get/compare/v0.2.0...v0.3.0 101 | [0.2.0]: https://github.com/grdl/git-get/compare/v0.1.0...v0.2.0 102 | [0.1.0]: https://github.com/grdl/git-get/compare/v0.0.7...v0.1.0 103 | [0.0.7]: https://github.com/grdl/git-get/compare/v0.0.6...v0.0.7 104 | [0.0.6]: https://github.com/grdl/git-get/compare/v0.0.5...v0.0.6 105 | [0.0.5]: https://github.com/grdl/git-get/compare/v0.0.4...v0.0.5 106 | [0.0.4]: https://github.com/grdl/git-get/compare/v0.0.3...v0.0.4 107 | [0.0.3]: https://github.com/grdl/git-get/compare/v0.0.1...v0.0.3 108 | [0.0.1]: https://github.com/grdl/git-get/releases/tag/v0.0.1 -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Grzegorz Dlugoszewski 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # git-get 3 | 4 | ![build](https://github.com/grdl/git-get/workflows/build/badge.svg) 5 | [![Go Report Card](https://goreportcard.com/badge/github.com/grdl/git-get)](https://goreportcard.com/report/github.com/grdl/git-get) 6 | 7 | `git-get` is a better way to clone, organize and manage multiple git repositories. 8 | 9 | - [git-get](#git-get) 10 | - [Description](#description) 11 | - [Installation](#installation) 12 | - [macOS](#macos) 13 | - [Linux](#linux) 14 | - [Windows](#windows) 15 | - [Usage](#usage) 16 | - [git get](#git-get-1) 17 | - [git list](#git-list) 18 | - [Dump file](#dump-file) 19 | - [Configuration](#configuration) 20 | - [Env variables](#env-variables) 21 | - [.gitconfig file](#gitconfig-file) 22 | - [Contributing](#contributing) 23 | - [Acknowledgments](#acknowledgments) 24 | 25 | ## Description 26 | 27 | `git-get` gives you two new git commands: 28 | - **`git get`** clones repositories into an automatically created directory tree based on repo's URL, owner and name (like golang's [`go get`](https://golang.org/cmd/go/)). 29 | - **`git list`** shows status of all your git repositories. 30 | 31 | ![Example](./docs/example.svg) 32 | 33 | ## Installation 34 | 35 | Each release contains two binaries: `git-get` and `git-list`. When put on PATH, git automatically recognizes them as custom commands and allows to run them as `git get` or `git list`. 36 | 37 | ### macOS 38 | 39 | Use Homebrew: 40 | ``` 41 | brew install grdl/tap/git-get 42 | ``` 43 | 44 | ### Linux 45 | 46 | Download and install `.deb` or `.rpm` file from the [latest release](https://github.com/grdl/git-get/releases/latest). 47 | 48 | Or install with [Linuxbrew](https://docs.brew.sh/Homebrew-on-Linux): 49 | ``` 50 | brew install grdl/tap/git-get 51 | ``` 52 | 53 | ### Windows 54 | 55 | Grab the `.zip` file from the [latest release](https://github.com/grdl/git-get/releases/latest) and put the binaries on your PATH. 56 | 57 | 58 | ## Usage 59 | 60 | ### git get 61 | ``` 62 | git get [flags] 63 | 64 | Flags: 65 | -b, --branch Branch (or tag) to checkout after cloning. 66 | -d, --dump Path to a dump file listing repos to clone. Ignored when argument is used. 67 | -h, --help Print this help and exit. 68 | -t, --host Host to use when doesn't have a specified host. (default "github.com") 69 | -r, --root Path to repos root where repositories are cloned. (default "~/repositories") 70 | -c, --scheme Scheme to use when doesn't have a specified scheme. (default "ssh") 71 | -s, --skip-host Don't create a directory for host. 72 | -v, --version Print version and exit. 73 | ``` 74 | 75 | The `` argument can be any valid URL supported by git. It also accepts a short `USER/REPO` format. In that case `git-get` will automatically use the configured host (github.com by default). 76 | 77 | For example, `git get grdl/git-get` will clone `https://github.com/grdl/git-get`. 78 | 79 | 80 | ### git list 81 | ``` 82 | Usage: 83 | git list [flags] 84 | 85 | Flags: 86 | -f, --fetch First fetch from remotes before listing repositories. 87 | -h, --help Print this help and exit. 88 | -o, --out Output format. Allowed values: [dump, flat, tree]. (default "tree") 89 | -r, --root Path to repos root where repositories are cloned. (default "~/repositories") 90 | -v, --version Print version and exit. 91 | ``` 92 | 93 | `git list` provides different ways to view the list of the repositories and their statuses. 94 | 95 | - **tree** (default) - repos printed as a directory tree. 96 | 97 | ![output_tree](./docs/out_tree.png) 98 | 99 | - **flat** - each repo (and each branch) on a new line with full path to the repo. 100 | 101 | ![output_flat](./docs/out_flat.png) 102 | 103 | - **dump** - each repo URL with its current branch on a new line. To be consumed by `git get --dump` command. 104 | 105 | ![output_dump](./docs/out_dump.png) 106 | 107 | ### Dump file 108 | 109 | `git get` is dotfiles friendly. When run with `--dump` flag, it accepts a file with a list of repositories and clones all of them. 110 | 111 | Dump file format is simply: 112 | - Each repo URL on a separate line. 113 | - Each URL can have a space-separated suffix with a branch or tag name to check out after cloning. Without that suffix, repository HEAD is cloned (usually it's `master`). 114 | 115 | Example dump file content: 116 | ``` 117 | https://github.com/grdl/git-get v1.0.0 118 | git@github.com:grdl/another-repository.git 119 | ``` 120 | 121 | You can generate a dump file with all your currently cloned repos by running: 122 | ``` 123 | git list --out dump > repos.dump 124 | ``` 125 | 126 | ## Configuration 127 | 128 | Each configuration flag listed in the [Usage](#Usage) section can be also specified using environment variables or your global `.gitconfig` file. 129 | 130 | The order of precedence for configuration is as follows: 131 | - command line flag (have the highest precedence) 132 | - environment variable 133 | - .gitconfig entry 134 | - default value 135 | 136 | 137 | ### Env variables 138 | 139 | Use the `GITGET_` prefix and the uppercase flag name to set the configuration using env variables. For example, to use a different repos root path run: 140 | ``` 141 | export GITGET_ROOT=/path/to/my/repos 142 | ``` 143 | 144 | ### .gitconfig file 145 | 146 | You can define a `[gitget]` section inside your global `.gitconfig` file and set the configuration flags there. A recommended pattern is to set `root` and `host` variables there if you don't want to use the defaults. 147 | 148 | If all of your repos come from the same host and you find creating directory for it redundant, you can use the `skip-host` flag to skip creating it. 149 | 150 | Here's an example of a working snippet from `.gitconfig` file: 151 | ``` 152 | [gitget] 153 | root = /path/to/my/repos 154 | host = gitlab.com 155 | skip-host = true 156 | ``` 157 | 158 | 159 | ## Contributing 160 | 161 | Pull requests are welcome. The project is still very much work in progress. Here's some of the missing features planned to be fixed soon: 162 | - improvements to the `git list` output (feedback appreciated) 163 | - info about stashes and submodules 164 | - better recognition of different repo states: conflict, merging, rebasing, cherry picking etc. 165 | - plenty of bugfixes and missing tests 166 | 167 | 168 | ## Acknowledgments 169 | 170 | Inspired by: 171 | - golang's [`go get`](https://golang.org/cmd/go/) command 172 | - [x-motemen/ghq](https://github.com/x-motemen/ghq) 173 | - [fboender/multi-git-status](https://github.com/fboender/multi-git-status) 174 | -------------------------------------------------------------------------------- /cmd/get/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "git-get/pkg" 5 | "git-get/pkg/cfg" 6 | "git-get/pkg/git" 7 | 8 | "github.com/spf13/cobra" 9 | "github.com/spf13/viper" 10 | ) 11 | 12 | const example = ` git get grdl/git-get 13 | git get https://github.com/grdl/git-get.git 14 | git get git@github.com:grdl/git-get.git 15 | git get -d path/to/dump/file` 16 | 17 | var cmd = &cobra.Command{ 18 | Use: "git get ", 19 | Short: "Clone git repository into an automatically created directory tree based on the repo's URL.", 20 | Example: example, 21 | RunE: run, 22 | Args: cobra.MaximumNArgs(1), // TODO: add custom validator 23 | Version: cfg.Version(), 24 | SilenceUsage: true, // We don't want to show usage on legit errors (eg, wrong path, repo already existing etc.) 25 | } 26 | 27 | func init() { 28 | cmd.PersistentFlags().StringP(cfg.KeyBranch, "b", "", "Branch (or tag) to checkout after cloning.") 29 | cmd.PersistentFlags().StringP(cfg.KeyDefaultHost, "t", cfg.Defaults[cfg.KeyDefaultHost], "Host to use when doesn't have a specified host.") 30 | cmd.PersistentFlags().StringP(cfg.KeyDefaultScheme, "c", cfg.Defaults[cfg.KeyDefaultScheme], "Scheme to use when doesn't have a specified scheme.") 31 | cmd.PersistentFlags().StringP(cfg.KeyDump, "d", "", "Path to a dump file listing repos to clone. Ignored when argument is used.") 32 | cmd.PersistentFlags().BoolP(cfg.KeySkipHost, "s", false, "Don't create a directory for host.") 33 | cmd.PersistentFlags().StringP(cfg.KeyReposRoot, "r", cfg.Defaults[cfg.KeyReposRoot], "Path to repos root where repositories are cloned.") 34 | cmd.PersistentFlags().BoolP("help", "h", false, "Print this help and exit.") 35 | cmd.PersistentFlags().BoolP("version", "v", false, "Print version and exit.") 36 | 37 | viper.BindPFlag(cfg.KeyBranch, cmd.PersistentFlags().Lookup(cfg.KeyBranch)) 38 | viper.BindPFlag(cfg.KeyDefaultHost, cmd.PersistentFlags().Lookup(cfg.KeyDefaultHost)) 39 | viper.BindPFlag(cfg.KeyDefaultScheme, cmd.PersistentFlags().Lookup(cfg.KeyDefaultScheme)) 40 | viper.BindPFlag(cfg.KeyDump, cmd.PersistentFlags().Lookup(cfg.KeyDump)) 41 | viper.BindPFlag(cfg.KeyReposRoot, cmd.PersistentFlags().Lookup(cfg.KeyReposRoot)) 42 | viper.BindPFlag(cfg.KeySkipHost, cmd.PersistentFlags().Lookup(cfg.KeySkipHost)) 43 | 44 | cfg.Init(&git.ConfigGlobal{}) 45 | } 46 | 47 | func run(cmd *cobra.Command, args []string) error { 48 | var url string 49 | if len(args) > 0 { 50 | url = args[0] 51 | } 52 | 53 | cfg.Expand(cfg.KeyReposRoot) 54 | 55 | config := &pkg.GetCfg{ 56 | Branch: viper.GetString(cfg.KeyBranch), 57 | DefHost: viper.GetString(cfg.KeyDefaultHost), 58 | DefScheme: viper.GetString(cfg.KeyDefaultScheme), 59 | Dump: viper.GetString(cfg.KeyDump), 60 | SkipHost: viper.GetBool(cfg.KeySkipHost), 61 | Root: viper.GetString(cfg.KeyReposRoot), 62 | URL: url, 63 | } 64 | return pkg.Get(config) 65 | } 66 | 67 | func main() { 68 | cmd.Execute() 69 | } 70 | -------------------------------------------------------------------------------- /cmd/list/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "git-get/pkg" 6 | "git-get/pkg/cfg" 7 | "git-get/pkg/git" 8 | "strings" 9 | 10 | "github.com/spf13/cobra" 11 | "github.com/spf13/viper" 12 | ) 13 | 14 | var cmd = &cobra.Command{ 15 | Use: "git list", 16 | Short: "List all repositories cloned by 'git get' and their status.", 17 | RunE: run, 18 | Args: cobra.NoArgs, 19 | Version: cfg.Version(), 20 | SilenceUsage: true, // We don't want to show usage on legit errors (eg, wrong path, repo already existing etc.) 21 | } 22 | 23 | func init() { 24 | cmd.PersistentFlags().BoolP(cfg.KeyFetch, "f", false, "First fetch from remotes before listing repositories.") 25 | cmd.PersistentFlags().StringP(cfg.KeyOutput, "o", cfg.Defaults[cfg.KeyOutput], fmt.Sprintf("Output format. Allowed values: [%s].", strings.Join(cfg.AllowedOut, ", "))) 26 | cmd.PersistentFlags().StringP(cfg.KeyReposRoot, "r", cfg.Defaults[cfg.KeyReposRoot], "Path to repos root where repositories are cloned.") 27 | cmd.PersistentFlags().BoolP("help", "h", false, "Print this help and exit.") 28 | cmd.PersistentFlags().BoolP("version", "v", false, "Print version and exit.") 29 | 30 | viper.BindPFlag(cfg.KeyFetch, cmd.PersistentFlags().Lookup(cfg.KeyFetch)) 31 | viper.BindPFlag(cfg.KeyOutput, cmd.PersistentFlags().Lookup(cfg.KeyOutput)) 32 | viper.BindPFlag(cfg.KeyReposRoot, cmd.PersistentFlags().Lookup(cfg.KeyReposRoot)) 33 | 34 | cfg.Init(&git.ConfigGlobal{}) 35 | } 36 | 37 | func run(cmd *cobra.Command, args []string) error { 38 | cfg.Expand(cfg.KeyReposRoot) 39 | 40 | config := &pkg.ListCfg{ 41 | Fetch: viper.GetBool(cfg.KeyFetch), 42 | Output: viper.GetString(cfg.KeyOutput), 43 | Root: viper.GetString(cfg.KeyReposRoot), 44 | } 45 | 46 | return pkg.List(config) 47 | } 48 | 49 | func main() { 50 | cmd.Execute() 51 | } 52 | -------------------------------------------------------------------------------- /docs/example.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 107 | 130 | 131 | 132 | g git list git list git list git list git list git list git list git g git get grdl/dotfiles git get grdl/dotfiles git get grdl/dotfiles git get grdl/dotfiles git get grdl/dotfiles git get grdl/dotfiles git get grdl/dotfiles git get grdl/dotfiles git get grdl/dotfiles git get grdl/dotfiles git get grdl/dotfiles git get grdl/dotfiles git get grdl/dotfiles git get grdl/dotfiles git get grdl/dotfiles git get grdl/dotfiles git get grdl/dotfiles git get grdl/dotfiles git get grdl/dotfiles git get grdl/dotfiles git get grdl/dotfiles git get grdl/dotfiles Cloning into '/home/grdl/repos/github.com/grdl/dotfiles'...remote: Enumerating objects: 502, done. remote: Counting objects: 22% (116/502) remote: Counting objects: 45% (226/502) remote: Counting objects: 68% (342/502) remote: Counting objects: 91% (457/502) remote: Counting objects: 100% (502/502), done. remote: Compressing objects: 12% (28/232) remote: Compressing objects: 34% (79/232) remote: Compressing objects: 56% (130/232) remote: Compressing objects: 78% (181/232) remote: Compressing objects: 100% (232/232) remote: Compressing objects: 100% (232/232), done. Receiving objects: 28% (141/502)Receiving objects: 58% (292/502)remote: Total 502 (delta 253), reused 462 (delta 213), pack-reused 0 Receiving objects: 86% (432/502)Receiving objects: 100% (502/502), 71.35 KiB | 417.00 KiB/s, done.Resolving deltas: 38% (98/253)Resolving deltas: 100% (253/253), done. git get grdl/dotfiles git get grdl/dotfiles git get grdl/dotfiles git get grdl/dotfiles git get grdl/dotfiles git get grdl/dotfiles git get grdl/dotfiles git l git list git list git list git list git list /home/grdl/repos├── github.com│   ├── grdl│   │   ├── dotfiles master ok│   │   ├── git-get master [ 2 uncommitted 1 untracked ]│ │ │ development 1 ahead│   │   └── testrepo master 2 ahead 1 behind │   └── prometheus│   └── node_exporter release-0.15 [ 1 uncommitted ]master ok└── gitlab.com └── grdl └── gitlab-mirror-maker HEAD ok master no upstream 133 | -------------------------------------------------------------------------------- /docs/out_dump.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/grdl/git-get/8cd27a8f629317bd27432f2e9a4bd00561b3b21e/docs/out_dump.png -------------------------------------------------------------------------------- /docs/out_flat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/grdl/git-get/8cd27a8f629317bd27432f2e9a4bd00561b3b21e/docs/out_flat.png -------------------------------------------------------------------------------- /docs/out_tree.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/grdl/git-get/8cd27a8f629317bd27432f2e9a4bd00561b3b21e/docs/out_tree.png -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module git-get 2 | 3 | go 1.16 4 | 5 | require ( 6 | github.com/karrick/godirwalk v1.15.6 7 | github.com/kr/text v0.2.0 // indirect 8 | github.com/mitchellh/go-homedir v1.1.0 9 | github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect 10 | github.com/pkg/errors v0.9.1 11 | github.com/spf13/cobra v1.0.0 12 | github.com/spf13/viper v1.7.0 13 | github.com/stretchr/testify v1.6.0 14 | github.com/xlab/treeprint v1.0.0 15 | golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527 // indirect 16 | gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect 17 | ) 18 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= 2 | cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= 3 | cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= 4 | cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU= 5 | cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= 6 | cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc= 7 | cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0= 8 | cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= 9 | cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= 10 | cloud.google.com/go/firestore v1.1.0/go.mod h1:ulACoGHTpvq5r8rxGJ4ddJZBZqakUQqClKRT5SZwBmk= 11 | cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= 12 | cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= 13 | dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= 14 | github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= 15 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 16 | github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= 17 | github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= 18 | github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= 19 | github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= 20 | github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= 21 | github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= 22 | github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= 23 | github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= 24 | github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= 25 | github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= 26 | github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= 27 | github.com/bketelsen/crypt v0.0.3-0.20200106085610-5cbc8cc4026c/go.mod h1:MKsuJmJgSg28kpZDP6UIiPt0e0Oz0kqKNGyRaWEPv84= 28 | github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= 29 | github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= 30 | github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk= 31 | github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= 32 | github.com/coreos/etcd v3.3.13+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= 33 | github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= 34 | github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= 35 | github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= 36 | github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= 37 | github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= 38 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 39 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 40 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 41 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 42 | github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= 43 | github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= 44 | github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= 45 | github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I= 46 | github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= 47 | github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= 48 | github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= 49 | github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= 50 | github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= 51 | github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= 52 | github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= 53 | github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= 54 | github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= 55 | github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= 56 | github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= 57 | github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= 58 | github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= 59 | github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= 60 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 61 | github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 62 | github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 63 | github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= 64 | github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= 65 | github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= 66 | github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 67 | github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= 68 | github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= 69 | github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= 70 | github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= 71 | github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= 72 | github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= 73 | github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8= 74 | github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= 75 | github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= 76 | github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 77 | github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= 78 | github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= 79 | github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= 80 | github.com/hashicorp/consul/api v1.1.0/go.mod h1:VmuI/Lkw1nC05EYQWNKwWGbkg+FbDBtguAZLlVdkD9Q= 81 | github.com/hashicorp/consul/sdk v0.1.1/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8= 82 | github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= 83 | github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= 84 | github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= 85 | github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM= 86 | github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= 87 | github.com/hashicorp/go-rootcerts v1.0.0/go.mod h1:K6zTfqpRlCUIjkwsN4Z+hiSfzSTQa6eBIzfwKfwNnHU= 88 | github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU= 89 | github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4= 90 | github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= 91 | github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= 92 | github.com/hashicorp/go.net v0.0.1/go.mod h1:hjKkEWcCURg++eb33jQU7oqQcI9XDCnUzHA0oac0k90= 93 | github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= 94 | github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= 95 | github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= 96 | github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= 97 | github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= 98 | github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ= 99 | github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I= 100 | github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc= 101 | github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= 102 | github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= 103 | github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= 104 | github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= 105 | github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= 106 | github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= 107 | github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= 108 | github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= 109 | github.com/karrick/godirwalk v1.15.6 h1:Yf2mmR8TJy+8Fa0SuQVto5SYap6IF7lNVX4Jdl8G1qA= 110 | github.com/karrick/godirwalk v1.15.6/go.mod h1:j4mkqPuvaLI8mp1DroR3P6ad7cyYd4c1qeJ3RV7ULlk= 111 | github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= 112 | github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= 113 | github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= 114 | github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= 115 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 116 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 117 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 118 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 119 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 120 | github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= 121 | github.com/magiconair/properties v1.8.1 h1:ZC2Vc7/ZFkGmsVC9KvOjumD+G5lXy2RtTKyzRKO2BQ4= 122 | github.com/magiconair/properties v1.8.1/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= 123 | github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= 124 | github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= 125 | github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= 126 | github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= 127 | github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc= 128 | github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= 129 | github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= 130 | github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= 131 | github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI= 132 | github.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS42BGNg= 133 | github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY= 134 | github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= 135 | github.com/mitchellh/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQzvN1EDeE= 136 | github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= 137 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 138 | github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= 139 | github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= 140 | github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= 141 | github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= 142 | github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= 143 | github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= 144 | github.com/pelletier/go-toml v1.2.0 h1:T5zMGML61Wp+FlcbWjRDT7yAxhJNAiPPLOFECq181zc= 145 | github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= 146 | github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 147 | github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 148 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 149 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 150 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 151 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 152 | github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= 153 | github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= 154 | github.com/prometheus/client_golang v0.9.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDft0ttaMvbicHlPoso= 155 | github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= 156 | github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= 157 | github.com/prometheus/common v0.0.0-20181113130724-41aa239b4cce/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= 158 | github.com/prometheus/common v0.4.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= 159 | github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= 160 | github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= 161 | github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU= 162 | github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= 163 | github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= 164 | github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 165 | github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= 166 | github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= 167 | github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= 168 | github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= 169 | github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM= 170 | github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= 171 | github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s= 172 | github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= 173 | github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM= 174 | github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= 175 | github.com/spf13/afero v1.1.2 h1:m8/z1t7/fwjysjQRYbP0RD+bUIF/8tJwPdEZsI83ACI= 176 | github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= 177 | github.com/spf13/cast v1.3.0 h1:oget//CVOEoFewqQxwr0Ej5yjygnqGkvggSE/gB35Q8= 178 | github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= 179 | github.com/spf13/cobra v1.0.0 h1:6m/oheQuQ13N9ks4hubMG6BnvwOeaJrqSPLahSnczz8= 180 | github.com/spf13/cobra v1.0.0/go.mod h1:/6GTrnGXV9HjY+aR4k0oJ5tcvakLuG6EuKReYlHNrgE= 181 | github.com/spf13/jwalterweatherman v1.0.0 h1:XHEdyB+EcvlqZamSM4ZOMGlc93t6AcsBEu9Gc1vn7yk= 182 | github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= 183 | github.com/spf13/pflag v1.0.3 h1:zPAT6CGy6wXeQ7NtTnaTerfKOsV6V6F8agHXFiazDkg= 184 | github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= 185 | github.com/spf13/viper v1.4.0/go.mod h1:PTJ7Z/lr49W6bUbkmS1V3by4uWynFiR9p7+dSq/yZzE= 186 | github.com/spf13/viper v1.7.0 h1:xVKxvI7ouOI5I+U9s2eeiUfMaWBVoXA3AWskkrqK0VM= 187 | github.com/spf13/viper v1.7.0/go.mod h1:8WkrPz2fc9jxqZNCJI/76HCieCp4Q8HaLFoCha5qpdg= 188 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 189 | github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 190 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 191 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 192 | github.com/stretchr/testify v1.6.0 h1:jlIyCplCJFULU/01vCkhKuTyc3OorI3bJFuw6obfgho= 193 | github.com/stretchr/testify v1.6.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 194 | github.com/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s= 195 | github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= 196 | github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= 197 | github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc= 198 | github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= 199 | github.com/xlab/treeprint v1.0.0 h1:J0TkWtiuYgtdlrkkrDLISYBQ92M+X5m4LrIIMKrbDTs= 200 | github.com/xlab/treeprint v1.0.0/go.mod h1:IoImgRak9i3zJyuxOKUP1v4UZd1tMoKkq/Cimt1uhCg= 201 | github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= 202 | go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= 203 | go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= 204 | go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= 205 | go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= 206 | go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= 207 | go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= 208 | golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= 209 | golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= 210 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 211 | golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 212 | golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 213 | golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= 214 | golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= 215 | golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= 216 | golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek= 217 | golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= 218 | golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= 219 | golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= 220 | golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= 221 | golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= 222 | golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= 223 | golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 224 | golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 225 | golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 226 | golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 227 | golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= 228 | golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= 229 | golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= 230 | golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= 231 | golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 232 | golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 233 | golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 234 | golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 235 | golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 236 | golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 237 | golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 238 | golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 239 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 240 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 241 | golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 242 | golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 243 | golang.org/x/net v0.0.0-20190522155817-f3200d17e092/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= 244 | golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= 245 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 246 | golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= 247 | golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= 248 | golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= 249 | golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 250 | golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 251 | golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 252 | golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 253 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 254 | golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 255 | golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 256 | golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 257 | golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 258 | golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 259 | golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 260 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 261 | golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 262 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 263 | golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 264 | golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 265 | golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 266 | golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 267 | golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527 h1:uYVVQ9WP/Ds2ROhcaGPeIdVq0RIXVLwsHlnvJ+cT1So= 268 | golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 269 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 270 | golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 271 | golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs= 272 | golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= 273 | golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= 274 | golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= 275 | golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 276 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 277 | golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 278 | golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= 279 | golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 280 | golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 281 | golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 282 | golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 283 | golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= 284 | golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= 285 | golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= 286 | golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= 287 | golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= 288 | golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 289 | golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 290 | golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 291 | golang.org/x/tools v0.0.0-20191112195655-aa38f8e97acc/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 292 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 293 | google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= 294 | google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= 295 | google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= 296 | google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= 297 | google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= 298 | google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= 299 | google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= 300 | google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= 301 | google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= 302 | google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= 303 | google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= 304 | google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= 305 | google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= 306 | google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= 307 | google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= 308 | google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= 309 | google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8= 310 | google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= 311 | google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= 312 | google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= 313 | google.golang.org/grpc v1.21.0/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= 314 | google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= 315 | gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= 316 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 317 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 318 | gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= 319 | gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 320 | gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= 321 | gopkg.in/ini.v1 v1.51.0 h1:AQvPpx3LzTDM0AjnIRlVFwFFGC+npRopjZxLJj6gdno= 322 | gopkg.in/ini.v1 v1.51.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= 323 | gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo= 324 | gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74= 325 | gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 326 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 327 | gopkg.in/yaml.v2 v2.2.4 h1:/eiJrUcujPVeJ3xlSWaiNi3uSVmDGBK1pDHUHAnao1I= 328 | gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 329 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= 330 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 331 | honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 332 | honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 333 | honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 334 | honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= 335 | rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= 336 | -------------------------------------------------------------------------------- /pkg/cfg/config.go: -------------------------------------------------------------------------------- 1 | // Package cfg provides common configuration to all commands. 2 | // It contains config key names, default values and provides methods to read values from global gitconfig file. 3 | package cfg 4 | 5 | import ( 6 | "bytes" 7 | "fmt" 8 | "path/filepath" 9 | "strings" 10 | 11 | "github.com/mitchellh/go-homedir" 12 | "github.com/spf13/viper" 13 | ) 14 | 15 | // GitgetPrefix is the name of the gitconfig section name and the env var prefix. 16 | const GitgetPrefix = "gitget" 17 | 18 | // CLI flag keys. 19 | var ( 20 | KeyBranch = "branch" 21 | KeyDump = "dump" 22 | KeyDefaultHost = "host" 23 | KeyFetch = "fetch" 24 | KeyOutput = "out" 25 | KeyDefaultScheme = "scheme" 26 | KeySkipHost = "skip-host" 27 | KeyReposRoot = "root" 28 | ) 29 | 30 | // Defaults is a map of default values for config keys. 31 | var Defaults = map[string]string{ 32 | KeyDefaultHost: "github.com", 33 | KeyOutput: OutTree, 34 | KeyReposRoot: fmt.Sprintf("~%c%s", filepath.Separator, "repositories"), 35 | KeyDefaultScheme: "ssh", 36 | } 37 | 38 | // Values for the --out flag. 39 | const ( 40 | OutDump = "dump" 41 | OutFlat = "flat" 42 | OutTree = "tree" 43 | ) 44 | 45 | // AllowedOut are allowed values for the --out flag. 46 | var AllowedOut = []string{OutDump, OutFlat, OutTree} 47 | 48 | // Version metadata set by ldflags during the build. 49 | var ( 50 | version string 51 | commit string 52 | date string 53 | ) 54 | 55 | // Version returns a string with version metadata: version number, git sha and build date. 56 | // It returns "development" if version variables are not set during the build. 57 | func Version() string { 58 | if version == "" { 59 | return "development" 60 | } 61 | 62 | return fmt.Sprintf("%s - revision %s built at %s", version, commit[:6], date) 63 | } 64 | 65 | // Gitconfig represents gitconfig file 66 | type Gitconfig interface { 67 | Get(key string) string 68 | } 69 | 70 | // Init initializes viper config registry. Values are looked up in the following order: cli flag, env variable, gitconfig file, default value. 71 | func Init(cfg Gitconfig) { 72 | readGitconfig(cfg) 73 | 74 | viper.SetEnvPrefix(strings.ToUpper(GitgetPrefix)) 75 | viper.AutomaticEnv() 76 | } 77 | 78 | // readGitConfig loads values from gitconfig file into viper's registry. 79 | // Viper doesn't support the gitconfig format so we load it using "git config --global" command and populate a temporary "env" string, 80 | // which is then feed to Viper. 81 | func readGitconfig(cfg Gitconfig) { 82 | var lines []string 83 | 84 | // TODO: Can we somehow iterate over all possible flags? 85 | for key := range Defaults { 86 | if val := cfg.Get(fmt.Sprintf("%s.%s", GitgetPrefix, key)); val != "" { 87 | lines = append(lines, fmt.Sprintf("%s=%s", key, val)) 88 | } 89 | } 90 | 91 | viper.SetConfigType("env") 92 | viper.ReadConfig(bytes.NewBuffer([]byte(strings.Join(lines, "\n")))) 93 | 94 | // TODO: A hacky way to read boolean flag from gitconfig. Find a cleaner way. 95 | if val := cfg.Get(fmt.Sprintf("%s.%s", GitgetPrefix, KeySkipHost)); strings.ToLower(val) == "true" { 96 | viper.Set(KeySkipHost, true) 97 | } 98 | } 99 | 100 | // Expand applies the variables expansion to a viper config of given key. 101 | // If expansion fails or is not needed, the config is not modified. 102 | func Expand(key string) { 103 | if expanded, err := homedir.Expand(viper.GetString(key)); err == nil { 104 | viper.Set(key, expanded) 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /pkg/cfg/config_test.go: -------------------------------------------------------------------------------- 1 | package cfg 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strings" 7 | "testing" 8 | 9 | "github.com/spf13/cobra" 10 | "github.com/spf13/viper" 11 | ) 12 | 13 | var ( 14 | envVarName = strings.ToUpper(fmt.Sprintf("%s_%s", GitgetPrefix, KeyDefaultHost)) 15 | fromGitconfig = "value.from.gitconfig" 16 | fromEnv = "value.from.env" 17 | fromFlag = "value.from.flag" 18 | ) 19 | 20 | func TestConfig(t *testing.T) { 21 | tests := []struct { 22 | name string 23 | configMaker func(*testing.T) 24 | key string 25 | want string 26 | }{ 27 | { 28 | name: "no config", 29 | configMaker: testConfigEmpty, 30 | key: KeyDefaultHost, 31 | want: Defaults[KeyDefaultHost], 32 | }, 33 | { 34 | name: "value only in gitconfig", 35 | configMaker: testConfigOnlyInGitconfig, 36 | key: KeyDefaultHost, 37 | want: fromGitconfig, 38 | }, 39 | { 40 | name: "value only in env var", 41 | configMaker: testConfigOnlyInEnvVar, 42 | key: KeyDefaultHost, 43 | want: fromEnv, 44 | }, 45 | { 46 | name: "value in gitconfig and env var", 47 | configMaker: testConfigInGitconfigAndEnvVar, 48 | key: KeyDefaultHost, 49 | want: fromEnv, 50 | }, 51 | { 52 | name: "value in flag", 53 | configMaker: testConfigInFlag, 54 | key: KeyDefaultHost, 55 | want: fromFlag, 56 | }, 57 | } 58 | 59 | for _, test := range tests { 60 | t.Run(test.name, func(t *testing.T) { 61 | viper.SetDefault(test.key, Defaults[KeyDefaultHost]) 62 | test.configMaker(t) 63 | 64 | got := viper.GetString(test.key) 65 | if got != test.want { 66 | t.Errorf("expected %q; got %q", test.want, got) 67 | } 68 | 69 | // Clear env variables and reset viper registry after each test so they impact other tests. 70 | os.Clearenv() 71 | viper.Reset() 72 | }) 73 | } 74 | } 75 | 76 | type gitconfigEmpty struct{} 77 | 78 | func (c *gitconfigEmpty) Get(key string) string { 79 | return "" 80 | } 81 | 82 | type gitconfigValid struct{} 83 | 84 | func (c *gitconfigValid) Get(key string) string { 85 | return fromGitconfig 86 | } 87 | 88 | func testConfigEmpty(t *testing.T) { 89 | Init(&gitconfigEmpty{}) 90 | } 91 | 92 | func testConfigOnlyInGitconfig(t *testing.T) { 93 | Init(&gitconfigValid{}) 94 | } 95 | 96 | func testConfigOnlyInEnvVar(t *testing.T) { 97 | Init(&gitconfigEmpty{}) 98 | os.Setenv(envVarName, fromEnv) 99 | 100 | } 101 | 102 | func testConfigInGitconfigAndEnvVar(t *testing.T) { 103 | Init(&gitconfigValid{}) 104 | os.Setenv(envVarName, fromEnv) 105 | } 106 | 107 | func testConfigInFlag(t *testing.T) { 108 | Init(&gitconfigValid{}) 109 | os.Setenv(envVarName, fromEnv) 110 | 111 | cmd := cobra.Command{} 112 | cmd.PersistentFlags().String(KeyDefaultHost, Defaults[KeyDefaultHost], "") 113 | viper.BindPFlag(KeyDefaultHost, cmd.PersistentFlags().Lookup(KeyDefaultHost)) 114 | 115 | cmd.SetArgs([]string{"--" + KeyDefaultHost, fromFlag}) 116 | cmd.Execute() 117 | } 118 | -------------------------------------------------------------------------------- /pkg/dump.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import ( 4 | "bufio" 5 | "os" 6 | "strings" 7 | 8 | "github.com/pkg/errors" 9 | ) 10 | 11 | var ( 12 | errInvalidNumberOfElements = errors.New("more than two space-separated 2 elements on the line") 13 | errEmptyLine = errors.New("empty line") 14 | ) 15 | 16 | type parsedLine struct { 17 | rawurl string 18 | branch string 19 | } 20 | 21 | // ParseDumpFile opens a given gitgetfile and parses its content into a slice of CloneOpts. 22 | func parseDumpFile(path string) ([]parsedLine, error) { 23 | file, err := os.Open(path) 24 | if err != nil { 25 | return nil, errors.Wrapf(err, "failed opening dump file %s", path) 26 | } 27 | defer file.Close() 28 | 29 | scanner := bufio.NewScanner(file) 30 | 31 | var parsedLines []parsedLine 32 | var line int 33 | for scanner.Scan() { 34 | line++ 35 | parsed, err := parseLine(scanner.Text()) 36 | if err != nil && !errors.Is(errEmptyLine, err) { 37 | return nil, errors.Wrapf(err, "failed parsing dump file line %d", line) 38 | } 39 | 40 | parsedLines = append(parsedLines, parsed) 41 | } 42 | 43 | return parsedLines, nil 44 | } 45 | 46 | // parseLine splits a dump file line into space-separated segments. 47 | // First part is the URL to clone. Second, optional, is the branch (or tag) to checkout after cloning 48 | func parseLine(line string) (parsedLine, error) { 49 | var parsed parsedLine 50 | 51 | if len(strings.TrimSpace(line)) == 0 { 52 | return parsed, errEmptyLine 53 | } 54 | 55 | parts := strings.Split(strings.TrimSpace(line), " ") 56 | if len(parts) > 2 { 57 | return parsed, errInvalidNumberOfElements 58 | } 59 | 60 | parsed.rawurl = parts[0] 61 | if len(parts) == 2 { 62 | parsed.branch = parts[1] 63 | } 64 | 65 | return parsed, nil 66 | } 67 | -------------------------------------------------------------------------------- /pkg/dump_test.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestParsingRefs(t *testing.T) { 8 | var tests = []struct { 9 | name string 10 | line string 11 | wantBranch string 12 | wantErr error 13 | }{ 14 | { 15 | name: "url without branch", 16 | line: "https://github.com/grdl/git-get", 17 | wantBranch: "", 18 | wantErr: nil, 19 | }, 20 | { 21 | name: "url with branch", 22 | line: "https://github.com/grdl/git-get master", 23 | wantBranch: "master", 24 | wantErr: nil, 25 | }, 26 | { 27 | name: "url with multiple branches", 28 | line: "https://github.com/grdl/git-get master branch", 29 | wantBranch: "", 30 | wantErr: errInvalidNumberOfElements, 31 | }, 32 | { 33 | name: "url without path", 34 | line: "https://github.com", 35 | wantBranch: "", 36 | wantErr: errEmptyURLPath, 37 | }, 38 | } 39 | 40 | for _, test := range tests { 41 | t.Run(test.name, func(t *testing.T) { 42 | got, err := parseLine(test.line) 43 | if err != nil && test.wantErr == nil { 44 | t.Fatalf("got error %q", err) 45 | } 46 | 47 | // TODO: this should check if we actually got the error we expected 48 | if err != nil && test.wantErr != nil { 49 | return 50 | } 51 | 52 | if got.branch != test.wantBranch { 53 | t.Errorf("expected %q; got %q", test.wantBranch, got.branch) 54 | } 55 | }) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /pkg/get.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import ( 4 | "fmt" 5 | "git-get/pkg/git" 6 | "path/filepath" 7 | ) 8 | 9 | // GetCfg provides configuration for the Get command. 10 | type GetCfg struct { 11 | Branch string 12 | DefHost string 13 | DefScheme string 14 | Dump string 15 | Root string 16 | SkipHost bool 17 | URL string 18 | } 19 | 20 | // Get executes the "git get" command. 21 | func Get(c *GetCfg) error { 22 | if c.URL == "" && c.Dump == "" { 23 | return fmt.Errorf("missing argument or --dump flag") 24 | } 25 | 26 | if c.URL != "" { 27 | return cloneSingleRepo(c) 28 | } 29 | 30 | if c.Dump != "" { 31 | return cloneDumpFile(c) 32 | } 33 | return nil 34 | } 35 | 36 | func cloneSingleRepo(c *GetCfg) error { 37 | url, err := ParseURL(c.URL, c.DefHost, c.DefScheme) 38 | if err != nil { 39 | return err 40 | } 41 | 42 | opts := &git.CloneOpts{ 43 | URL: url, 44 | Path: filepath.Join(c.Root, URLToPath(*url, c.SkipHost)), 45 | Branch: c.Branch, 46 | } 47 | 48 | _, err = git.Clone(opts) 49 | 50 | return err 51 | } 52 | 53 | func cloneDumpFile(c *GetCfg) error { 54 | parsedLines, err := parseDumpFile(c.Dump) 55 | if err != nil { 56 | return err 57 | } 58 | 59 | for _, line := range parsedLines { 60 | url, err := ParseURL(line.rawurl, c.DefHost, c.DefScheme) 61 | if err != nil { 62 | return err 63 | } 64 | 65 | opts := &git.CloneOpts{ 66 | URL: url, 67 | Path: filepath.Join(c.Root, URLToPath(*url, c.SkipHost)), 68 | Branch: line.branch, 69 | } 70 | 71 | // If target path already exists, skip cloning this repo 72 | if exists, _ := git.Exists(opts.Path); exists { 73 | continue 74 | } 75 | 76 | fmt.Printf("Cloning %s...\n", opts.URL.String()) 77 | _, err = git.Clone(opts) 78 | if err != nil { 79 | return err 80 | } 81 | } 82 | return nil 83 | } 84 | -------------------------------------------------------------------------------- /pkg/git/config.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import ( 4 | "git-get/pkg/run" 5 | ) 6 | 7 | // ConfigGlobal represents a global gitconfig file. 8 | type ConfigGlobal struct{} 9 | 10 | // Get reads a value from global gitconfig file. Returns empty string when key is missing. 11 | func (c *ConfigGlobal) Get(key string) string { 12 | out, err := run.Git("config", "--global", key).AndCaptureLine() 13 | // In case of error return an empty string, the missing value will fall back to a default. 14 | if err != nil { 15 | return "" 16 | } 17 | 18 | return out 19 | } 20 | -------------------------------------------------------------------------------- /pkg/git/config_test.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import ( 4 | "git-get/pkg/git/test" 5 | "git-get/pkg/run" 6 | "testing" 7 | ) 8 | 9 | // cfgStub represents a gitconfig file but instead of using a global one, it creates a temporary git repo and uses its local gitconfig. 10 | type cfgStub struct { 11 | *test.Repo 12 | } 13 | 14 | func (c *cfgStub) Get(key string) string { 15 | out, err := run.Git("config", "--local", key).OnRepo(c.Path()).AndCaptureLine() 16 | if err != nil { 17 | return "" 18 | } 19 | 20 | return out 21 | } 22 | 23 | func TestGitConfig(t *testing.T) { 24 | tests := []struct { 25 | name string 26 | configMaker func(t *testing.T) *cfgStub 27 | key string 28 | want string 29 | }{ 30 | { 31 | name: "empty", 32 | configMaker: makeConfigEmpty, 33 | key: "gitget.host", 34 | want: "", 35 | }, 36 | { 37 | name: "valid", 38 | configMaker: makeConfigValid, 39 | key: "gitget.host", 40 | want: "github.com", 41 | }, { 42 | name: "only section name", 43 | configMaker: makeConfigValid, 44 | key: "gitget", 45 | want: "", 46 | }, { 47 | name: "missing key", 48 | configMaker: makeConfigValid, 49 | key: "gitget.missingkey", 50 | want: "", 51 | }, 52 | } 53 | 54 | for _, test := range tests { 55 | t.Run(test.name, func(t *testing.T) { 56 | cfg := test.configMaker(t) 57 | 58 | got := cfg.Get(test.key) 59 | 60 | if got != test.want { 61 | t.Errorf("expected %q; got %q", test.want, got) 62 | } 63 | }) 64 | } 65 | } 66 | 67 | func makeConfigEmpty(t *testing.T) *cfgStub { 68 | return &cfgStub{ 69 | Repo: test.RepoWithEmptyConfig(t), 70 | } 71 | } 72 | 73 | func makeConfigValid(t *testing.T) *cfgStub { 74 | return &cfgStub{ 75 | Repo: test.RepoWithValidConfig(t), 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /pkg/git/finder.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "sort" 8 | "strings" 9 | "syscall" 10 | 11 | "github.com/karrick/godirwalk" 12 | "github.com/pkg/errors" 13 | ) 14 | 15 | // Max number of concurrently running status loading workers. 16 | const maxWorkers = 100 17 | 18 | // errSkipNode is used as an error indicating that .git directory has been found. 19 | // It's handled by ErrorsCallback to tell the WalkCallback to skip this dir. 20 | var errSkipNode = errors.New(".git directory found, skipping this node") 21 | 22 | var errDirNoAccess = errors.New("directory can't be accessed") 23 | var errDirNotExist = errors.New("directory doesn't exist") 24 | 25 | // Exists returns true if a directory exists. If it doesn't or the directory can't be accessed it returns an error. 26 | func Exists(path string) (bool, error) { 27 | _, err := os.Stat(path) 28 | 29 | if err == nil { 30 | return true, nil 31 | } 32 | 33 | if err != nil { 34 | if os.IsNotExist(err) { 35 | return false, errors.Wrapf(errDirNotExist, "can't access %s", path) 36 | } 37 | } 38 | 39 | // Directory exists but can't be accessed 40 | return true, errors.Wrapf(errDirNoAccess, "can't access %s", path) 41 | } 42 | 43 | // RepoFinder finds git repositories inside a given path and loads their status. 44 | type RepoFinder struct { 45 | root string 46 | repos []*Repo 47 | maxWorkers int 48 | } 49 | 50 | // NewRepoFinder returns a RepoFinder pointed at given root path. 51 | func NewRepoFinder(root string) *RepoFinder { 52 | return &RepoFinder{ 53 | root: root, 54 | maxWorkers: maxWorkers, 55 | } 56 | } 57 | 58 | // Find finds git repositories inside a given root path. 59 | // It doesn't add repositories nested inside other git repos. 60 | // Returns error if root repo path can't be found or accessed. 61 | func (f *RepoFinder) Find() error { 62 | if _, err := Exists(f.root); err != nil { 63 | return err 64 | } 65 | 66 | walkOpts := &godirwalk.Options{ 67 | ErrorCallback: f.errorCb, 68 | Callback: f.walkCb, 69 | // Use Unsorted to improve speed because repos will be processed by goroutines in a random order anyway. 70 | Unsorted: true, 71 | } 72 | 73 | err := godirwalk.Walk(f.root, walkOpts) 74 | if err != nil { 75 | return err 76 | } 77 | 78 | if len(f.repos) == 0 { 79 | return fmt.Errorf("no git repos found in root path %s", f.root) 80 | } 81 | 82 | return nil 83 | } 84 | 85 | // LoadAll loads and returns sorted slice of statuses of all repositories found by RepoFinder. 86 | // If fetch equals true, it first fetches from the remote repo before loading the status. 87 | // Each repo is loaded concurrently by a separate worker, with max 100 workers being active at the same time. 88 | func (f *RepoFinder) LoadAll(fetch bool) []*Status { 89 | var ss []*Status 90 | 91 | reposChan := make(chan *Repo, f.maxWorkers) 92 | statusChan := make(chan *Status, f.maxWorkers) 93 | 94 | // Fire up workers. They listen on reposChan, load status and send the result to statusChan. 95 | for i := 0; i < f.maxWorkers; i++ { 96 | go statusWorker(fetch, reposChan, statusChan) 97 | } 98 | 99 | // Start loading the slice of repos found by finder into the reposChan. 100 | // It runs in a goroutine so that as soon as repos appear on the channel they can be processed and sent to statusChan. 101 | go loadRepos(f.repos, reposChan) 102 | 103 | // Read statuses from the statusChan and add then to the result slice. 104 | // Close the channel when all repos are loaded. 105 | for status := range statusChan { 106 | ss = append(ss, status) 107 | if len(ss) == len(f.repos) { 108 | close(statusChan) 109 | } 110 | } 111 | 112 | // Sort the status slice by path 113 | sort.Slice(ss, func(i, j int) bool { 114 | return strings.Compare(ss[i].path, ss[j].path) < 0 115 | }) 116 | 117 | return ss 118 | } 119 | 120 | func loadRepos(repos []*Repo, reposChan chan<- *Repo) { 121 | for _, repo := range repos { 122 | reposChan <- repo 123 | } 124 | 125 | close(reposChan) 126 | } 127 | 128 | func statusWorker(fetch bool, reposChan <-chan *Repo, statusChan chan<- *Status) { 129 | for repo := range reposChan { 130 | statusChan <- repo.LoadStatus(fetch) 131 | } 132 | } 133 | 134 | func (f *RepoFinder) walkCb(path string, ent *godirwalk.Dirent) error { 135 | // Do not traverse .git directories 136 | if ent.IsDir() && ent.Name() == dotgit { 137 | f.addIfOk(path) 138 | return errSkipNode 139 | } 140 | 141 | // Do not traverse directories containing a .git directory 142 | if ent.IsDir() { 143 | _, err := os.Stat(filepath.Join(path, dotgit)) 144 | if err == nil { 145 | f.addIfOk(path) 146 | return errSkipNode 147 | } 148 | } 149 | return nil 150 | } 151 | 152 | // addIfOk adds the found repo to the repos slice if it can be opened. 153 | func (f *RepoFinder) addIfOk(path string) { 154 | // TODO: is the case below really correct? What if there's a race condition and the dir becomes unaccessible between finding it and opening? 155 | 156 | // Open() should never return an error here. If a finder found a .git inside this dir, it means it could open and access it. 157 | // If the dir was unaccessible, then it would have been skipped by the check in errorCb(). 158 | repo, err := Open(strings.TrimSuffix(path, dotgit)) 159 | if err == nil { 160 | f.repos = append(f.repos, repo) 161 | } 162 | } 163 | 164 | func (f *RepoFinder) errorCb(_ string, err error) godirwalk.ErrorAction { 165 | // Skip .git directory and directories we don't have permissions to access 166 | // TODO: Will syscall.EACCES work on windows? 167 | if errors.Is(err, errSkipNode) || errors.Is(err, syscall.EACCES) { 168 | return godirwalk.SkipNode 169 | } 170 | return godirwalk.Halt 171 | } 172 | -------------------------------------------------------------------------------- /pkg/git/finder_test.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import ( 4 | "errors" 5 | "git-get/pkg/git/test" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestFinder(t *testing.T) { 12 | tests := []struct { 13 | name string 14 | reposMaker func(*testing.T) string 15 | want int 16 | }{ 17 | { 18 | name: "no repos", 19 | reposMaker: makeNoRepos, 20 | want: 0, 21 | }, { 22 | name: "single repos", 23 | reposMaker: makeSingleRepo, 24 | want: 1, 25 | }, { 26 | name: "single nested repo", 27 | reposMaker: makeNestedRepo, 28 | want: 1, 29 | }, { 30 | name: "multiple nested repo", 31 | reposMaker: makeMultipleNestedRepos, 32 | want: 2, 33 | }, 34 | } 35 | 36 | for _, test := range tests { 37 | t.Run(test.name, func(t *testing.T) { 38 | root := test.reposMaker(t) 39 | 40 | finder := NewRepoFinder(root) 41 | finder.Find() 42 | 43 | assert.Len(t, finder.repos, test.want) 44 | }) 45 | } 46 | } 47 | 48 | // TODO: this test will only work on Linux 49 | func TestExists(t *testing.T) { 50 | tests := []struct { 51 | name string 52 | path string 53 | want error 54 | }{ 55 | { 56 | name: "dir does not exist", 57 | path: "/this/directory/does/not/exist", 58 | want: errDirNotExist, 59 | }, { 60 | name: "dir cant be accessed", 61 | path: "/root/some/directory", 62 | want: errDirNoAccess, 63 | }, { 64 | name: "dir exists", 65 | path: "/tmp/", 66 | want: nil, 67 | }, 68 | } 69 | 70 | for _, test := range tests { 71 | t.Run(test.name, func(t *testing.T) { 72 | _, err := Exists(test.path) 73 | 74 | assert.True(t, errors.Is(err, test.want)) 75 | }) 76 | } 77 | } 78 | 79 | func makeNoRepos(t *testing.T) string { 80 | root := test.TempDir(t, "") 81 | 82 | return root 83 | } 84 | 85 | func makeSingleRepo(t *testing.T) string { 86 | root := test.TempDir(t, "") 87 | 88 | test.RepoEmptyInDir(t, root) 89 | 90 | return root 91 | } 92 | 93 | func makeNestedRepo(t *testing.T) string { 94 | // a repo with single nested repo should still be counted as one beacause finder doesn't traverse inside nested repos 95 | root := test.TempDir(t, "") 96 | 97 | r := test.RepoEmptyInDir(t, root) 98 | test.RepoEmptyInDir(t, r.Path()) 99 | 100 | return root 101 | } 102 | 103 | func makeMultipleNestedRepos(t *testing.T) string { 104 | root := test.TempDir(t, "") 105 | 106 | // create two repos inside root - should be counted as 2 107 | repo1 := test.RepoEmptyInDir(t, root) 108 | repo2 := test.RepoEmptyInDir(t, root) 109 | 110 | // created repos nested inside two parent roots - shouldn't be counted 111 | test.RepoEmptyInDir(t, repo1.Path()) 112 | test.RepoEmptyInDir(t, repo1.Path()) 113 | test.RepoEmptyInDir(t, repo2.Path()) 114 | 115 | // create a empty dir inside root - shouldn't be counted 116 | test.TempDir(t, root) 117 | 118 | return root 119 | } 120 | -------------------------------------------------------------------------------- /pkg/git/repo.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import ( 4 | "fmt" 5 | "git-get/pkg/run" 6 | "net/url" 7 | "os" 8 | "path/filepath" 9 | "strconv" 10 | "strings" 11 | ) 12 | 13 | const ( 14 | dotgit = ".git" 15 | untracked = "??" // Untracked files are marked as "??" in git status output. 16 | master = "master" 17 | head = "HEAD" 18 | ) 19 | 20 | // Repo represents a git Repository cloned or initialized on disk. 21 | type Repo struct { 22 | path string 23 | } 24 | 25 | // CloneOpts specify detail about Repository to clone. 26 | type CloneOpts struct { 27 | URL *url.URL 28 | Path string // TODO: should Path be a part of clone opts? 29 | Branch string 30 | Quiet bool 31 | } 32 | 33 | // Open checks if given path can be accessed and returns a Repo instance pointing to it. 34 | func Open(path string) (*Repo, error) { 35 | if _, err := Exists(path); err != nil { 36 | return nil, err 37 | } 38 | 39 | return &Repo{ 40 | path: path, 41 | }, nil 42 | } 43 | 44 | // Clone clones Repository specified with CloneOpts. 45 | func Clone(opts *CloneOpts) (*Repo, error) { 46 | runGit := run.Git("clone", opts.URL.String(), opts.Path) 47 | if opts.Branch != "" { 48 | runGit = run.Git("clone", "--branch", opts.Branch, "--single-branch", opts.URL.String(), opts.Path) 49 | } 50 | 51 | var err error 52 | if opts.Quiet { 53 | err = runGit.AndShutUp() 54 | } else { 55 | err = runGit.AndShow() 56 | } 57 | 58 | if err != nil { 59 | cleanupFailedClone(opts.Path) 60 | return nil, err 61 | } 62 | 63 | Repo, err := Open(opts.Path) 64 | return Repo, err 65 | } 66 | 67 | // Fetch preforms a git fetch on all remotes 68 | func (r *Repo) Fetch() error { 69 | err := run.Git("fetch", "--all").OnRepo(r.path).AndShutUp() 70 | return err 71 | } 72 | 73 | // Uncommitted returns the number of uncommitted files in the Repository. 74 | // Only tracked files are not counted. 75 | func (r *Repo) Uncommitted() (int, error) { 76 | out, err := run.Git("status", "--ignore-submodules", "--porcelain").OnRepo(r.path).AndCaptureLines() 77 | if err != nil { 78 | return 0, err 79 | } 80 | 81 | count := 0 82 | for _, line := range out { 83 | // Don't count lines with untracked files and empty lines. 84 | if !strings.HasPrefix(line, untracked) && strings.TrimSpace(line) != "" { 85 | count++ 86 | } 87 | } 88 | 89 | return count, nil 90 | } 91 | 92 | // Untracked returns the number of untracked files in the Repository. 93 | func (r *Repo) Untracked() (int, error) { 94 | out, err := run.Git("status", "--ignore-submodules", "--untracked-files=all", "--porcelain").OnRepo(r.path).AndCaptureLines() 95 | if err != nil { 96 | return 0, err 97 | } 98 | 99 | count := 0 100 | for _, line := range out { 101 | if strings.HasPrefix(line, untracked) { 102 | count++ 103 | } 104 | } 105 | 106 | return count, nil 107 | } 108 | 109 | // CurrentBranch returns the short name currently checked-out branch for the Repository. 110 | // If Repo is in a detached head state, it will return "HEAD". 111 | func (r *Repo) CurrentBranch() (string, error) { 112 | out, err := run.Git("rev-parse", "--symbolic-full-name", "--abbrev-ref", "HEAD").OnRepo(r.path).AndCaptureLine() 113 | if err != nil { 114 | return "", err 115 | } 116 | 117 | return out, nil 118 | } 119 | 120 | // Branches returns a list of local branches in the Repository. 121 | func (r *Repo) Branches() ([]string, error) { 122 | out, err := run.Git("branch", "--format=%(refname:short)").OnRepo(r.path).AndCaptureLines() 123 | if err != nil { 124 | return nil, err 125 | } 126 | 127 | // TODO: Is detached head shown always on the first line? Maybe we don't need to iterate over everything. 128 | // Remove the line containing detached head. 129 | for i, line := range out { 130 | if strings.Contains(line, "HEAD detached") { 131 | out = append(out[:i], out[i+1:]...) 132 | } 133 | } 134 | 135 | return out, nil 136 | } 137 | 138 | // Upstream returns the name of an upstream branch if a given branch is tracking one. 139 | // Otherwise it returns an empty string. 140 | func (r *Repo) Upstream(branch string) (string, error) { 141 | out, err := run.Git("rev-parse", "--abbrev-ref", "--symbolic-full-name", fmt.Sprintf("%s@{upstream}", branch)).OnRepo(r.path).AndCaptureLine() 142 | if err != nil { 143 | // TODO: no upstream will also throw an error. 144 | return "", nil 145 | } 146 | 147 | return out, nil 148 | } 149 | 150 | // AheadBehind returns the number of commits a given branch is ahead and/or behind the upstream. 151 | func (r *Repo) AheadBehind(branch string, upstream string) (int, int, error) { 152 | out, err := run.Git("rev-list", "--left-right", "--count", fmt.Sprintf("%s...%s", branch, upstream)).OnRepo(r.path).AndCaptureLine() 153 | if err != nil { 154 | return 0, 0, err 155 | } 156 | 157 | // rev-list --left-right --count output is separated by a tab 158 | lr := strings.Split(out, "\t") 159 | 160 | ahead, err := strconv.Atoi(lr[0]) 161 | if err != nil { 162 | return 0, 0, err 163 | } 164 | 165 | behind, err := strconv.Atoi(lr[1]) 166 | if err != nil { 167 | return 0, 0, err 168 | } 169 | 170 | return ahead, behind, nil 171 | } 172 | 173 | // Remote returns URL of remote Repository. 174 | func (r *Repo) Remote() (string, error) { 175 | // https://stackoverflow.com/a/16880000/1085632 176 | out, err := run.Git("ls-remote", "--get-url").OnRepo(r.path).AndCaptureLine() 177 | if err != nil { 178 | return "", err 179 | } 180 | 181 | // TODO: needs testing. What happens when there are more than 1 remotes? 182 | return out, nil 183 | } 184 | 185 | // Path returns path to the Repository. 186 | func (r *Repo) Path() string { 187 | return r.path 188 | } 189 | 190 | // cleanupFailedClone removes empty directories created by a failed git clone. 191 | // Git itself will delete the final repo directory if a clone has failed, 192 | // but it won't delete all the parent dirs that it created when cloning. 193 | // eg: 194 | // When operation like `git clone https://github.com/grdl/git-get /tmp/some/temp/dir/git-get` fails, 195 | // git will only delete the final `git-get` dir in the path, but will leave /tmp/some/temp/dir even if it just created them. 196 | // 197 | // os.Remove will only delete an empty dir so we traverse the path "upwards" and delete all directories 198 | // until a non-empty one is reached. 199 | func cleanupFailedClone(path string) { 200 | for { 201 | path = filepath.Dir(path) 202 | if err := os.Remove(path); err != nil { 203 | return 204 | } 205 | } 206 | } 207 | -------------------------------------------------------------------------------- /pkg/git/repo_test.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import ( 4 | "fmt" 5 | "git-get/pkg/git/test" 6 | "os" 7 | "path/filepath" 8 | "reflect" 9 | "testing" 10 | 11 | "github.com/stretchr/testify/assert" 12 | ) 13 | 14 | func TestUncommitted(t *testing.T) { 15 | tests := []struct { 16 | name string 17 | repoMaker func(*testing.T) *test.Repo 18 | want int 19 | }{ 20 | { 21 | name: "empty", 22 | repoMaker: test.RepoEmpty, 23 | want: 0, 24 | }, 25 | { 26 | name: "single untracked", 27 | repoMaker: test.RepoWithUntracked, 28 | want: 0, 29 | }, 30 | { 31 | name: "single tracked", 32 | repoMaker: test.RepoWithStaged, 33 | want: 1, 34 | }, 35 | { 36 | name: "committed", 37 | repoMaker: test.RepoWithCommit, 38 | want: 0, 39 | }, 40 | { 41 | name: "untracked and uncommitted", 42 | repoMaker: test.RepoWithUncommittedAndUntracked, 43 | want: 1, 44 | }, 45 | } 46 | 47 | for _, test := range tests { 48 | t.Run(test.name, func(t *testing.T) { 49 | r, _ := Open(test.repoMaker(t).Path()) 50 | got, err := r.Uncommitted() 51 | 52 | if err != nil { 53 | t.Errorf("got error %q", err) 54 | } 55 | 56 | if got != test.want { 57 | t.Errorf("expected %d; got %d", test.want, got) 58 | } 59 | }) 60 | } 61 | } 62 | func TestUntracked(t *testing.T) { 63 | tests := []struct { 64 | name string 65 | repoMaker func(*testing.T) *test.Repo 66 | want int 67 | }{ 68 | { 69 | name: "empty", 70 | repoMaker: test.RepoEmpty, 71 | want: 0, 72 | }, 73 | { 74 | name: "single untracked", 75 | repoMaker: test.RepoWithUntracked, 76 | want: 0, 77 | }, 78 | { 79 | name: "single tracked ", 80 | repoMaker: test.RepoWithStaged, 81 | want: 1, 82 | }, 83 | { 84 | name: "committed", 85 | repoMaker: test.RepoWithCommit, 86 | want: 0, 87 | }, 88 | { 89 | name: "untracked and uncommitted", 90 | repoMaker: test.RepoWithUncommittedAndUntracked, 91 | want: 1, 92 | }, 93 | } 94 | 95 | for _, test := range tests { 96 | t.Run(test.name, func(t *testing.T) { 97 | r, _ := Open(test.repoMaker(t).Path()) 98 | got, err := r.Uncommitted() 99 | 100 | if err != nil { 101 | t.Errorf("got error %q", err) 102 | } 103 | 104 | if got != test.want { 105 | t.Errorf("expected %d; got %d", test.want, got) 106 | } 107 | }) 108 | } 109 | } 110 | 111 | func TestCurrentBranch(t *testing.T) { 112 | tests := []struct { 113 | name string 114 | repoMaker func(*testing.T) *test.Repo 115 | want string 116 | }{ 117 | // TODO: maybe add wantErr to check if error is returned correctly? 118 | // { 119 | // name: "empty", 120 | // repoMaker: newTestRepo, 121 | // want: "", 122 | // }, 123 | { 124 | name: "only master branch", 125 | repoMaker: test.RepoWithCommit, 126 | want: master, 127 | }, 128 | { 129 | name: "checked out new branch", 130 | repoMaker: test.RepoWithBranch, 131 | want: "feature/branch", 132 | }, 133 | { 134 | name: "checked out new tag", 135 | repoMaker: test.RepoWithTag, 136 | want: head, 137 | }, 138 | } 139 | 140 | for _, test := range tests { 141 | t.Run(test.name, func(t *testing.T) { 142 | r, _ := Open(test.repoMaker(t).Path()) 143 | got, err := r.CurrentBranch() 144 | 145 | if err != nil { 146 | t.Errorf("got error %q", err) 147 | } 148 | 149 | if got != test.want { 150 | t.Errorf("expected %q; got %q", test.want, got) 151 | } 152 | }) 153 | } 154 | } 155 | func TestBranches(t *testing.T) { 156 | tests := []struct { 157 | name string 158 | repoMaker func(*testing.T) *test.Repo 159 | want []string 160 | }{ 161 | { 162 | name: "empty", 163 | repoMaker: test.RepoEmpty, 164 | want: []string{""}, 165 | }, 166 | { 167 | name: "only master branch", 168 | repoMaker: test.RepoWithCommit, 169 | want: []string{"master"}, 170 | }, 171 | { 172 | name: "new branch", 173 | repoMaker: test.RepoWithBranch, 174 | want: []string{"feature/branch", "master"}, 175 | }, 176 | { 177 | name: "checked out new tag", 178 | repoMaker: test.RepoWithTag, 179 | want: []string{"master"}, 180 | }, 181 | } 182 | 183 | for _, test := range tests { 184 | t.Run(test.name, func(t *testing.T) { 185 | r, _ := Open(test.repoMaker(t).Path()) 186 | got, err := r.Branches() 187 | 188 | if err != nil { 189 | t.Errorf("got error %q", err) 190 | } 191 | 192 | if !reflect.DeepEqual(got, test.want) { 193 | t.Errorf("expected %+v; got %+v", test.want, got) 194 | } 195 | }) 196 | } 197 | } 198 | func TestUpstream(t *testing.T) { 199 | tests := []struct { 200 | name string 201 | repoMaker func(*testing.T) *test.Repo 202 | branch string 203 | want string 204 | }{ 205 | { 206 | name: "empty", 207 | repoMaker: test.RepoEmpty, 208 | branch: "master", 209 | want: "", 210 | }, 211 | // TODO: add wantErr 212 | { 213 | name: "wrong branch name", 214 | repoMaker: test.RepoWithCommit, 215 | branch: "wrong_branch_name", 216 | want: "", 217 | }, 218 | { 219 | name: "master with upstream", 220 | repoMaker: test.RepoWithBranchWithUpstream, 221 | branch: "master", 222 | want: "origin/master", 223 | }, 224 | { 225 | name: "branch with upstream", 226 | repoMaker: test.RepoWithBranchWithUpstream, 227 | branch: "feature/branch", 228 | want: "origin/feature/branch", 229 | }, 230 | { 231 | name: "branch without upstream", 232 | repoMaker: test.RepoWithBranchWithoutUpstream, 233 | branch: "feature/branch", 234 | want: "", 235 | }, 236 | } 237 | 238 | for _, test := range tests { 239 | t.Run(test.name, func(t *testing.T) { 240 | r, _ := Open(test.repoMaker(t).Path()) 241 | got, _ := r.Upstream(test.branch) 242 | 243 | // TODO: 244 | // if err != nil { 245 | // t.Errorf("got error %q", err) 246 | // } 247 | 248 | if !reflect.DeepEqual(got, test.want) { 249 | t.Errorf("expected %+v; got %+v", test.want, got) 250 | } 251 | }) 252 | } 253 | } 254 | func TestAheadBehind(t *testing.T) { 255 | tests := []struct { 256 | name string 257 | repoMaker func(*testing.T) *test.Repo 258 | branch string 259 | want []int 260 | }{ 261 | { 262 | name: "fresh clone", 263 | repoMaker: test.RepoWithBranchWithUpstream, 264 | branch: "master", 265 | want: []int{0, 0}, 266 | }, 267 | { 268 | name: "branch ahead", 269 | repoMaker: test.RepoWithBranchAhead, 270 | branch: "feature/branch", 271 | want: []int{1, 0}, 272 | }, 273 | 274 | { 275 | name: "branch behind", 276 | repoMaker: test.RepoWithBranchBehind, 277 | branch: "feature/branch", 278 | want: []int{0, 1}, 279 | }, 280 | { 281 | name: "branch ahead and behind", 282 | repoMaker: test.RepoWithBranchAheadAndBehind, 283 | branch: "feature/branch", 284 | want: []int{2, 1}, 285 | }, 286 | } 287 | 288 | for _, test := range tests { 289 | t.Run(test.name, func(t *testing.T) { 290 | r, _ := Open(test.repoMaker(t).Path()) 291 | upstream, err := r.Upstream(test.branch) 292 | if err != nil { 293 | t.Errorf("got error %q", err) 294 | } 295 | 296 | ahead, behind, err := r.AheadBehind(test.branch, upstream) 297 | if err != nil { 298 | t.Errorf("got error %q", err) 299 | } 300 | 301 | if ahead != test.want[0] || behind != test.want[1] { 302 | t.Errorf("expected %+v; got [%d, %d]", test.want, ahead, behind) 303 | } 304 | }) 305 | } 306 | } 307 | 308 | func TestCleanupFailedClone(t *testing.T) { 309 | // Test dir structure: 310 | // root 311 | // └── a/ 312 | // ├── b/ 313 | // │ └── c/ 314 | // └── x/ 315 | // └── y/ 316 | // └── file.txt 317 | 318 | tests := []struct { 319 | path string // path to cleanup 320 | wantGone string // this path should be deleted, if empty - nothing should be deleted 321 | wantStay string // this path shouldn't be deleted 322 | }{ 323 | { 324 | path: "a/b/c/repo", 325 | wantGone: "a/b/c/repo", 326 | wantStay: "a", 327 | }, { 328 | path: "a/b/c/repo", 329 | wantGone: "a/b", 330 | wantStay: "a", 331 | }, { 332 | path: "a/b/repo", 333 | wantGone: "", 334 | wantStay: "a/b/c", 335 | }, { 336 | path: "a/x/y/repo", 337 | wantGone: "", 338 | wantStay: "a/x/y", 339 | }, 340 | } 341 | 342 | for i, test := range tests { 343 | t.Run(fmt.Sprintf("%d", i), func(t *testing.T) { 344 | root := createTestDirTree(t) 345 | 346 | path := filepath.Join(root, test.path) 347 | cleanupFailedClone(path) 348 | 349 | if test.wantGone != "" { 350 | wantGone := filepath.Join(root, test.wantGone) 351 | assert.NoDirExists(t, wantGone, "%s dir should be deleted during the cleanup", wantGone) 352 | } 353 | 354 | if test.wantStay != "" { 355 | wantLeft := filepath.Join(root, test.wantStay) 356 | assert.DirExists(t, wantLeft, "%s dir should not be deleted during the cleanup", wantLeft) 357 | } 358 | }) 359 | } 360 | } 361 | 362 | func createTestDirTree(t *testing.T) string { 363 | root := test.TempDir(t, "") 364 | err := os.MkdirAll(filepath.Join(root, "a", "b", "c"), os.ModePerm) 365 | err = os.MkdirAll(filepath.Join(root, "a", "x", "y"), os.ModePerm) 366 | _, err = os.Create(filepath.Join(root, "a", "x", "y", "file.txt")) 367 | 368 | if err != nil { 369 | t.Fatal(err) 370 | } 371 | 372 | return root 373 | } 374 | -------------------------------------------------------------------------------- /pkg/git/status.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | // Status contains human readable (and printable) representation of a git repository status. 9 | type Status struct { 10 | path string 11 | current string 12 | branches map[string]string // key: branch name, value: branch status 13 | worktree string 14 | remote string 15 | errors []string // Slice of errors which occurred when loading the status. 16 | } 17 | 18 | // LoadStatus reads status of a repository. 19 | // If fetch equals true, it first fetches from the remote repo before loading the status. 20 | // If errors occur during loading, they are stored in Status.errors slice. 21 | func (r *Repo) LoadStatus(fetch bool) *Status { 22 | status := &Status{ 23 | path: r.path, 24 | branches: make(map[string]string), 25 | errors: make([]string, 0), 26 | } 27 | 28 | if fetch { 29 | if err := r.Fetch(); err != nil { 30 | status.errors = append(status.errors, err.Error()) 31 | } 32 | } 33 | 34 | var err error 35 | status.current, err = r.CurrentBranch() 36 | if err != nil { 37 | status.errors = append(status.errors, err.Error()) 38 | } 39 | 40 | var errs []error 41 | status.branches, errs = r.loadBranches() 42 | for _, err := range errs { 43 | status.errors = append(status.errors, err.Error()) 44 | } 45 | 46 | status.worktree, err = r.loadWorkTree() 47 | if err != nil { 48 | status.errors = append(status.errors, err.Error()) 49 | } 50 | 51 | status.remote, err = r.Remote() 52 | if err != nil { 53 | status.errors = append(status.errors, err.Error()) 54 | } 55 | 56 | return status 57 | } 58 | 59 | func (r *Repo) loadBranches() (map[string]string, []error) { 60 | statuses := make(map[string]string) 61 | errors := make([]error, 0) 62 | 63 | branches, err := r.Branches() 64 | if err != nil { 65 | errors = append(errors, err) 66 | return statuses, errors 67 | } 68 | 69 | for _, branch := range branches { 70 | status, err := r.loadBranchStatus(branch) 71 | statuses[branch] = status 72 | if err != nil { 73 | errors = append(errors, err) 74 | } 75 | } 76 | 77 | return statuses, errors 78 | } 79 | 80 | func (r *Repo) loadBranchStatus(branch string) (string, error) { 81 | upstream, err := r.Upstream(branch) 82 | if err != nil { 83 | return "", err 84 | } 85 | 86 | if upstream == "" { 87 | return "no upstream", nil 88 | } 89 | 90 | ahead, behind, err := r.AheadBehind(branch, upstream) 91 | if err != nil { 92 | return "", err 93 | } 94 | 95 | if ahead == 0 && behind == 0 { 96 | return "", nil 97 | } 98 | 99 | var res []string 100 | if ahead != 0 { 101 | res = append(res, fmt.Sprintf("%d ahead", ahead)) 102 | } 103 | if behind != 0 { 104 | res = append(res, fmt.Sprintf("%d behind", behind)) 105 | } 106 | 107 | return strings.Join(res, " "), nil 108 | } 109 | 110 | func (r *Repo) loadWorkTree() (string, error) { 111 | uncommitted, err := r.Uncommitted() 112 | if err != nil { 113 | return "", err 114 | } 115 | 116 | untracked, err := r.Untracked() 117 | if err != nil { 118 | return "", err 119 | } 120 | 121 | if uncommitted == 0 && untracked == 0 { 122 | return "", nil 123 | } 124 | 125 | var res []string 126 | if uncommitted != 0 { 127 | res = append(res, fmt.Sprintf("%d uncommitted", uncommitted)) 128 | } 129 | if untracked != 0 { 130 | res = append(res, fmt.Sprintf("%d untracked", untracked)) 131 | } 132 | 133 | return strings.Join(res, " "), nil 134 | } 135 | 136 | // Path returns path to a repository. 137 | func (s *Status) Path() string { 138 | return s.path 139 | } 140 | 141 | // Current returns the name of currently checked out branch (or tag or detached HEAD). 142 | func (s *Status) Current() string { 143 | return s.current 144 | } 145 | 146 | // Branches returns a list of all branches names except the currently checked out one. Use Current() to get its name. 147 | func (s *Status) Branches() []string { 148 | var branches []string 149 | for b := range s.branches { 150 | if b != s.current { 151 | branches = append(branches, b) 152 | } 153 | } 154 | return branches 155 | } 156 | 157 | // BranchStatus returns status of a given branch 158 | func (s *Status) BranchStatus(branch string) string { 159 | return s.branches[branch] 160 | } 161 | 162 | // WorkTreeStatus returns status of a worktree 163 | func (s *Status) WorkTreeStatus() string { 164 | return s.worktree 165 | } 166 | 167 | // Remote returns URL to remote repository 168 | func (s *Status) Remote() string { 169 | return s.remote 170 | } 171 | 172 | // Errors is a slice of errors that occurred when loading repo status 173 | func (s *Status) Errors() []string { 174 | return s.errors 175 | } 176 | -------------------------------------------------------------------------------- /pkg/git/test/helpers.go: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import ( 4 | "fmt" 5 | "git-get/pkg/run" 6 | "io/ioutil" 7 | "os" 8 | "path/filepath" 9 | "testing" 10 | ) 11 | 12 | // TempDir creates a temporary directory inside the parent dir. 13 | // If parent is empty, it will use a system default temp dir (usually /tmp). 14 | func TempDir(t *testing.T, parent string) string { 15 | dir, err := ioutil.TempDir(parent, "git-get-repo-") 16 | checkFatal(t, err) 17 | 18 | // Automatically remove temp dir when the test is over. 19 | t.Cleanup(func() { 20 | err := os.RemoveAll(dir) 21 | if err != nil { 22 | t.Errorf("failed removing test repo %s", dir) 23 | } 24 | }) 25 | 26 | return dir 27 | } 28 | 29 | func (r *Repo) init() { 30 | err := run.Git("init", "--quiet", r.path).AndShutUp() 31 | checkFatal(r.t, err) 32 | } 33 | 34 | // writeFile writes the content string into a file. If file doesn't exists, it will create it. 35 | func (r *Repo) writeFile(filename string, content string) { 36 | path := filepath.Join(r.path, filename) 37 | 38 | file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644) 39 | checkFatal(r.t, err) 40 | 41 | _, err = file.Write([]byte(content)) 42 | checkFatal(r.t, err) 43 | } 44 | 45 | func (r *Repo) stageFile(path string) { 46 | err := run.Git("add", path).OnRepo(r.path).AndShutUp() 47 | checkFatal(r.t, err) 48 | } 49 | 50 | func (r *Repo) commit(msg string) { 51 | err := run.Git("commit", "-m", fmt.Sprintf("%q", msg), "--author=\"user \"").OnRepo(r.path).AndShutUp() 52 | checkFatal(r.t, err) 53 | } 54 | 55 | func (r *Repo) branch(name string) { 56 | err := run.Git("branch", name).OnRepo(r.path).AndShutUp() 57 | checkFatal(r.t, err) 58 | } 59 | 60 | func (r *Repo) tag(name string) { 61 | err := run.Git("tag", "-a", name, "-m", name).OnRepo(r.path).AndShutUp() 62 | checkFatal(r.t, err) 63 | } 64 | 65 | func (r *Repo) checkout(name string) { 66 | err := run.Git("checkout", name).OnRepo(r.path).AndShutUp() 67 | checkFatal(r.t, err) 68 | } 69 | 70 | func (r *Repo) clone() *Repo { 71 | dir := TempDir(r.t, "") 72 | 73 | url := fmt.Sprintf("file://%s/.git", r.path) 74 | err := run.Git("clone", url, dir).AndShutUp() 75 | checkFatal(r.t, err) 76 | 77 | clone := &Repo{ 78 | path: dir, 79 | t: r.t, 80 | } 81 | 82 | return clone 83 | } 84 | 85 | func (r *Repo) fetch() { 86 | err := run.Git("fetch", "--all").OnRepo(r.path).AndShutUp() 87 | checkFatal(r.t, err) 88 | } 89 | 90 | func checkFatal(t *testing.T, err error) { 91 | if err != nil { 92 | t.Fatalf("failed making test repo: %+v", err) 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /pkg/git/test/testrepos.go: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import ( 4 | "path/filepath" 5 | "testing" 6 | ) 7 | 8 | // Repo represents a test repository. 9 | // It embeds testing.T so that any error thrown while creating a test repo will cause a t.Fatal call. 10 | type Repo struct { 11 | path string 12 | t *testing.T 13 | } 14 | 15 | // Path returns path to a repository. 16 | func (r *Repo) Path() string { 17 | return r.path 18 | } 19 | 20 | // RepoEmpty creates an empty git repo. 21 | func RepoEmpty(t *testing.T) *Repo { 22 | return RepoEmptyInDir(t, "") 23 | } 24 | 25 | // RepoEmptyInDir creates an empty git repo inside a given parent dir. 26 | func RepoEmptyInDir(t *testing.T, parent string) *Repo { 27 | r := &Repo{ 28 | path: TempDir(t, parent), 29 | t: t, 30 | } 31 | 32 | r.init() 33 | return r 34 | } 35 | 36 | // RepoWithUntracked creates a git repo with a single untracked file. 37 | func RepoWithUntracked(t *testing.T) *Repo { 38 | r := RepoEmpty(t) 39 | r.writeFile("README.md", "I'm a readme file") 40 | 41 | return r 42 | } 43 | 44 | // RepoWithStaged creates a git repo with a single staged file. 45 | func RepoWithStaged(t *testing.T) *Repo { 46 | r := RepoEmpty(t) 47 | r.writeFile("README.md", "I'm a readme file") 48 | r.stageFile("README.md") 49 | 50 | return r 51 | } 52 | 53 | // RepoWithCommit creates a git repo with a single commit. 54 | func RepoWithCommit(t *testing.T) *Repo { 55 | r := RepoEmpty(t) 56 | r.writeFile("README.md", "I'm a readme file") 57 | r.stageFile("README.md") 58 | r.commit("Initial commit") 59 | 60 | return r 61 | } 62 | 63 | // RepoWithUncommittedAndUntracked creates a git repo with one staged but uncommitted file and one untracked file. 64 | func RepoWithUncommittedAndUntracked(t *testing.T) *Repo { 65 | r := RepoEmpty(t) 66 | r.writeFile("README.md", "I'm a readme file") 67 | r.stageFile("README.md") 68 | r.commit("Initial commit") 69 | r.writeFile("README.md", "These changes won't be committed") 70 | r.writeFile("untracked.txt", "I'm untracked") 71 | 72 | return r 73 | } 74 | 75 | // RepoWithBranch creates a git repo with a new branch. 76 | func RepoWithBranch(t *testing.T) *Repo { 77 | r := RepoWithCommit(t) 78 | r.branch("feature/branch") 79 | r.checkout("feature/branch") 80 | 81 | return r 82 | } 83 | 84 | // RepoWithTag creates a git repo with a new tag. 85 | func RepoWithTag(t *testing.T) *Repo { 86 | r := RepoWithCommit(t) 87 | r.tag("v0.0.1") 88 | r.checkout("v0.0.1") 89 | 90 | return r 91 | } 92 | 93 | // RepoWithBranchWithUpstream creates a git repo by cloning another repo and checking out a remote branch. 94 | func RepoWithBranchWithUpstream(t *testing.T) *Repo { 95 | origin := RepoWithCommit(t) 96 | origin.branch("feature/branch") 97 | 98 | r := origin.clone() 99 | r.checkout("feature/branch") 100 | return r 101 | } 102 | 103 | // RepoWithBranchWithoutUpstream creates a git repo by cloning another repo and checking out a new local branch. 104 | func RepoWithBranchWithoutUpstream(t *testing.T) *Repo { 105 | origin := RepoWithCommit(t) 106 | 107 | r := origin.clone() 108 | r.branch("feature/branch") 109 | r.checkout("feature/branch") 110 | return r 111 | } 112 | 113 | // RepoWithBranchAhead creates a git repo with a branch being ahead of a remote branch by 1 commit. 114 | func RepoWithBranchAhead(t *testing.T) *Repo { 115 | origin := RepoWithCommit(t) 116 | origin.branch("feature/branch") 117 | 118 | r := origin.clone() 119 | r.checkout("feature/branch") 120 | 121 | r.writeFile("local.new", "local.new") 122 | r.stageFile("local.new") 123 | r.commit("local.new") 124 | 125 | return r 126 | } 127 | 128 | // RepoWithBranchBehind creates a git repo with a branch being behind a remote branch by 1 commit. 129 | func RepoWithBranchBehind(t *testing.T) *Repo { 130 | origin := RepoWithCommit(t) 131 | origin.branch("feature/branch") 132 | origin.checkout("feature/branch") 133 | 134 | r := origin.clone() 135 | r.checkout("feature/branch") 136 | 137 | origin.writeFile("origin.new", "origin.new") 138 | origin.stageFile("origin.new") 139 | origin.commit("origin.new") 140 | 141 | r.fetch() 142 | 143 | return r 144 | } 145 | 146 | // RepoWithBranchAheadAndBehind creates a git repo with a branch being 2 commits ahead and 1 behind a remote branch. 147 | func RepoWithBranchAheadAndBehind(t *testing.T) *Repo { 148 | origin := RepoWithCommit(t) 149 | origin.branch("feature/branch") 150 | origin.checkout("feature/branch") 151 | 152 | r := origin.clone() 153 | r.checkout("feature/branch") 154 | 155 | origin.writeFile("origin.new", "origin.new") 156 | origin.stageFile("origin.new") 157 | origin.commit("origin.new") 158 | 159 | r.writeFile("local.new", "local.new") 160 | r.stageFile("local.new") 161 | r.commit("local.new") 162 | 163 | r.writeFile("local.new2", "local.new2") 164 | r.stageFile("local.new2") 165 | r.commit("local.new2") 166 | 167 | r.fetch() 168 | 169 | return r 170 | } 171 | 172 | // RepoWithEmptyConfig creates a git repo with empty .git/config file 173 | func RepoWithEmptyConfig(t *testing.T) *Repo { 174 | r := RepoEmpty(t) 175 | r.writeFile(filepath.Join(".git", "config"), "") 176 | 177 | return r 178 | } 179 | 180 | // RepoWithValidConfig creates a git repo with valid content in .git/config file 181 | func RepoWithValidConfig(t *testing.T) *Repo { 182 | r := RepoEmpty(t) 183 | 184 | gitconfig := ` 185 | [user] 186 | name = grdl 187 | [gitget] 188 | host = github.com 189 | ` 190 | r.writeFile(filepath.Join(".git", "config"), gitconfig) 191 | 192 | return r 193 | } 194 | -------------------------------------------------------------------------------- /pkg/list.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import ( 4 | "fmt" 5 | "git-get/pkg/cfg" 6 | "git-get/pkg/git" 7 | "git-get/pkg/print" 8 | "strings" 9 | ) 10 | 11 | // ListCfg provides configuration for the List command. 12 | type ListCfg struct { 13 | Fetch bool 14 | Output string 15 | Root string 16 | } 17 | 18 | // List executes the "git list" command. 19 | func List(c *ListCfg) error { 20 | finder := git.NewRepoFinder(c.Root) 21 | if err := finder.Find(); err != nil { 22 | return err 23 | } 24 | 25 | statuses := finder.LoadAll(c.Fetch) 26 | printables := make([]print.Printable, len(statuses)) 27 | for i := range statuses { 28 | printables[i] = statuses[i] 29 | } 30 | 31 | switch c.Output { 32 | case cfg.OutFlat: 33 | fmt.Print(print.NewFlatPrinter().Print(printables)) 34 | case cfg.OutTree: 35 | fmt.Print(print.NewTreePrinter().Print(c.Root, printables)) 36 | case cfg.OutDump: 37 | fmt.Print(print.NewDumpPrinter().Print(printables)) 38 | default: 39 | return fmt.Errorf("invalid --out flag; allowed values: [%s]", strings.Join(cfg.AllowedOut, ", ")) 40 | } 41 | 42 | return nil 43 | } 44 | -------------------------------------------------------------------------------- /pkg/print/dump.go: -------------------------------------------------------------------------------- 1 | package print 2 | 3 | import ( 4 | "strings" 5 | ) 6 | 7 | // DumpPrinter prints a list of repos in a dump file format. 8 | type DumpPrinter struct{} 9 | 10 | // NewDumpPrinter creates a DumpPrinter. 11 | func NewDumpPrinter() *DumpPrinter { 12 | return &DumpPrinter{} 13 | } 14 | 15 | // Print generates a list of repos URLs. Each line contains a URL and, if applicable, a currently checked out branch name. 16 | // It's a way to dump all repositories managed by git-get and is supposed to be consumed by `git get --dump`. 17 | func (p *DumpPrinter) Print(repos []Printable) string { 18 | var str strings.Builder 19 | 20 | for _, r := range repos { 21 | str.WriteString(r.Remote()) 22 | 23 | // TODO: if head is detached maybe we should get the revision it points to in case it's a tag 24 | if current := r.Current(); current != "" && current != head { 25 | str.WriteString(" " + current) 26 | } 27 | 28 | str.WriteString("\n") 29 | } 30 | 31 | return str.String() 32 | } 33 | -------------------------------------------------------------------------------- /pkg/print/flat.go: -------------------------------------------------------------------------------- 1 | package print 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strings" 7 | ) 8 | 9 | // FlatPrinter prints a list of repos in a flat format. 10 | type FlatPrinter struct{} 11 | 12 | // NewFlatPrinter creates a FlatPrinter. 13 | func NewFlatPrinter() *FlatPrinter { 14 | return &FlatPrinter{} 15 | } 16 | 17 | // Print generates a flat list of repositories and their statuses - each repo in new line with full path. 18 | func (p *FlatPrinter) Print(repos []Printable) string { 19 | var str strings.Builder 20 | 21 | for _, r := range repos { 22 | str.WriteString(strings.TrimSuffix(r.Path(), string(os.PathSeparator))) 23 | 24 | if len(r.Errors()) > 0 { 25 | str.WriteString(" " + red("error") + "\n") 26 | continue 27 | } 28 | 29 | str.WriteString(" " + blue(r.Current())) 30 | 31 | current := r.BranchStatus(r.Current()) 32 | worktree := r.WorkTreeStatus() 33 | 34 | if worktree != "" { 35 | worktree = fmt.Sprintf("[ %s ]", worktree) 36 | } 37 | 38 | if worktree == "" && current == "" { 39 | str.WriteString(" " + green("ok")) 40 | } else { 41 | str.WriteString(" " + strings.Join([]string{yellow(current), red(worktree)}, " ")) 42 | } 43 | 44 | for _, branch := range r.Branches() { 45 | status := r.BranchStatus(branch) 46 | if status == "" { 47 | status = green("ok") 48 | } 49 | 50 | indent := strings.Repeat(" ", len(r.Path())-1) 51 | str.WriteString(fmt.Sprintf("\n%s %s %s", indent, blue(branch), yellow(status))) 52 | } 53 | 54 | str.WriteString("\n") 55 | } 56 | 57 | return str.String() + Errors(repos) 58 | } 59 | -------------------------------------------------------------------------------- /pkg/print/print.go: -------------------------------------------------------------------------------- 1 | package print 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | const ( 9 | head = "HEAD" 10 | ) 11 | 12 | // Printable represents a repository which status can be printed 13 | type Printable interface { 14 | Path() string 15 | Current() string 16 | Branches() []string 17 | BranchStatus(string) string 18 | WorkTreeStatus() string 19 | Remote() string 20 | Errors() []string 21 | } 22 | 23 | // Errors returns a printable list of errors from the slice of Printables or an empty string if there are no errors. 24 | // It's meant to be appended at the end of Print() result. 25 | func Errors(repos []Printable) string { 26 | errors := []string{} 27 | 28 | for _, repo := range repos { 29 | for _, err := range repo.Errors() { 30 | errors = append(errors, err) 31 | } 32 | } 33 | 34 | if len(errors) == 0 { 35 | return "" 36 | } 37 | 38 | var str strings.Builder 39 | str.WriteString(red("\nOops, errors happened when loading repository status:\n")) 40 | str.WriteString(strings.Join(errors, "\n")) 41 | 42 | return str.String() 43 | } 44 | 45 | // TODO: not sure if this works on windows. See https://github.com/mattn/go-colorable 46 | func red(str string) string { 47 | return fmt.Sprintf("\033[1;31m%s\033[0m", str) 48 | } 49 | 50 | func green(str string) string { 51 | return fmt.Sprintf("\033[1;32m%s\033[0m", str) 52 | } 53 | 54 | func blue(str string) string { 55 | return fmt.Sprintf("\033[1;34m%s\033[0m", str) 56 | } 57 | 58 | func yellow(str string) string { 59 | return fmt.Sprintf("\033[1;33m%s\033[0m", str) 60 | } 61 | -------------------------------------------------------------------------------- /pkg/print/tree.go: -------------------------------------------------------------------------------- 1 | package print 2 | 3 | import ( 4 | "fmt" 5 | "path/filepath" 6 | "strings" 7 | 8 | "github.com/xlab/treeprint" 9 | ) 10 | 11 | // TreePrinter prints list of repos in a directory tree format. 12 | type TreePrinter struct { 13 | } 14 | 15 | // NewTreePrinter creates a TreePrinter. 16 | func NewTreePrinter() *TreePrinter { 17 | return &TreePrinter{} 18 | } 19 | 20 | // Print generates a tree view of repos and their statuses. 21 | func (p *TreePrinter) Print(root string, repos []Printable) string { 22 | if len(repos) == 0 { 23 | return fmt.Sprintf("There are no git repos under %s", root) 24 | } 25 | 26 | tree := buildTree(root, repos) 27 | tp := treeprint.New() 28 | tp.SetValue(root) 29 | 30 | p.printTree(tree, tp) 31 | 32 | return tp.String() + Errors(repos) 33 | } 34 | 35 | // Node represents a path fragment in repos tree. 36 | type Node struct { 37 | val string 38 | parent *Node 39 | children []*Node 40 | repo Printable 41 | depth int 42 | } 43 | 44 | // Root creates a new root of a tree. 45 | func Root(val string) *Node { 46 | root := &Node{ 47 | val: val, 48 | } 49 | return root 50 | } 51 | 52 | // Add adds a child node with given value to a current node. 53 | func (n *Node) Add(val string) *Node { 54 | if n.children == nil { 55 | n.children = make([]*Node, 0) 56 | } 57 | 58 | child := &Node{ 59 | val: val, 60 | parent: n, 61 | depth: n.depth + 1, 62 | } 63 | n.children = append(n.children, child) 64 | return child 65 | } 66 | 67 | // GetChild finds a node with val inside this node's children (only 1 level deep). 68 | // Returns pointer to found child or nil if node doesn't have any children or doesn't have a child with sought value. 69 | func (n *Node) GetChild(val string) *Node { 70 | if n.children == nil { 71 | return nil 72 | } 73 | 74 | for _, child := range n.children { 75 | if child.val == val { 76 | return child 77 | } 78 | } 79 | 80 | return nil 81 | } 82 | 83 | // buildTree builds a directory tree of paths to repositories. 84 | // Each node represents a directory in the repo path. 85 | // Each leaf (final node) contains a pointer to the repo. 86 | func buildTree(root string, repos []Printable) *Node { 87 | tree := Root(root) 88 | 89 | for _, r := range repos { 90 | path := strings.TrimPrefix(r.Path(), root) 91 | path = strings.Trim(path, string(filepath.Separator)) 92 | subs := strings.Split(path, string(filepath.Separator)) 93 | 94 | // For each path fragment, start at the root of the tree 95 | // and check if the fragment exist among the children of the node. 96 | // If not, add it to node's children and move to next fragment. 97 | // If it does, just move to the next fragment. 98 | node := tree 99 | for i, sub := range subs { 100 | child := node.GetChild(sub) 101 | if child == nil { 102 | node = node.Add(sub) 103 | 104 | // If that's the last fragment, it's a tree leaf and needs a *Repo attached. 105 | if i == len(subs)-1 { 106 | node.repo = r 107 | } 108 | 109 | continue 110 | } 111 | node = child 112 | } 113 | } 114 | return tree 115 | } 116 | 117 | // printTree renders the repo tree by recursively traversing the tree nodes. 118 | // If a node doesn't have any children, it's a leaf node containing the repo status. 119 | func (p *TreePrinter) printTree(node *Node, tp treeprint.Tree) { 120 | if node.children == nil { 121 | tp.SetValue(printLeaf(node)) 122 | } 123 | 124 | for _, child := range node.children { 125 | branch := tp.AddBranch(child.val) 126 | p.printTree(child, branch) 127 | } 128 | } 129 | 130 | func printLeaf(node *Node) string { 131 | r := node.repo 132 | 133 | // If any errors happened during status loading, don't print the status but "error" instead. 134 | // Actual error messages are printed in bulk below the tree. 135 | if len(r.Errors()) > 0 { 136 | return fmt.Sprintf("%s %s", node.val, red("error")) 137 | } 138 | 139 | current := r.BranchStatus(r.Current()) 140 | worktree := r.WorkTreeStatus() 141 | 142 | if worktree != "" { 143 | worktree = fmt.Sprintf("[ %s ]", worktree) 144 | } 145 | 146 | var str strings.Builder 147 | 148 | if worktree == "" && current == "" { 149 | str.WriteString(fmt.Sprintf("%s %s %s", node.val, blue(r.Current()), green("ok"))) 150 | } else { 151 | str.WriteString(fmt.Sprintf("%s %s %s", node.val, blue(r.Current()), strings.Join([]string{yellow(current), red(worktree)}, " "))) 152 | } 153 | 154 | for _, branch := range r.Branches() { 155 | status := r.BranchStatus(branch) 156 | if status == "" { 157 | status = green("ok") 158 | } 159 | 160 | str.WriteString(fmt.Sprintf("\n%s%s %s", indentation(node), blue(branch), yellow(status))) 161 | } 162 | 163 | return str.String() 164 | } 165 | 166 | // indentation generates a correct indentation for the branches row to match the links to lower rows. 167 | // It traverses the tree "upwards" and checks if a parent node is the youngest one (ie, there are no more sibling at the same level). 168 | // If it is, it means that level should be indented with empty spaces because there is nothing to link to anymore. 169 | // If it isn't the youngest, that level needs to be indented using a "|" link. 170 | func indentation(node *Node) string { 171 | // Slice of levels. Slice index is node depth, true value means the node is the youngest. 172 | levels := make([]bool, node.depth) 173 | 174 | // Traverse until node has no parents (ie, we reached the root). 175 | n := node 176 | for n.parent != nil { 177 | levels[n.depth-1] = n.isYoungest() 178 | n = n.parent 179 | } 180 | 181 | var indent strings.Builder 182 | 183 | const space = " " 184 | const link = "│ " 185 | for _, y := range levels { 186 | if y { 187 | indent.WriteString(space) 188 | } else { 189 | indent.WriteString(link) 190 | } 191 | } 192 | 193 | // Finally, indent by the size of node name (to match the rest of the branches) 194 | indent.WriteString(strings.Repeat(" ", len(node.val)+1)) 195 | 196 | return indent.String() 197 | } 198 | 199 | // isYoungest checks if the node is the last one in the slice of children 200 | func (n *Node) isYoungest() bool { 201 | if n.parent == nil { 202 | return true 203 | } 204 | 205 | sisters := n.parent.children 206 | var myIndex int 207 | for i, sis := range sisters { 208 | if sis.val == n.val { 209 | myIndex = i 210 | break 211 | } 212 | } 213 | return myIndex == len(sisters)-1 214 | } 215 | -------------------------------------------------------------------------------- /pkg/run/run.go: -------------------------------------------------------------------------------- 1 | // Package run provides methods for running git command and capturing their output and errors 2 | package run 3 | 4 | import ( 5 | "bytes" 6 | "fmt" 7 | "os" 8 | "os/exec" 9 | pathpkg "path" 10 | "strings" 11 | ) 12 | 13 | // Cmd represents a git command. 14 | // The command is executed by chaining functions: Git() + optional OnRepo() + output specifier. 15 | // This way the function chain reads more naturally. 16 | // 17 | // Examples of different compositions: 18 | // 19 | // - run.Git("clone", ).AndShow() 20 | // means running "git clone " and printing the progress into stdout 21 | // 22 | // - run.Git("branch","-a").OnRepo().AndCaptureLines() 23 | // means running "git branch -a" inside and returning a slice of branch names 24 | // 25 | // - run.Git("pull").OnRepo().AndShutUp() 26 | // means running "git pull" inside and not printing any output 27 | type Cmd struct { 28 | cmd *exec.Cmd 29 | args string 30 | path string 31 | } 32 | 33 | // Git creates a git command with given arguments. 34 | func Git(args ...string) *Cmd { 35 | return &Cmd{ 36 | cmd: exec.Command("git", args...), 37 | args: strings.Join(args, " "), 38 | } 39 | } 40 | 41 | // OnRepo makes the command run inside a given repository path. Otherwise the command is run outside of any repository. 42 | // Commands like "git clone" or "git config --global" don't have to (or shouldn't in some cases) be run inside a repo. 43 | func (c *Cmd) OnRepo(path string) *Cmd { 44 | if strings.TrimSpace(path) == "" { 45 | return c 46 | } 47 | 48 | insert := []string{"--work-tree", path, "--git-dir", pathpkg.Join(path, ".git")} 49 | // Insert into the args slice after the 1st element (https://github.com/golang/go/wiki/SliceTricks#insert) 50 | c.cmd.Args = append(c.cmd.Args[:1], append(insert, c.cmd.Args[1:]...)...) 51 | 52 | c.path = path 53 | 54 | return c 55 | } 56 | 57 | // AndCaptureLines executes the command and returns its output as a slice of lines. 58 | func (c *Cmd) AndCaptureLines() ([]string, error) { 59 | errStream := &bytes.Buffer{} 60 | c.cmd.Stderr = errStream 61 | 62 | out, err := c.cmd.Output() 63 | if err != nil { 64 | return nil, &GitError{errStream, c.args, c.path, err} 65 | } 66 | 67 | lines := lines(out) 68 | if len(lines) == 0 { 69 | return []string{""}, nil 70 | } 71 | 72 | return lines, nil 73 | } 74 | 75 | // AndCaptureLine executes the command and returns the first line of its output. 76 | func (c *Cmd) AndCaptureLine() (string, error) { 77 | lines, err := c.AndCaptureLines() 78 | if err != nil { 79 | return "", err 80 | } 81 | return lines[0], nil 82 | } 83 | 84 | // AndShow executes the command and prints its stderr and stdout. 85 | func (c *Cmd) AndShow() error { 86 | c.cmd.Stdout = os.Stdout 87 | c.cmd.Stderr = os.Stderr 88 | 89 | err := c.cmd.Run() 90 | if err != nil { 91 | return &GitError{&bytes.Buffer{}, c.args, c.path, err} 92 | } 93 | return nil 94 | } 95 | 96 | // AndShutUp executes the command and doesn't return or show any output. 97 | func (c *Cmd) AndShutUp() error { 98 | c.cmd.Stdout = nil 99 | 100 | errStream := &bytes.Buffer{} 101 | c.cmd.Stderr = errStream 102 | 103 | err := c.cmd.Run() 104 | if err != nil { 105 | return &GitError{errStream, c.args, c.path, err} 106 | } 107 | return nil 108 | } 109 | 110 | // GitError provides more visibility into why an git command had failed. 111 | type GitError struct { 112 | Stderr *bytes.Buffer 113 | Args string 114 | Path string 115 | Err error 116 | } 117 | 118 | func (e GitError) Error() string { 119 | msg := e.Stderr.String() 120 | 121 | if e.Path == "" { 122 | return fmt.Sprintf("git %s failed: %s", e.Args, msg) 123 | } 124 | 125 | return fmt.Sprintf("git %s failed on %s: %s", e.Args, e.Path, msg) 126 | 127 | } 128 | 129 | func lines(output []byte) []string { 130 | lines := strings.TrimSuffix(string(output), "\n") 131 | return strings.Split(lines, "\n") 132 | } 133 | -------------------------------------------------------------------------------- /pkg/url.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import ( 4 | urlpkg "net/url" 5 | "path" 6 | "path/filepath" 7 | "regexp" 8 | "strings" 9 | 10 | "github.com/pkg/errors" 11 | ) 12 | 13 | var errEmptyURLPath = errors.New("parsed URL path is empty") 14 | 15 | // scpSyntax matches the SCP-like addresses used by the ssh protocol (eg, [user@]host.xz:path/to/repo.git/). 16 | // See: https://golang.org/src/cmd/go/internal/get/vcs.go 17 | var scpSyntax = regexp.MustCompile(`^([a-zA-Z0-9_]+)@([a-zA-Z0-9._-]+):(.*)$`) 18 | 19 | // ParseURL parses given rawURL string into a URL. 20 | // When the parsed URL has an empty host, use the defaultHost. 21 | // When the parsed URL has an empty scheme, use the defaultScheme. 22 | func ParseURL(rawURL string, defaultHost string, defaultScheme string) (url *urlpkg.URL, err error) { 23 | // If rawURL matches the SCP-like syntax, convert it into a standard ssh Path. 24 | // eg, git@github.com:user/repo => ssh://git@github.com/user/repo 25 | if m := scpSyntax.FindStringSubmatch(rawURL); m != nil { 26 | url = &urlpkg.URL{ 27 | Scheme: "ssh", 28 | User: urlpkg.User(m[1]), 29 | Host: m[2], 30 | Path: path.Join("/", m[3]), 31 | } 32 | } else { 33 | url, err = urlpkg.Parse(rawURL) 34 | if err != nil { 35 | return nil, errors.Wrapf(err, "failed parsing URL %s", rawURL) 36 | } 37 | } 38 | 39 | if url.Host == "" && url.Path == "" { 40 | return nil, errEmptyURLPath 41 | } 42 | 43 | if url.Scheme == "git+ssh" { 44 | url.Scheme = "ssh" 45 | } 46 | 47 | // Default to configured defaultHost when host is empty 48 | if url.Host == "" { 49 | url.Host = defaultHost 50 | // Add a leading slash to path when host is missing. It's needed to correctly compare urlpkg.URL structs. 51 | url.Path = path.Join("/", url.Path) 52 | } 53 | 54 | // Default to configured defaultScheme when scheme is empty 55 | if url.Scheme == "" { 56 | url.Scheme = defaultScheme 57 | } 58 | 59 | // Default to "git" user when using ssh and no user is provided 60 | if url.Scheme == "ssh" && url.User == nil { 61 | url.User = urlpkg.User("git") 62 | } 63 | 64 | // Don't use host when scheme is file://. The fragment detected as url.Host should be a first directory of url.Path 65 | if url.Scheme == "file" && url.Host != "" { 66 | url.Path = path.Join(url.Host, url.Path) 67 | url.Host = "" 68 | } 69 | 70 | return url, nil 71 | } 72 | 73 | // URLToPath cleans up the URL and converts it into a path string with correct separators for the current OS. 74 | // Eg, ssh://git@github.com:22/~user/repo.git => github.com/user/repo 75 | // 76 | // If skipHost is true, it removes the host part from the path. 77 | // Eg, ssh://git@github.com:22/~user/repo.git => user/repo 78 | func URLToPath(url urlpkg.URL, skipHost bool) string { 79 | // Remove port numbers from host. 80 | url.Host = strings.Split(url.Host, ":")[0] 81 | 82 | // Remove tilde (~) char from username. 83 | url.Path = strings.ReplaceAll(url.Path, "~", "") 84 | 85 | // Remove leading and trailing slashes (correct separator is added by the filepath.Join() below). 86 | url.Path = strings.Trim(url.Path, "/") 87 | 88 | // Remove trailing ".git" from repo name. 89 | url.Path = strings.TrimSuffix(url.Path, ".git") 90 | 91 | // Replace slashes with separator correct for the current OS. 92 | url.Path = strings.ReplaceAll(url.Path, "/", string(filepath.Separator)) 93 | 94 | if skipHost { 95 | url.Host = "" 96 | } 97 | 98 | return filepath.Join(url.Host, url.Path) 99 | } 100 | -------------------------------------------------------------------------------- /pkg/url_test.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import ( 4 | "git-get/pkg/cfg" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | // Following URLs are considered valid according to https://git-scm.com/docs/git-clone#_git_urls: 11 | // ssh://[user@]host.xz[:port]/path/to/repo.git 12 | // ssh://[user@]host.xz[:port]/~[user]/path/to/repo.git/ 13 | // [user@]host.xz:path/to/repo.git/ 14 | // [user@]host.xz:/~[user]/path/to/repo.git/ 15 | // git://host.xz[:port]/path/to/repo.git/ 16 | // git://host.xz[:port]/~[user]/path/to/repo.git/ 17 | // http[s]://host.xz[:port]/path/to/repo.git/ 18 | // ftp[s]://host.xz[:port]/path/to/repo.git/ 19 | // /path/to/repo.git/ 20 | // file:///path/to/repo.git/ 21 | 22 | func TestURLParse(t *testing.T) { 23 | tests := []struct { 24 | in string 25 | want string 26 | }{ 27 | {"ssh://github.com/grdl/git-get.git", "github.com/grdl/git-get"}, 28 | {"ssh://user@github.com/grdl/git-get.git", "github.com/grdl/git-get"}, 29 | {"ssh://user@github.com:1234/grdl/git-get.git", "github.com/grdl/git-get"}, 30 | {"ssh://user@github.com/~user/grdl/git-get.git", "github.com/user/grdl/git-get"}, 31 | {"git+ssh://github.com/grdl/git-get.git", "github.com/grdl/git-get"}, 32 | {"git@github.com:grdl/git-get.git", "github.com/grdl/git-get"}, 33 | {"git@github.com:/~user/grdl/git-get.git", "github.com/user/grdl/git-get"}, 34 | {"git://github.com/grdl/git-get.git", "github.com/grdl/git-get"}, 35 | {"git://github.com/~user/grdl/git-get.git", "github.com/user/grdl/git-get"}, 36 | {"https://github.com/grdl/git-get.git", "github.com/grdl/git-get"}, 37 | {"http://github.com/grdl/git-get.git", "github.com/grdl/git-get"}, 38 | {"https://github.com/grdl/git-get", "github.com/grdl/git-get"}, 39 | {"https://github.com/git-get.git", "github.com/git-get"}, 40 | {"https://github.com/git-get", "github.com/git-get"}, 41 | {"https://github.com/grdl/sub/path/git-get.git", "github.com/grdl/sub/path/git-get"}, 42 | {"https://github.com:1234/grdl/git-get.git", "github.com/grdl/git-get"}, 43 | {"https://github.com/grdl/git-get.git/", "github.com/grdl/git-get"}, 44 | {"https://github.com/grdl/git-get/", "github.com/grdl/git-get"}, 45 | {"https://github.com/grdl/git-get/////", "github.com/grdl/git-get"}, 46 | {"https://github.com/grdl/git-get.git/////", "github.com/grdl/git-get"}, 47 | {"ftp://github.com/grdl/git-get.git", "github.com/grdl/git-get"}, 48 | {"ftps://github.com/grdl/git-get.git", "github.com/grdl/git-get"}, 49 | {"rsync://github.com/grdl/git-get.git", "github.com/grdl/git-get"}, 50 | {"local/grdl/git-get/", "github.com/local/grdl/git-get"}, 51 | {"file://local/grdl/git-get", "local/grdl/git-get"}, 52 | } 53 | 54 | for _, test := range tests { 55 | url, err := ParseURL(test.in, cfg.Defaults[cfg.KeyDefaultHost], cfg.Defaults[cfg.KeyDefaultScheme]) 56 | assert.NoError(t, err) 57 | 58 | got := URLToPath(*url, false) 59 | assert.Equal(t, test.want, got) 60 | } 61 | } 62 | func TestURLParseSkipHost(t *testing.T) { 63 | tests := []struct { 64 | in string 65 | want string 66 | }{ 67 | {"ssh://github.com/grdl/git-get.git", "grdl/git-get"}, 68 | {"ssh://user@github.com/grdl/git-get.git", "grdl/git-get"}, 69 | {"ssh://user@github.com:1234/grdl/git-get.git", "grdl/git-get"}, 70 | {"ssh://user@github.com/~user/grdl/git-get.git", "user/grdl/git-get"}, 71 | {"git+ssh://github.com/grdl/git-get.git", "grdl/git-get"}, 72 | {"git@github.com:grdl/git-get.git", "grdl/git-get"}, 73 | {"git@github.com:/~user/grdl/git-get.git", "user/grdl/git-get"}, 74 | {"git://github.com/grdl/git-get.git", "grdl/git-get"}, 75 | {"git://github.com/~user/grdl/git-get.git", "user/grdl/git-get"}, 76 | {"https://github.com/grdl/git-get.git", "grdl/git-get"}, 77 | {"http://github.com/grdl/git-get.git", "grdl/git-get"}, 78 | {"https://github.com/grdl/git-get", "grdl/git-get"}, 79 | {"https://github.com/git-get.git", "git-get"}, 80 | {"https://github.com/git-get", "git-get"}, 81 | {"https://github.com/grdl/sub/path/git-get.git", "grdl/sub/path/git-get"}, 82 | {"https://github.com:1234/grdl/git-get.git", "grdl/git-get"}, 83 | {"https://github.com/grdl/git-get.git/", "grdl/git-get"}, 84 | {"https://github.com/grdl/git-get/", "grdl/git-get"}, 85 | {"https://github.com/grdl/git-get/////", "grdl/git-get"}, 86 | {"https://github.com/grdl/git-get.git/////", "grdl/git-get"}, 87 | {"ftp://github.com/grdl/git-get.git", "grdl/git-get"}, 88 | {"ftps://github.com/grdl/git-get.git", "grdl/git-get"}, 89 | {"rsync://github.com/grdl/git-get.git", "grdl/git-get"}, 90 | {"local/grdl/git-get/", "local/grdl/git-get"}, 91 | {"file://local/grdl/git-get", "local/grdl/git-get"}, 92 | } 93 | 94 | for _, test := range tests { 95 | url, err := ParseURL(test.in, cfg.Defaults[cfg.KeyDefaultHost], cfg.Defaults[cfg.KeyDefaultScheme]) 96 | assert.NoError(t, err) 97 | 98 | got := URLToPath(*url, true) 99 | assert.Equal(t, test.want, got) 100 | } 101 | } 102 | 103 | func TestDefaultScheme(t *testing.T) { 104 | tests := []struct { 105 | in string 106 | scheme string 107 | want string 108 | }{ 109 | {"grdl/git-get", "ssh", "ssh://git@github.com/grdl/git-get"}, 110 | {"grdl/git-get", "https", "https://github.com/grdl/git-get"}, 111 | {"https://github.com/grdl/git-get", "ssh", "https://github.com/grdl/git-get"}, 112 | {"https://github.com/grdl/git-get", "https", "https://github.com/grdl/git-get"}, 113 | {"ssh://github.com/grdl/git-get", "ssh", "ssh://git@github.com/grdl/git-get"}, 114 | {"ssh://github.com/grdl/git-get", "https", "ssh://git@github.com/grdl/git-get"}, 115 | {"git+ssh://github.com/grdl/git-get", "https", "ssh://git@github.com/grdl/git-get"}, 116 | {"git@github.com:grdl/git-get", "ssh", "ssh://git@github.com/grdl/git-get"}, 117 | {"git@github.com:grdl/git-get", "https", "ssh://git@github.com/grdl/git-get"}, 118 | } 119 | 120 | for _, test := range tests { 121 | url, err := ParseURL(test.in, cfg.Defaults[cfg.KeyDefaultHost], test.scheme) 122 | assert.NoError(t, err) 123 | 124 | want, err := url.Parse(test.want) 125 | assert.NoError(t, err) 126 | 127 | assert.Equal(t, url, want) 128 | } 129 | } 130 | 131 | func TestInvalidURLParse(t *testing.T) { 132 | invalidURLs := []string{ 133 | "", 134 | //TODO: This Path is technically a correct scp-like syntax. Not sure how to handle it 135 | "github.com:grdl/git-git.get.git", 136 | 137 | //TODO: Is this a valid git Path? 138 | //"git@github.com:1234:grdl/git-get.git", 139 | } 140 | 141 | for _, test := range invalidURLs { 142 | _, err := ParseURL(test, cfg.Defaults[cfg.KeyDefaultHost], cfg.Defaults[cfg.KeyDefaultScheme]) 143 | 144 | assert.Error(t, err) 145 | } 146 | } 147 | --------------------------------------------------------------------------------