From 133dd4a4e57334c2af790846a3fda38b4a51494d Mon Sep 17 00:00:00 2001 From: Dylan Arbour Date: Thu, 7 May 2026 01:03:11 -0400 Subject: [PATCH] Add autocompletion and standard CLI flags Implements shell completion for Bash, Fish, Zsh, and PowerShell. Walks GETPATH to provide tab completion for available roots. Adds --help and --version flags. Updates Goreleaser config for completion, man pages, and version ldflags. --- .github/workflows/ci.yml | 9 ++- .goreleaser.yml | 10 ++++ README.md | 28 +++++++++ completions/git-get.bash | 10 ++++ completions/git-get.fish | 8 +++ completions/git-get.ps1 | 17 ++++++ completions/git-get.zsh | 13 +++++ completions_test.go | 97 +++++++++++++++++++++++++++++++ get/complete.go | 70 +++++++++++++++++++++++ get/complete_test.go | 118 ++++++++++++++++++++++++++++++++++++++ get/get.go | 71 ++++++++++++----------- get/get_test.go | 80 ++++++++++---------------- main.go | 83 +++++++++++++++++++++------ main_test.go | 119 +++++++++++++++++++++++++++++++++++++++ man/git-get.1 | 84 +++++++++++++++++++++++++++ 15 files changed, 714 insertions(+), 103 deletions(-) create mode 100644 completions/git-get.bash create mode 100644 completions/git-get.fish create mode 100644 completions/git-get.ps1 create mode 100644 completions/git-get.zsh create mode 100644 completions_test.go create mode 100644 get/complete.go create mode 100644 get/complete_test.go create mode 100644 main_test.go create mode 100644 man/git-get.1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f74b2a2..3ba0f0e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,14 +23,17 @@ jobs: - uses: actions/checkout@v6 - run: git config url.https://github.com/.insteadOf ssh://git@github.com/ + - if: runner.os != 'Windows' + uses: fish-actions/install-fish@v1 + - uses: actions/setup-go@v6 with: go-version-file: go.mod - - run: go mod download - - run: go test ./... - - run: go build . + - run: go vet ./... + - run: go test -v -race ./... + - run: go build -ldflags "-X main.Version=$(git rev-parse --short HEAD)" . - run: ./git-get github.com/arbourd/git-get bin: diff --git a/.goreleaser.yml b/.goreleaser.yml index 19e4f14..2e6d07b 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -18,6 +18,9 @@ checksum: archives: - name_template: "{{ .ProjectName }}-v{{ .Version }}-{{ .Os }}-{{ .Arch }}" + files: + - completions/* + - man/* format_overrides: - goos: windows formats: @@ -28,6 +31,13 @@ brews: description: Go gets your code homepage: https://github.com/arbourd/git-get + install: | + bin.install "git-get" + man1.install "man/git-get.1" + bash_completion.install "completions/git-get.bash" => "git-get" + zsh_completion.install "completions/git-get.zsh" => "_git_get" + fish_completion.install "completions/git-get.fish" + test: | repo = "github.com/arbourd/git-get" assert_match "#{testpath}/src/#{repo}", shell_output("#{bin}/git-get #{repo}") diff --git a/README.md b/README.md index 4165b75..3ccbf74 100644 --- a/README.md +++ b/README.md @@ -63,3 +63,31 @@ Install with `go install`. ```console $ go install github.com/arbourd/git-get@latest ``` + +### Autocompletion + +Homebrew installs shell completions automatically. For other installs, completion scripts are available in the `completions/` directory of each [release](https://github.com/arbourd/git-get/releases). + +**Fish** + +```console +$ cp completions/git-get.fish ~/.config/fish/completions/git-get.fish +``` + +**Bash** — add to `~/.bashrc` to persist. + +```console +$ source completions/git-get.bash +``` + +**Zsh** — copy to a directory in `$fpath`. + +```console +$ cp completions/git-get.zsh "${fpath[1]}/_git_get" +``` + +**PowerShell** — dot-source in `$PROFILE` to persist. + +```console +> . completions\git-get.ps1 +``` diff --git a/completions/git-get.bash b/completions/git-get.bash new file mode 100644 index 0000000..2db5de7 --- /dev/null +++ b/completions/git-get.bash @@ -0,0 +1,10 @@ +_git_get() { + local limit=1 + [[ "${COMP_WORDS[0]}" == "git" ]] && limit=2 + if [[ $COMP_CWORD -gt $limit ]]; then + compopt +o default +o bashdefault 2>/dev/null + return + fi + COMPREPLY=($(git-get --complete "${COMP_WORDS[COMP_CWORD]}" 2>/dev/null)) +} +complete -F _git_get git-get diff --git a/completions/git-get.fish b/completions/git-get.fish new file mode 100644 index 0000000..c6a79b9 --- /dev/null +++ b/completions/git-get.fish @@ -0,0 +1,8 @@ +function __fish_gitget_completion + git-get --complete (commandline -ct) 2>/dev/null +end + +complete -c git-get -f -n 'test (count (commandline -opc)) -le 1' -a "(__fish_gitget_completion)" +complete -c git-get -f -n 'test (count (commandline -opc)) -gt 1' +complete -c git -n '__fish_git_using_command get; and test (count (commandline -opc)) -le 2' -f -a "(__fish_gitget_completion)" +complete -c git -n '__fish_git_using_command get; and test (count (commandline -opc)) -gt 2' -f diff --git a/completions/git-get.ps1 b/completions/git-get.ps1 new file mode 100644 index 0000000..02f70ae --- /dev/null +++ b/completions/git-get.ps1 @@ -0,0 +1,17 @@ +$gitGetCompleter = { + param($wordToComplete, $commandAst, $cursorPosition) + if ($commandAst.CommandElements.Count -gt 2) { return } + git-get --complete "$wordToComplete" 2>$null | ForEach-Object { + [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_) + } +} + +Register-ArgumentCompleter -Native -CommandName git-get -ScriptBlock $gitGetCompleter + +Register-ArgumentCompleter -Native -CommandName git -ScriptBlock { + param($wordToComplete, $commandAst, $cursorPosition) + $c = $commandAst.CommandElements.Count + if ($c -ge 2 -and $c -le 3 -and $commandAst.CommandElements[1].Value -eq 'get') { + & $gitGetCompleter $wordToComplete $commandAst $cursorPosition + } +} diff --git a/completions/git-get.zsh b/completions/git-get.zsh new file mode 100644 index 0000000..13ecdfe --- /dev/null +++ b/completions/git-get.zsh @@ -0,0 +1,13 @@ +#compdef git-get + +_git_get() { + emulate -L zsh + local c="${cword:-$CURRENT}" + [[ $c -gt 2 ]] && { _ret=0; return } + local prefix="${cur-${words[$CURRENT]}}" + local completions + completions=($(git-get --complete "$prefix" 2>/dev/null)) + compadd -S "" -- ${(M)completions:#*/} + compadd -- ${completions:#*/} + _ret=0 +} diff --git a/completions_test.go b/completions_test.go new file mode 100644 index 0000000..cc04f21 --- /dev/null +++ b/completions_test.go @@ -0,0 +1,97 @@ +package main + +import ( + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func TestBashCompletion(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("bash completion not supported on Windows") + } + if _, err := exec.LookPath("bash"); err != nil { + t.Skip("bash not on PATH") + } + if err := gitConfigGlobalFixture(t); err != nil { + t.Fatalf("setup: %v", err) + } + + bin, getpath := buildCompletionFixture(t) + cmd := exec.Command("bash", "-c", `source completions/git-get.bash +COMP_WORDS=("git-get" "github.com/arbourd/") +COMP_CWORD=1 +_git_get +printf '%s\n' "${COMPREPLY[@]}"`) + cmd.Env = completionEnv(t, bin, getpath) + + out, err := cmd.Output() + if err != nil { + t.Fatalf("bash: %v", err) + } + if got := string(out); !strings.Contains(got, "github.com/arbourd/git-get") { + t.Errorf("unexpected output: %q", got) + } +} + +func TestFishCompletion(t *testing.T) { + if _, err := exec.LookPath("fish"); err != nil { + t.Skip("fish not on PATH") + } + if err := gitConfigGlobalFixture(t); err != nil { + t.Fatalf("setup: %v", err) + } + + bin, getpath := buildCompletionFixture(t) + cmd := exec.Command("fish", "-c", `source completions/git-get.fish +complete --do-complete "git-get github.com/arbourd/"`) + cmd.Env = completionEnv(t, bin, getpath) + + out, err := cmd.Output() + if err != nil { + t.Fatalf("fish: %v", err) + } + if got := string(out); !strings.Contains(got, "github.com/arbourd/git-get") { + t.Errorf("unexpected output: %q", got) + } +} + +func buildCompletionFixture(t *testing.T) (bin, getpath string) { + t.Helper() + + bin = filepath.Join(t.TempDir(), "git-get") + if runtime.GOOS == "windows" { + bin += ".exe" + } + if out, err := exec.Command("go", "build", "-o", bin, ".").CombinedOutput(); err != nil { + t.Fatalf("go build: %v\n%s", err, out) + } + + getpath = t.TempDir() + seedRepos(t, getpath, []string{"github.com/arbourd/git-get"}) + return bin, getpath +} + +func completionEnv(t *testing.T, bin, getpath string) []string { + t.Helper() + pathSep := ":" + if runtime.GOOS == "windows" { + pathSep = ";" + } + + env := make([]string, 0, len(os.Environ())+3) + for _, e := range os.Environ() { + if !strings.HasPrefix(e, "PATH=") && !strings.HasPrefix(e, "GETPATH=") && !strings.HasPrefix(e, "HOME=") { + env = append(env, e) + } + } + + return append(env, + "PATH="+filepath.Dir(bin)+pathSep+os.Getenv("PATH"), + "GETPATH="+getpath, + "HOME="+t.TempDir(), + ) +} diff --git a/get/complete.go b/get/complete.go new file mode 100644 index 0000000..f8d0ce4 --- /dev/null +++ b/get/complete.go @@ -0,0 +1,70 @@ +package get + +import ( + "errors" + "fmt" + "io/fs" + "path/filepath" + "strings" +) + +// Complete returns repository paths relative to GETPATH that match the given prefix, +// completing one path segment at a time. +func Complete(prefix string) ([]string, error) { + trailingSlash := strings.HasSuffix(prefix, "/") || strings.HasSuffix(prefix, string(filepath.Separator)) + if prefix != "" { + prefix = strings.ToLower(filepath.ToSlash(filepath.Clean(prefix))) + } + prefixDepth := strings.Count(prefix, "/") + if trailingSlash { + prefixDepth++ + } + + getpath, err := AbsolutePath() + if err != nil { + return nil, fmt.Errorf("resolving GETPATH: %w", err) + } + + var matches []string + err = filepath.WalkDir(getpath, func(path string, d fs.DirEntry, err error) error { + if err != nil { + if path == getpath { + return err + } + return nil + } + if !d.IsDir() || path == getpath { + return nil + } + if strings.HasPrefix(d.Name(), ".") { + return fs.SkipDir + } + + rel, err := filepath.Rel(getpath, path) + if err != nil { + return nil + } + rel = filepath.ToSlash(rel) + relLower := strings.ToLower(rel) + relDepth := strings.Count(rel, "/") + + if relDepth == prefixDepth { + if strings.HasPrefix(relLower, prefix) { + if !isGitRepository(path) { + rel += "/" + } + matches = append(matches, rel) + } + return fs.SkipDir + } + + if relLower != prefix && !strings.HasPrefix(prefix, relLower+"/") { + return fs.SkipDir + } + return nil + }) + if errors.Is(err, fs.ErrNotExist) { + return nil, nil + } + return matches, err +} diff --git a/get/complete_test.go b/get/complete_test.go new file mode 100644 index 0000000..4f0a57b --- /dev/null +++ b/get/complete_test.go @@ -0,0 +1,118 @@ +package get + +import ( + "os" + "path/filepath" + "slices" + "testing" +) + +func TestComplete(t *testing.T) { + err := gitConfigGlobalFixture(t) + if err != nil { + t.Fatalf("unable to setup test fixture: %s", err) + } + + getpath := t.TempDir() + repos := []string{ + "github.com/arbourd/git-get", + "github.com/torvalds/linux", + "gitlab.com/gitlab-org/dev-subdepartment/ai-dev-promptcollection", + } + for _, r := range repos { + dir := filepath.Join(getpath, filepath.FromSlash(r)) + if err := os.MkdirAll(filepath.Join(dir, ".git"), 0755); err != nil { + t.Fatalf("setup: %v", err) + } + } + t.Setenv("GETPATH", getpath) + + cases := map[string]struct { + prefix string + want []string + }{ + "empty prefix returns hosts": { + prefix: "", + want: []string{"github.com/", "gitlab.com/"}, + }, + "partial host": { + prefix: "github", + want: []string{"github.com/"}, + }, + "full host": { + prefix: "github.com", + want: []string{"github.com/"}, + }, + "host with slash": { + prefix: "github.com/", + want: []string{"github.com/arbourd/", "github.com/torvalds/"}, + }, + "partial user": { + prefix: "github.com/ar", + want: []string{"github.com/arbourd/"}, + }, + "full user": { + prefix: "github.com/arbourd", + want: []string{"github.com/arbourd/"}, + }, + "user with slash": { + prefix: "github.com/arbourd/", + want: []string{"github.com/arbourd/git-get"}, + }, + "partial repo": { + prefix: "github.com/arbourd/git", + want: []string{"github.com/arbourd/git-get"}, + }, + "case insensitive": { + prefix: "GitHub.com/Arbourd/GIT", + want: []string{"github.com/arbourd/git-get"}, + }, + "no match": { + prefix: "notexist", + want: nil, + }, + } + + for name, c := range cases { + t.Run(name, func(t *testing.T) { + got, err := Complete(c.prefix) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !slices.Equal(got, c.want) { + t.Fatalf("unexpected completions:\n\t(GOT): %v\n\t(WNT): %v", got, c.want) + } + }) + } + + t.Run("hidden directories are skipped", func(t *testing.T) { + hiddenpath := t.TempDir() + for _, r := range []string{".hidden/user/repo", "visible/user/repo"} { + dir := filepath.Join(hiddenpath, filepath.FromSlash(r)) + if err := os.MkdirAll(filepath.Join(dir, ".git"), 0755); err != nil { + t.Fatalf("setup: %v", err) + } + } + t.Setenv("GETPATH", hiddenpath) + + got, err := Complete("") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !slices.Equal(got, []string{"visible/"}) { + t.Fatalf("unexpected completions:\n\t(GOT): %v\n\t(WNT): [visible/]", got) + } + }) + + t.Run("non-existent GETPATH returns empty", func(t *testing.T) { + t.Setenv("GETPATH", filepath.Join(t.TempDir(), "nonexistent")) + + got, err := Complete("") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(got) != 0 { + t.Fatalf("expected empty, got: %v", got) + } + }) +} diff --git a/get/get.go b/get/get.go index 3803e86..981c539 100644 --- a/get/get.go +++ b/get/get.go @@ -15,8 +15,9 @@ import ( ) const ( - // defaultGetpath is the default path where repositories will be cloned if not configured - defaultGetpath = "~/src" + // defaultGetpath is the default GETPATH used when none is specified + defaultGetpath = defaultPrefix + "/src" + defaultPrefix = "~" // defaultScheme is the scheme used when a URL is provided without one defaultScheme = "https" @@ -28,48 +29,49 @@ const ( EnvKey = "GETPATH" ) -// Path returns the absolute GETPATH -func Path() (string, error) { - p := configPath() +// AbsolutePath returns the absolute GETPATH, resolving env vars and ~ expansion. +// Precedence: GETPATH env var > get.path git config > default. +func AbsolutePath() (string, error) { + p := os.Getenv(EnvKey) + if p == "" { + out, _ := git.Config(config.Global, config.Get(GitConfigKey, "")) + p = strings.TrimSpace(out) + } + if p == "" { + p = defaultGetpath + } p = os.ExpandEnv(p) - if strings.HasPrefix(p, "~") { - dir, err := os.UserHomeDir() + home, err := os.UserHomeDir() if err != nil { return "", fmt.Errorf("detecting home directory: %w", err) } - - p = filepath.Join(dir, p[1:]) + p = filepath.Join(home, p[1:]) } if !filepath.IsAbs(p) { - return "", fmt.Errorf("GETPATH is not an absolute path: \"%s\"", p) + return "", fmt.Errorf("GETPATH is not an absolute path: %q", p) } - - // Make GETPATH directory if it does not exist - if _, err := os.Stat(p); os.IsNotExist(err) { - err := os.MkdirAll(p, 0755) - if err != nil { - return "", fmt.Errorf("creating GETPATH directory: %w", err) - } - } - return p, nil } -// configPath returns the GETPATH from the config, environment or default -func configPath() string { - if getpath := os.Getenv(EnvKey); getpath != "" { - return getpath +// ShortPath returns a shortened version of the given path +func ShortPath(path string) string { + home, err := os.UserHomeDir() + if err != nil { + return path } - out, _ := git.Config(config.Global, config.Get(GitConfigKey, "")) - if getpath := strings.TrimSpace(out); getpath != "" { - return getpath + rel, err := filepath.Rel(home, path) + if err != nil || strings.HasPrefix(rel, "..") { + return path } - return defaultGetpath + if rel == "." { + return defaultPrefix + } + return filepath.Join(defaultPrefix, rel) } var scpSyntaxRe = regexp.MustCompile(`^(\w+)@([\w.-]+):(.*)$`) @@ -91,7 +93,7 @@ func ParseURL(remote string) (*url.URL, error) { u, err := url.Parse(remote) if err != nil { - return nil, err + return nil, fmt.Errorf("parsing url: %w", err) } if len(u.Scheme) == 0 { @@ -119,16 +121,14 @@ func Clone(u *url.URL, dir string) (string, error) { if err != nil { sanitized := *u sanitized.User = nil - return "", fmt.Errorf("git repository not found: %s", sanitized.String()) + return "", fmt.Errorf("git repository not found at %s: %w", sanitized.String(), err) } parentdir, _ := filepath.Split(dir) - gitdir := filepath.Join(dir, ".git") - if _, err := os.Stat(gitdir); os.IsNotExist(err) { - // Check if root folder exists, even though the .git directory does not + if !isGitRepository(dir) { if _, err := os.Stat(dir); !os.IsNotExist(err) { - return "", fmt.Errorf("%s exists but %s does not", dir, gitdir) + return "", fmt.Errorf("%s exists but %s does not", dir, filepath.Join(dir, ".git")) } err := os.MkdirAll(parentdir, 0755) @@ -144,3 +144,8 @@ func Clone(u *url.URL, dir string) (string, error) { return dir, nil } + +func isGitRepository(path string) bool { + _, err := os.Stat(filepath.Join(path, ".git")) + return err == nil +} diff --git a/get/get_test.go b/get/get_test.go index a5d7fa9..f7c7e08 100644 --- a/get/get_test.go +++ b/get/get_test.go @@ -12,7 +12,7 @@ import ( "github.com/ldez/go-git-cmd-wrapper/v2/git" ) -func TestPath(t *testing.T) { +func TestAbsolutePath(t *testing.T) { home, err := os.UserHomeDir() if err != nil { t.Fatalf("unable to detect homedir: %s", err) @@ -26,11 +26,11 @@ func TestPath(t *testing.T) { t.Fatalf("unable to setup test fixture: %s", err) } - homeVar := "HOME" + homeVar := "$HOME" if runtime.GOOS == "windows" { - homeVar = "USERPROFILE" + homeVar = "$USERPROFILE" } - getpathWithVar := filepath.FromSlash(fmt.Sprintf("$%s/src", homeVar)) + getpathWithVar := filepath.Join(homeVar, "src") cases := map[string]struct { gitConfigGetpath string @@ -72,7 +72,7 @@ func TestPath(t *testing.T) { t.Run(name, func(t *testing.T) { setupEnv(t, c.gitConfigGetpath, c.envGetpath) - path, err := Path() + path, err := AbsolutePath() if err != nil && !c.wantErr { t.Fatalf("unexpected error:\n\t(GOT): %#v\n\t(WNT): nil", err) } else if err == nil && c.wantErr { @@ -87,7 +87,7 @@ func TestPath(t *testing.T) { t.Setenv("TEST_GETPATH", "~/src") setupEnv(t, "", "$TEST_GETPATH") - path, err := Path() + path, err := AbsolutePath() if err != nil { t.Fatalf("unexpected error:\n\t(GOT): %#v\n\t(WNT): nil", err) } @@ -97,44 +97,34 @@ func TestPath(t *testing.T) { }) } -func TestConfigPath(t *testing.T) { - configGetpath := t.TempDir() - envGetpath := t.TempDir() - err := gitConfigGlobalFixture(t) +func TestShortPath(t *testing.T) { + home, err := os.UserHomeDir() if err != nil { - t.Fatalf("unable to setup test fixture: %s", err) + t.Fatalf("unable to get home dir: %v", err) } cases := map[string]struct { - gitConfigGetpath string - envGetpath string - expectedPath string + path string + want string }{ - "default": { - expectedPath: "~/src", + "subdir of home": { + path: filepath.Join(home, "src"), + want: filepath.Join(defaultPrefix, "src"), }, - "git config getpath": { - gitConfigGetpath: configGetpath, - expectedPath: configGetpath, - }, - "env var getpath": { - envGetpath: envGetpath, - expectedPath: envGetpath, + "home itself": { + path: home, + want: defaultPrefix, }, - "env var getpath over git config getpath": { - gitConfigGetpath: configGetpath, - envGetpath: envGetpath, - expectedPath: envGetpath, + "homeless": { + path: "/root/dev", + want: "/root/dev", }, } for name, c := range cases { t.Run(name, func(t *testing.T) { - setupEnv(t, c.gitConfigGetpath, c.envGetpath) - - path := configPath() - if path != c.expectedPath { - t.Fatalf("unexpected path:\n\t(GOT): %#v\n\t(WNT): %#v", path, c.expectedPath) + if got := ShortPath(c.path); got != c.want { + t.Fatalf("unexpected ShortPath(%q):\n\t(GOT): %q\n\t(WNT): %q", c.path, got, c.want) } }) } @@ -265,9 +255,9 @@ func TestClone(t *testing.T) { url: &url.URL{ Scheme: "https", Host: "gitlab.com", - Path: "gitlab-org/dev-subdepartment/ai-experimentation-chrome-plugin", + Path: "gitlab-org/dev-subdepartment/ai-dev-promptcollection", }, - expectedPath: filepath.Join(dir, "gitlab.com/gitlab-org/dev-subdepartment/ai-experimentation-chrome-plugin"), + expectedPath: filepath.Join(dir, "gitlab.com/gitlab-org/dev-subdepartment/ai-dev-promptcollection"), }, "invalid url": { url: &url.URL{ @@ -302,27 +292,15 @@ func TestClone(t *testing.T) { } } -// gitConfigGlobalFixture creates a temporary folder and .gitconfig to be used as the global -// Git config for tests. func gitConfigGlobalFixture(t *testing.T) error { - // Skip fixture on Windows in CI - if os.Getenv("CI") == "true" && runtime.GOOS == "windows" { - return nil - } - + t.Helper() gitconfig := filepath.Join(t.TempDir(), ".gitconfig") - - _, err := os.Create(gitconfig) + f, err := os.Create(gitconfig) if err != nil { - return fmt.Errorf("unable create .gitconfig: %w", err) + return fmt.Errorf("unable to create .gitconfig: %w", err) } - - err = os.Setenv("GIT_CONFIG_GLOBAL", gitconfig) - if err != nil { - return fmt.Errorf("unable to set GIT_CONFIG_GLOBAL: %w", err) - } - t.Cleanup(func() { os.Unsetenv("GIT_CONFIG_GLOBAL") }) - + defer f.Close() + t.Setenv("GIT_CONFIG_GLOBAL", gitconfig) return nil } diff --git a/main.go b/main.go index 084b558..671fe5a 100644 --- a/main.go +++ b/main.go @@ -2,44 +2,95 @@ package main import ( "fmt" + "io" "os" "path/filepath" "github.com/arbourd/git-get/get" ) +// Version is set via -ldflags at build time. +var Version = "dev" + +const usage = `Usage: git-get + +Clone a git repository to GETPATH (%s). + +Arguments: + repository The git repository URL to clone + +Options: + -h, --help Show this help message + -v, --version Show version` + func main() { - dir, err := run() - if err != nil { - fmt.Printf("error: %s\n", err) + if err := run(os.Args[1:], os.Stdout); err != nil { + fmt.Fprintf(os.Stderr, "error: %s\n", err) os.Exit(1) } - - fmt.Println(dir) } -func run() (string, error) { - path, err := get.Path() - if err != nil { - return "", fmt.Errorf("resolving GETPATH: %w", err) +func run(args []string, stdout io.Writer) error { + if len(args) == 0 { + return fmt.Errorf("no repository specified\n\n%s", buildUsage()) } - args := os.Args[1:] - if len(args) == 0 { - return "", fmt.Errorf("must provide a git repository url") + switch args[0] { + case "--help", "-h": + fmt.Fprintln(stdout, buildUsage()) + return nil + case "--version", "-v": + fmt.Fprintln(stdout, Version) + return nil + case "--complete": + prefix := "" + if len(args) > 1 { + prefix = args[1] + } + matches, err := get.Complete(prefix) + if err != nil { + return fmt.Errorf("completions: %w", err) + } + for _, m := range matches { + fmt.Fprintln(stdout, m) + } + return nil + } + + return clone(args[0], stdout) +} + +func clone(remote string, stdout io.Writer) error { + path, err := get.AbsolutePath() + if err != nil { + return fmt.Errorf("resolving GETPATH: %w", err) } - remote := args[0] url, err := get.ParseURL(remote) if err != nil { - return "", fmt.Errorf("unable to parse repository url \"%s\": %w", remote, err) + return fmt.Errorf("unable to parse repository url %q: %w", remote, err) } relDir, err := get.Directory(url) if err != nil { - return "", fmt.Errorf("unable to determine directory for url \"%s\": %w", remote, err) + return fmt.Errorf("unable to determine directory for url %q: %w", remote, err) } dir := filepath.Join(path, relDir) - return get.Clone(url, dir) + result, err := get.Clone(url, dir) + if err != nil { + return fmt.Errorf("cloning repository: %w", err) + } + + fmt.Fprintln(stdout, result) + return nil +} + +func buildUsage() string { + path, err := get.AbsolutePath() + if err != nil { + return fmt.Sprintf(usage, "~/src") + } + + return fmt.Sprintf(usage, get.ShortPath(path)) } diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..fd6226f --- /dev/null +++ b/main_test.go @@ -0,0 +1,119 @@ +package main + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestRun(t *testing.T) { + cases := map[string]struct { + args []string + wantStdout string + wantRunErr bool + wantErrContains string + setup func(t *testing.T) + }{ + "no args": { + args: []string{}, + wantRunErr: true, + wantErrContains: "no repository specified", + }, + "--help": { + args: []string{"--help"}, + wantStdout: "Usage: git-get", + }, + "-h": { + args: []string{"-h"}, + wantStdout: "Usage: git-get", + }, + "--version": { + args: []string{"--version"}, + wantStdout: "testversion\n", + }, + "-v": { + args: []string{"-v"}, + wantStdout: "testversion\n", + }, + "--complete empty prefix": { + args: []string{"--complete"}, + wantStdout: "github.com/\n", + setup: setupGetpath, + }, + "--complete with prefix": { + args: []string{"--complete", "github.com/arbourd/git"}, + wantStdout: "github.com/arbourd/git-get\n", + setup: setupGetpath, + }, + "--complete no match": { + args: []string{"--complete", "notexist"}, + setup: setupGetpath, + }, + } + + for name, c := range cases { + t.Run(name, func(t *testing.T) { + Version = "testversion" + + if err := gitConfigGlobalFixture(t); err != nil { + t.Fatalf("setup: %v", err) + } + if c.setup != nil { + c.setup(t) + } + + var stdout bytes.Buffer + err := run(c.args, &stdout) + + if c.wantRunErr && err == nil { + t.Fatal("expected run() to return an error, got nil") + } + if !c.wantRunErr && err != nil { + t.Fatalf("unexpected error: %v", err) + } + if c.wantErrContains != "" && (err == nil || !strings.Contains(err.Error(), c.wantErrContains)) { + t.Fatalf("unexpected error:\n\t(GOT): %v\n\t(WNT): contains %q", err, c.wantErrContains) + } + + if c.wantStdout != "" && !strings.Contains(stdout.String(), c.wantStdout) { + t.Fatalf("unexpected stdout:\n\t(GOT): %q\n\t(WNT): contains %q", stdout.String(), c.wantStdout) + } + if c.wantStdout == "" && stdout.Len() > 0 { + t.Fatalf("expected no stdout, got: %q", stdout.String()) + } + }) + } +} + +func gitConfigGlobalFixture(t *testing.T) error { + t.Helper() + gitconfig := filepath.Join(t.TempDir(), ".gitconfig") + f, err := os.Create(gitconfig) + if err != nil { + return fmt.Errorf("unable to create .gitconfig: %w", err) + } + defer f.Close() + + t.Setenv("GIT_CONFIG_GLOBAL", gitconfig) + return nil +} + +func setupGetpath(t *testing.T) { + t.Helper() + getpath := t.TempDir() + seedRepos(t, getpath, []string{"github.com/arbourd/git-get"}) + t.Setenv("GETPATH", getpath) +} + +func seedRepos(t *testing.T, getpath string, repos []string) { + t.Helper() + for _, r := range repos { + dir := filepath.Join(getpath, filepath.FromSlash(r)) + if err := os.MkdirAll(filepath.Join(dir, ".git"), 0755); err != nil { + t.Fatalf("setup: %v", err) + } + } +} diff --git a/man/git-get.1 b/man/git-get.1 new file mode 100644 index 0000000..a66ebad --- /dev/null +++ b/man/git-get.1 @@ -0,0 +1,84 @@ +.TH GIT-GET 1 "2026-05-07" "git-get" "User Commands" +.SH NAME +git-get \- clone a git repository to GETPATH +.SH SYNOPSIS +.B git-get +.RI [ options ] +.I repository +.SH DESCRIPTION +.B git-get +clones a git repository to a structured path under +.B GETPATH +(default: +.IR ~/src ), +mirroring the repository's URL as a directory hierarchy in the style of +.BR go-get (1). +For example, +.I github.com/arbourd/git-get +is cloned to +.IR $GETPATH/github.com/arbourd/git-get . +.PP +If the destination already contains a +.I .git +directory, +.B git-get +exits without re-cloning. +.SH OPTIONS +.TP +.BR \-h ", " \-\-help +Print usage information and exit. +.TP +.BR \-v ", " \-\-version +Print the version and exit. +.SH ARGUMENTS +.TP +.I repository +The repository to clone. Accepted forms: +.RS +.IP \(bu 4 +Bare path: +.I github.com/user/repo +.IP \(bu 4 +HTTPS URL: +.I https://github.com/user/repo.git +.IP \(bu 4 +SSH URL: +.I git@github.com:user/repo.git +.RE +.SH ENVIRONMENT +.TP +.B GETPATH +Root directory for cloned repositories. Takes precedence over +.BR get.path . +.SH CONFIGURATION +.TP +.B get.path +Git config key for the root directory. Set with: +.RS +.EX +git config \-\-global get.path ~/dev +.EE +.RE +If neither +.B GETPATH +nor +.B get.path +is set, the default is +.IR ~/src . +.SH EXAMPLES +.EX +$ git get github.com/arbourd/git-get +~/src/github.com/arbourd/git-get + +$ git get https://github.com/arbourd/git-get.git +~/src/github.com/arbourd/git-get + +$ git get git@github.com:arbourd/git-get.git +~/src/github.com/arbourd/git-get + +$ GETPATH=~/corp git get github.com/org/repo +~/corp/github.com/org/repo +.EE +.SH SEE ALSO +.BR git (1), +.BR git-clone (1)