diff --git a/AGENTS.md b/AGENTS.md index 4b02b62..b86c655 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,8 +18,8 @@ Version is injected at build time: `go build -ldflags "-X main.Version=$(git rev ``` main.go / main_test.go CLI entry point and tests -completions_test.go Bash/fish completion tests (spawn built binary) -get/get.go AbsolutePath, ParseURL, Directory, Clone +completions_test.go Bash/fish/zsh/pwsh completion tests (spawn built binary) +get/get.go AbsolutePath, ShortPath, ParseURL, Directory, Clone, RedactRemote get/complete.go Shell completion walker completions/ Shell completion scripts (bash, fish, zsh, ps1) man/git-get.1 Man page (update when adding flags) @@ -39,14 +39,16 @@ run() --complete → Complete() → AbsolutePath() (walks GETPATH for shell co - `Directory` — strips `.git` suffix, returns `host/path`; guarantees the result is `filepath.IsLocal` (never escapes upward via `..`) so any GETPATH it's joined to stays contained - `Clone` — three cases: `.git` present → return dir; dir exists without `.git` → error; dir absent → ls-remote, mkdir, clone - `ShortPath` — inverse of `AbsolutePath`'s `~` expansion; used by `main.go` to render GETPATH in usage output +- `run` parses flags itself: `--complete` first, then `-h`/`--help`/`--version` anywhere, then options. A lone `-v` is the version; with a repository it is `--verbose`. Unknown or duplicate options and extra positionals are errors; `--` ends options. +- stdout carries only the resulting path (scripts do `cd $(git get …)`); verbose output goes to stderr via `verboseOption` (`Clone`'s `io.Writer`, nil = quiet). Git output is printed after each command finishes, not streamed: the wrapper's executor uses `CombinedOutput`, and streaming would need `exec.Command`. ## Hard constraints -**Git operations:** use `github.com/ldez/go-git-cmd-wrapper/v2` exclusively. Never use `exec.Command("git", ...)` or build command strings manually. +**Git operations:** use `github.com/ldez/go-git-cmd-wrapper/v2` exclusively. Never use `exec.Command("git", ...)` or build command strings to execute. (`shellQuote` only formats the verbose log line.) **Dependencies:** zero new dependencies. The only permitted external module is `github.com/ldez/go-git-cmd-wrapper/v2`. -**Error handling:** `fmt.Errorf("context: %w", err)`. Set `url.User = nil` before formatting any URL into an error string. +**Error handling:** `fmt.Errorf("context: %w", err)`. Format URLs in errors and verbose logs through `redact()` (drops userinfo except a bare ssh username); raw user input through `get.RedactRemote()`. **Not-exist checks:** `errors.Is(err, fs.ErrNotExist)` — not `os.IsNotExist` (deprecated). @@ -60,12 +62,12 @@ run() --complete → Complete() → AbsolutePath() (walks GETPATH for shell co - `t.TempDir()` for filesystem isolation; `t.Setenv()` for env vars. - Always call `gitConfigGlobalFixture(t)` to redirect `GIT_CONFIG_GLOBAL` to a temp file — defined once per package (`main_test.go`, `get/get_test.go`); other test files in the same package reuse it. Do not import across packages. - Guard network tests with `if testing.Short() { t.Skip(...) }`. -- Completion tests (`completions_test.go`) build and spawn a real binary via the bash/fish scripts; `completionEnv` sets `GETPATH`, `HOME`, and `PATH` explicitly and inherits `GIT_CONFIG_GLOBAL`. They skip if the shell is not installed. +- Completion tests (`completions_test.go`) build and spawn a real binary via the bash/fish/zsh/pwsh scripts; `completionEnv` sets `GETPATH`, `HOME`, and `PATH` explicitly and inherits `GIT_CONFIG_GLOBAL`. They skip if the shell is not installed. +- Offline clone tests: map a fake `https://example.test/` to a local `git init --bare` remote with `url..insteadOf` in `GIT_CONFIG_GLOBAL` (see `TestRunVerbose`). - The `clone ssh github` case needs GitHub SSH auth locally; CI rewrites SSH to HTTPS via `git config url.https://github.com/.insteadOf ssh://git@github.com/`. ## Do not -- Use `os.IsNotExist`; use `errors.Is(err, fs.ErrNotExist)`. - Skip the `ls-remote` validation in `Clone` — it must fire before any directory is created. - Add user-facing flags without updating the usage string in `main.go` and `man/git-get.1`. - Expose `--complete`; it is an internal flag consumed by `completions/` scripts only. diff --git a/completions/git-get.bash b/completions/git-get.bash index 2db5de7..cee4462 100644 --- a/completions/git-get.bash +++ b/completions/git-get.bash @@ -1,10 +1,19 @@ _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 + local start=1 + [[ "${COMP_WORDS[0]}" == "git" ]] && start=2 + local cur="${COMP_WORDS[COMP_CWORD]}" + if [[ "$cur" == -* ]]; then + COMPREPLY=($(compgen -W "-h --help -v --verbose --version" -- "$cur")) return fi - COMPREPLY=($(git-get --complete "${COMP_WORDS[COMP_CWORD]}" 2>/dev/null)) + # Only the repository is completed; stop once one is already given. + local i + for ((i = start; i < COMP_CWORD; i++)); do + if [[ "${COMP_WORDS[i]}" != -* ]]; then + compopt +o default +o bashdefault 2>/dev/null + return + fi + done + COMPREPLY=($(git-get --complete "$cur" 2>/dev/null)) } complete -F _git_get git-get diff --git a/completions/git-get.fish b/completions/git-get.fish index c6a79b9..57c14ce 100644 --- a/completions/git-get.fish +++ b/completions/git-get.fish @@ -2,7 +2,23 @@ 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 +# True while no repository has been given yet. $argv[1] is the number of +# leading tokens that precede git-get's own arguments (1 for git-get, 2 for git get). +function __fish_gitget_needs_repo + set -l i 0 + for t in (commandline -opc) + set i (math $i + 1) + test $i -le $argv[1]; and continue + string match -q -- '-*' $t; or return 1 + end + return 0 +end + +complete -c git-get -s h -l help -f -d 'Show help' +complete -c git-get -s v -l verbose -f -d 'Show git commands and output' +complete -c git-get -l version -f -d 'Show version' +complete -c git-get -f -n '__fish_gitget_needs_repo 1' -a "(__fish_gitget_completion)" +complete -c git -n '__fish_git_using_command get; and __fish_gitget_needs_repo 2' -f -a "(__fish_gitget_completion)" +complete -c git -n '__fish_git_using_command get' -s h -l help -f -d 'Show help' +complete -c git -n '__fish_git_using_command get' -s v -l verbose -f -d 'Show git commands and output' +complete -c git -n '__fish_git_using_command get' -l version -f -d 'Show version' diff --git a/completions/git-get.ps1 b/completions/git-get.ps1 index 02f70ae..96cc90a 100644 --- a/completions/git-get.ps1 +++ b/completions/git-get.ps1 @@ -1,6 +1,21 @@ +# $skip is the number of leading elements before git-get's own arguments (1 for git-get, 2 for git get). $gitGetCompleter = { - param($wordToComplete, $commandAst, $cursorPosition) - if ($commandAst.CommandElements.Count -gt 2) { return } + param($wordToComplete, $commandAst, $cursorPosition, $skip = 1) + $elements = @($commandAst.CommandElements | ForEach-Object { $_.Extent.Text }) + + if ($wordToComplete -like '-*') { + '-h', '--help', '-v', '--verbose', '--version' | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object { + [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterName', $_) + } + return + } + + # Skip the command itself and the word being completed; + # only the repository is completed, so stop once one is already given. + $given = @($elements | Select-Object -Skip $skip) + if ($wordToComplete -ne '') { $given = @($given | Select-Object -SkipLast 1) } + if (@($given | Where-Object { $_ -notlike '-*' }).Count -gt 0) { return } + git-get --complete "$wordToComplete" 2>$null | ForEach-Object { [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_) } @@ -10,8 +25,7 @@ Register-ArgumentCompleter -Native -CommandName git-get -ScriptBlock $gitGetComp 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 + if ($commandAst.CommandElements.Count -ge 2 -and $commandAst.CommandElements[1].Extent.Text -eq 'get') { + & $gitGetCompleter $wordToComplete $commandAst $cursorPosition 2 } } diff --git a/completions/git-get.zsh b/completions/git-get.zsh index 13ecdfe..dda5d8e 100644 --- a/completions/git-get.zsh +++ b/completions/git-get.zsh @@ -2,9 +2,22 @@ _git_get() { emulate -L zsh - local c="${cword:-$CURRENT}" - [[ $c -gt 2 ]] && { _ret=0; return } + # Index of git-get's first argument in $words (git get vs git-get ). + local start=2 i + [[ "${words[1]}" == git ]] && start=3 + # Under git's zsh wrapper CURRENT is shifted down by one but $words is not; cword is the real index - 1. + local pos=${cword:+$((cword + 1))} + pos=${pos:-$CURRENT} local prefix="${cur-${words[$CURRENT]}}" + if [[ "$prefix" == -* ]]; then + compadd -- -h --help -v --verbose --version + _ret=0 + return + fi + # Only the repository is completed; stop once one is already given. + for (( i = start; i < pos; i++ )); do + [[ "${words[$i]}" != -* ]] && { _ret=0; return } + done local completions completions=($(git-get --complete "$prefix" 2>/dev/null)) compadd -S "" -- ${(M)completions:#*/} diff --git a/completions_test.go b/completions_test.go index cc04f21..083a3c7 100644 --- a/completions_test.go +++ b/completions_test.go @@ -1,11 +1,13 @@ package main import ( + "fmt" "os" "os/exec" "path/filepath" "runtime" "strings" + "sync" "testing" ) @@ -16,24 +18,56 @@ func TestBashCompletion(t *testing.T) { if _, err := exec.LookPath("bash"); err != nil { t.Skip("bash not on PATH") } - if err := gitConfigGlobalFixture(t); err != nil { - t.Fatalf("setup: %v", err) + + cases := map[string]struct { + words string // COMP_WORDS contents + cword int + want []string + notWant []string + }{ + "repository": { + words: `"git-get" "github.com/arbourd/"`, + cword: 1, + want: []string{"github.com/arbourd/git-get"}, + }, + "repository after -v": { + words: `"git-get" "-v" "github.com/arbourd/"`, + cword: 2, + want: []string{"github.com/arbourd/git-get"}, + }, + "repository after git get -v": { + words: `"git" "get" "--verbose" "github.com/arbourd/"`, + cword: 3, + want: []string{"github.com/arbourd/git-get"}, + }, + "nothing after repository": { + words: `"git-get" "github.com/arbourd/git-get" "github.com/arbourd/"`, + cword: 2, + notWant: []string{"github.com/arbourd/git-get"}, + }, + "flags": { + words: `"git-get" "--v"`, + cword: 1, + want: []string{"--verbose", "--version"}, + }, } - bin, getpath := buildCompletionFixture(t) - cmd := exec.Command("bash", "-c", `source completions/git-get.bash -COMP_WORDS=("git-get" "github.com/arbourd/") -COMP_CWORD=1 + env := completionEnv(t) + for name, c := range cases { + t.Run(name, func(t *testing.T) { + cmd := exec.Command("bash", "-c", fmt.Sprintf(`source completions/git-get.bash +COMP_WORDS=(%s) +COMP_CWORD=%d _git_get -printf '%s\n' "${COMPREPLY[@]}"`) - cmd.Env = completionEnv(t, bin, getpath) +printf '%%s\n' "${COMPREPLY[@]}"`, c.words, c.cword)) + cmd.Env = env - 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) + out, err := cmd.Output() + if err != nil { + t.Fatalf("bash: %v", err) + } + assertContains(t, string(out), c.want, c.notWant) + }) } } @@ -41,42 +75,229 @@ 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) + + cases := map[string]struct { + line string + want []string + notWant []string + }{ + "repository": { + line: "git-get github.com/arbourd/", + want: []string{"github.com/arbourd/git-get"}, + }, + "repository after -v": { + line: "git-get -v github.com/arbourd/", + want: []string{"github.com/arbourd/git-get"}, + }, + "nothing after repository": { + line: "git-get github.com/arbourd/git-get github.com/arbourd/", + notWant: []string{"github.com/arbourd/git-get"}, + }, + "flags": { + line: "git-get --v", + want: []string{"--verbose", "--version"}, + }, + "flags via git get": { + line: "git get --v", + want: []string{"--verbose", "--version"}, + }, } - 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) + env := completionEnv(t) + for name, c := range cases { + t.Run(name, func(t *testing.T) { + cmd := exec.Command("fish", "-c", fmt.Sprintf(`source completions/git-get.fish +complete --do-complete %q`, c.line)) + cmd.Env = env - out, err := cmd.Output() - if err != nil { - t.Fatalf("fish: %v", err) + out, err := cmd.Output() + if err != nil { + t.Fatalf("fish: %v", err) + } + assertContains(t, string(out), c.want, c.notWant) + }) + } +} + +func TestZshCompletion(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("zsh completion not supported on Windows") + } + if _, err := exec.LookPath("zsh"); err != nil { + t.Skip("zsh not on PATH") + } + + // Under git's zsh wrapper (wrapper=true) CURRENT is one lower than the real index while + // words is unshifted, and cword (the real index - 1) is set; natively cword is unset. + cases := map[string]struct { + words string + current int + wrapper bool + want []string + notWant []string + }{ + "native repository": { + words: `git-get github.com/arbourd/`, current: 2, + want: []string{"github.com/arbourd/git-get"}, + }, + "native nothing after repository": { + words: `git-get github.com/arbourd/git-get github.com/arbourd/`, current: 3, + notWant: []string{"github.com/arbourd/git-get"}, + }, + "wrapper repository after -v": { + words: `git get -v github.com/arbourd/`, current: 3, wrapper: true, + want: []string{"github.com/arbourd/git-get"}, + }, + "wrapper nothing after repository": { + words: `git get github.com/arbourd/git-get github.com/arbourd/`, current: 3, wrapper: true, + notWant: []string{"github.com/arbourd/git-get"}, + }, + "wrapper nothing after -v repository": { + words: `git get -v github.com/arbourd/git-get github.com/arbourd/`, current: 4, wrapper: true, + notWant: []string{"github.com/arbourd/git-get"}, + }, + "wrapper flags": { + words: `git get --v`, current: 2, wrapper: true, + want: []string{"--verbose", "--version"}, + }, + } + + env := completionEnv(t) + for name, c := range cases { + t.Run(name, func(t *testing.T) { + wrapper := "" + if c.wrapper { + wrapper = fmt.Sprintf("cword=%d", c.current) + } + // The current word is the last one; compadd is stubbed to print what would be offered. + cmd := exec.Command("zsh", "-c", fmt.Sprintf(`source completions/git-get.zsh +compadd() { local a; for a in "$@"; do [[ -n $a && $a != -S && $a != -- ]] && print -r -- $a; done } +words=(%s) +CURRENT=%d +cur=${words[-1]} +%s +_git_get`, c.words, c.current, wrapper)) + cmd.Env = env + + out, err := cmd.Output() + if err != nil { + t.Fatalf("zsh: %v", err) + } + assertContains(t, string(out), c.want, c.notWant) + }) + } +} + +func TestPowerShellCompletion(t *testing.T) { + if _, err := exec.LookPath("pwsh"); err != nil { + t.Skip("pwsh not on PATH") + } + + cases := map[string]struct { + line string + want []string + notWant []string + }{ + "repository": { + line: "git-get github.com/arbourd/", + want: []string{"github.com/arbourd/git-get"}, + }, + "repository after -v": { + line: "git-get -v github.com/arbourd/", + want: []string{"github.com/arbourd/git-get"}, + }, + "nothing after repository": { + line: "git-get github.com/arbourd/git-get github.com/arbourd/", + notWant: []string{"github.com/arbourd/git-get"}, + }, + "flags": { + line: "git-get --v", + want: []string{"--verbose", "--version"}, + }, + "repository via git get": { + line: "git get -v github.com/arbourd/", + want: []string{"github.com/arbourd/git-get"}, + }, + "nothing after repository via git get": { + line: "git get github.com/arbourd/git-get github.com/arbourd/", + notWant: []string{"github.com/arbourd/git-get"}, + }, + "nothing after repository via git.exe": { + line: "git.exe get github.com/arbourd/git-get github.com/arbourd/", + notWant: []string{"github.com/arbourd/git-get"}, + }, } - if got := string(out); !strings.Contains(got, "github.com/arbourd/git-get") { - t.Errorf("unexpected output: %q", got) + + env := completionEnv(t) + for name, c := range cases { + t.Run(name, func(t *testing.T) { + cmd := exec.Command("pwsh", "-NoProfile", "-Command", fmt.Sprintf(`. ./completions/git-get.ps1 +$line = %s +(TabExpansion2 -inputScript $line -cursorColumn $line.Length).CompletionMatches | ForEach-Object { $_.CompletionText }`, "'"+c.line+"'")) + cmd.Env = env + + out, err := cmd.Output() + if err != nil { + t.Fatalf("pwsh: %v", err) + } + assertContains(t, string(out), c.want, c.notWant) + }) } } -func buildCompletionFixture(t *testing.T) (bin, getpath string) { +func assertContains(t *testing.T, got string, want, notWant []string) { t.Helper() + for _, w := range want { + if !strings.Contains(got, w) { + t.Errorf("output missing %q: %q", w, got) + } + } + for _, w := range notWant { + if strings.Contains(got, w) { + t.Errorf("output unexpectedly contains %q: %q", w, got) + } + } +} + +// binDir holds the git-get binary shared by all completion tests; TestMain removes it. +var binDir string - bin = filepath.Join(t.TempDir(), "git-get") +var buildCompletionBinary = sync.OnceValues(func() (string, error) { + var err error + if binDir, err = os.MkdirTemp("", "git-get-bin"); err != nil { + return "", err + } + bin := filepath.Join(binDir, "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) + return "", fmt.Errorf("go build: %w\n%s", err, out) } + return bin, nil +}) - getpath = t.TempDir() - seedRepos(t, getpath, []string{"github.com/arbourd/git-get"}) - return bin, getpath +func TestMain(m *testing.M) { + code := m.Run() + if binDir != "" { + os.RemoveAll(binDir) + } + os.Exit(code) } -func completionEnv(t *testing.T, bin, getpath string) []string { +// completionEnv sets up a git config fixture, the binary on PATH and a seeded GETPATH. +func completionEnv(t *testing.T) []string { t.Helper() + if err := gitConfigGlobalFixture(t); err != nil { + t.Fatalf("setup: %v", err) + } + bin, err := buildCompletionBinary() + if err != nil { + t.Fatal(err) + } + getpath := t.TempDir() + seedRepos(t, getpath, []string{"github.com/arbourd/git-get"}) + pathSep := ":" if runtime.GOOS == "windows" { pathSep = ";" diff --git a/get/get.go b/get/get.go index 8fe3894..1ee7b33 100644 --- a/get/get.go +++ b/get/get.go @@ -1,8 +1,10 @@ package get import ( + "context" "errors" "fmt" + "io" "io/fs" "net/url" "os" @@ -95,7 +97,22 @@ func ParseURL(remote string) (*url.URL, error) { u, err := url.Parse(remote) if err != nil { - return nil, fmt.Errorf("parsing url: %w", err) + _, userinfo, _ := splitUserinfo(remote) + if userinfo == "" { + return nil, fmt.Errorf("parsing url: %w", err) + } + // err quotes the raw URL, and a password containing "/" can land in ue.Err, so only + // keep ue.Err when the userinfo has no password. + var ue *url.Error + if errors.As(err, &ue) && !strings.Contains(userinfo, ":") { + return nil, fmt.Errorf("parsing url: %w", ue.Err) + } + return nil, errors.New("parsing url: invalid URL") + } + + // Opaque means no "//" after the scheme (e.g. "user:pw@host:o/r"); Directory can't map it. + if u.Opaque != "" { + return nil, errors.New("parsing url: unsupported format") } if len(u.Scheme) == 0 { @@ -121,9 +138,13 @@ func Directory(u *url.URL) (string, error) { return dir, nil } -// Clone clones the remote repository to the GETPATH and returns the directory -func Clone(u *url.URL, dir string) (string, error) { +// Clone clones the remote repository to the GETPATH and returns the directory. +// If verbose is non-nil, each git command and its output is written to it. +func Clone(u *url.URL, dir string, verbose io.Writer) (string, error) { if isGitRepository(dir) { + if verbose != nil { + fmt.Fprintf(verbose, "already cloned: %s\n", dir) + } return dir, nil } @@ -136,11 +157,10 @@ func Clone(u *url.URL, dir string) (string, error) { // Check if git remote exists before creating any directories _, err := git.Raw("ls-remote", func(g *types.Cmd) { g.AddOptions(u.String()) - }) + g.AddOptions("HEAD") + }, verboseOption(verbose, u)) if err != nil { - sanitized := *u - sanitized.User = nil - return "", fmt.Errorf("git repository not found at %s: %w", sanitized.String(), err) + return "", fmt.Errorf("git repository not found at %s: %w", redact(u), err) } parentdir, _ := filepath.Split(dir) @@ -148,13 +168,80 @@ func Clone(u *url.URL, dir string) (string, error) { return "", fmt.Errorf("creating clone directory: %w", err) } - if _, err = git.Clone(clone.Repository(u.String()), clone.Directory(dir)); err != nil { + if _, err = git.Clone(clone.Repository(u.String()), clone.Directory(dir), verboseOption(verbose, u)); err != nil { return "", fmt.Errorf("git clone: %w", err) } return dir, nil } +// verboseOption wraps the command executor to write the git command line and +// its output to w. Credentials in u are redacted from the command line. +func verboseOption(w io.Writer, u *url.URL) types.Option { + return func(g *types.Cmd) { + if w == nil { + return + } + + raw, safe := u.String(), redact(u).String() + next := g.Executor + g.Executor = func(ctx context.Context, name string, debug bool, args ...string) (string, error) { + line := []string{name} + for _, a := range args { + line = append(line, shellQuote(strings.ReplaceAll(a, raw, safe))) + } + fmt.Fprintln(w, "+", strings.Join(line, " ")) + + out, err := next(ctx, name, debug, args...) + fmt.Fprint(w, out) + return out, err + } + } +} + +// redact returns a copy of u that is safe to print: userinfo is dropped, like git does in +// its own messages, except a bare ssh username (e.g. git@), which isn't a secret and keeps +// the logged command identical to the one that ran. +func redact(u *url.URL) *url.URL { + r := *u + if _, hasPassword := u.User.Password(); u.Scheme != "ssh" || hasPassword { + r.User = nil + } + return &r +} + +// RedactRemote drops userinfo from a raw, possibly unparseable, remote string so it can be +// put in an error message. +func RedactRemote(s string) string { + if u, err := url.Parse(s); err == nil && u.Host != "" { + return redact(u).String() + } + // Like git's messages, this drops a scp-like username too. + scheme, _, rest := splitUserinfo(s) + return scheme + rest +} + +// splitUserinfo splits a raw, possibly unparseable, remote into its scheme (with "://"), +// userinfo and the rest. A password may contain "/" or "@", so userinfo runs to the last "@". +func splitUserinfo(s string) (scheme, userinfo, rest string) { + i := strings.LastIndex(s, "@") + userinfo, rest = s[:max(i, 0)], s[i+1:] + if sch, after, ok := strings.Cut(userinfo, "://"); ok { + scheme, userinfo = sch+"://", after + } + return scheme, userinfo, rest +} + +var shellSafe = regexp.MustCompile(`^[A-Za-z0-9_@%+=:,./-]+$`) + +// shellQuote quotes s so the logged command can be pasted into a POSIX shell. +func shellQuote(s string) string { + if shellSafe.MatchString(s) { + return s + } + return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" +} + 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 e921b39..0913bdf 100644 --- a/get/get_test.go +++ b/get/get_test.go @@ -1,15 +1,19 @@ package get import ( + "bytes" + "context" "fmt" "net/url" "os" "path/filepath" "runtime" + "strings" "testing" "github.com/ldez/go-git-cmd-wrapper/v2/config" "github.com/ldez/go-git-cmd-wrapper/v2/git" + "github.com/ldez/go-git-cmd-wrapper/v2/types" ) func TestAbsolutePath(t *testing.T) { @@ -330,7 +334,7 @@ func TestClone(t *testing.T) { for name, c := range cases { t.Run(name, func(t *testing.T) { - path, err := Clone(c.url, c.expectedPath) + path, err := Clone(c.url, c.expectedPath, nil) if err != nil && !c.wantErr { t.Fatalf("unexpected error:\n\t(GOT): %#v\n\t(WNT): nil", err) @@ -343,6 +347,73 @@ func TestClone(t *testing.T) { } } +func TestVerboseOption(t *testing.T) { + plain := &url.URL{Scheme: "https", Host: "github.com", Path: "arbourd/git-get"} + + cases := map[string]struct { + url *url.URL + extra []string + wantLog string + }{ + "logs command and output": { + url: plain, + wantLog: "+ git ls-remote https://github.com/arbourd/git-get\n", + }, + "redacts password": { + url: &url.URL{Scheme: "https", User: url.UserPassword("user", "secret"), Host: "github.com", Path: "arbourd/git-get"}, + wantLog: "+ git ls-remote https://github.com/arbourd/git-get\n", + }, + "redacts token used as username": { + url: &url.URL{Scheme: "https", User: url.User("ghp_token123"), Host: "github.com", Path: "arbourd/git-get"}, + wantLog: "+ git ls-remote https://github.com/arbourd/git-get\n", + }, + "keeps ssh username so the logged command matches the one that ran": { + url: &url.URL{Scheme: "ssh", User: url.User("git"), Host: "github.com", Path: "arbourd/git-get"}, + wantLog: "+ git ls-remote ssh://git@github.com/arbourd/git-get\n", + }, + "redacts ssh password but keeps username": { + url: &url.URL{Scheme: "ssh", User: url.UserPassword("git", "secret"), Host: "github.com", Path: "arbourd/git-get"}, + wantLog: "+ git ls-remote ssh://github.com/arbourd/git-get\n", + }, + "quotes args with spaces": { + url: plain, + extra: []string{"/Users/me/my src/repo", "it's"}, + wantLog: "+ git ls-remote https://github.com/arbourd/git-get '/Users/me/my src/repo' 'it'\\''s'\n", + }, + } + + for name, c := range cases { + t.Run(name, func(t *testing.T) { + var log bytes.Buffer + var gotArgs []string + + cmd := types.NewCmd("ls-remote") + cmd.Executor = func(_ context.Context, _ string, _ bool, args ...string) (string, error) { + gotArgs = args + return "fake output\n", nil + } + cmd.AddOptions(c.url.String()) + for _, e := range c.extra { + cmd.AddOptions(e) + } + + verboseOption(&log, c.url)(cmd) + + if _, err := cmd.Exec(context.Background(), "git", false, cmd.Options...); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // the real command must still receive the unredacted URL + if len(gotArgs) < 2 || gotArgs[1] != c.url.String() { + t.Fatalf("executor received unexpected args: %#v", gotArgs) + } + if want := c.wantLog + "fake output\n"; log.String() != want { + t.Fatalf("unexpected log:\n\t(GOT): %q\n\t(WNT): %q", log.String(), want) + } + }) + } +} + func gitConfigGlobalFixture(t *testing.T) error { t.Helper() gitconfig := filepath.Join(t.TempDir(), ".gitconfig") @@ -368,3 +439,113 @@ func setupEnv(t *testing.T, gitConfigGetpath, envGetpath string) { } } } + +// TestCloneVerboseNoCredentialLeak runs the real git against an unreachable +// host to check that neither git's own output nor our log echoes credentials. +func TestCloneVerboseNoCredentialLeak(t *testing.T) { + if testing.Short() { + t.Skip("skipping test that spawns git") + } + + cases := map[string]struct { + url *url.URL + secrets []string + }{ + "https user and password": { + url: &url.URL{Scheme: "https", User: url.UserPassword("someuser", "s3cret"), Host: "127.0.0.1:1", Path: "o/r"}, + secrets: []string{"s3cret", "someuser"}, + }, + "https token as username": { + url: &url.URL{Scheme: "https", User: url.User("ghp_token123"), Host: "127.0.0.1:1", Path: "o/r"}, + secrets: []string{"ghp_token123"}, + }, + } + + for name, c := range cases { + t.Run(name, func(t *testing.T) { + if err := gitConfigGlobalFixture(t); err != nil { + t.Fatalf("setup: %v", err) + } + t.Setenv("GIT_TERMINAL_PROMPT", "0") + + var log bytes.Buffer + _, err := Clone(c.url, filepath.Join(t.TempDir(), "o", "r"), &log) + if err == nil { + t.Fatal("expected error cloning from an unreachable host") + } + + t.Logf("log:\n%s", log.String()) + for _, s := range c.secrets { + if bytes.Contains(log.Bytes(), []byte(s)) { + t.Errorf("log leaked %q", s) + } + if strings.Contains(err.Error(), s) { + t.Errorf("error leaked %q: %v", s, err) + } + } + }) + } +} + +func TestRedactRemote(t *testing.T) { + cases := map[string]struct{ in, want string }{ + "https user and password": {"https://user:pw@host/o/r", "https://host/o/r"}, + "https token username": {"https://ghp_tok@host/o/r", "https://host/o/r"}, + "no userinfo": {"https://host/o/r", "https://host/o/r"}, + "scp without password": {"git@host:o/r", "host:o/r"}, + "scp-like with password": {"user:pw@host:o/r", "host:o/r"}, + "scp-like password slash": {"user:pa/ss@host:o/r", "host:o/r"}, + "scp-like password @": {"user:p@ss@host:o/r", "host:o/r"}, + "https password slash": {"https://user:pa/ss@host/o/r", "https://host/o/r"}, + "https password @": {"https://user:p@ss@host/o/r", "https://host/o/r"}, + "@ in path": {"https://host/o/r@v1.2", "https://host/o/r@v1.2"}, + "option": {"--verbos", "--verbos"}, + } + for name, c := range cases { + t.Run(name, func(t *testing.T) { + if got := RedactRemote(c.in); got != c.want { + t.Fatalf("RedactRemote(%q) = %q, want %q", c.in, got, c.want) + } + }) + } +} + +func TestParseURLErrorKeepsReasonWithoutPassword(t *testing.T) { + _, err := ParseURL("ssh://git@host:22x/o/r") + if err == nil || !strings.Contains(err.Error(), `invalid port ":22x"`) { + t.Fatalf("want invalid port reason, got %v", err) + } +} + +func TestSecretsNeverPrinted(t *testing.T) { + tests := map[string]struct { + input string + secrets []string + }{ + "password with @": {"https://user:p@ssw0rd@host/o/r", []string{"ssw0rd", "user"}}, + "password with slash": {"https://user:pa/ssw0rd@host/o/r", []string{"ssw0rd", "user"}}, + "opaque with password": {"user:pw5ecret@host:o/r", []string{"pw5ecret", "user"}}, + "token as ssh username": {"ssh://ghp_tok3n:x@host/o/r", []string{"ghp_tok3n"}}, + "bad port with password": {"https://user:s3cret@host:badport/o/r", []string{"s3cret"}}, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + var printed []string + printed = append(printed, RedactRemote(tt.input)) + if u, err := ParseURL(tt.input); err != nil { + printed = append(printed, err.Error()) + } else { + printed = append(printed, redact(u).String()) + } + + for _, out := range printed { + for _, secret := range tt.secrets { + if strings.Contains(out, secret) { + t.Errorf("output %q leaks %q", out, secret) + } + } + } + }) + } +} diff --git a/main.go b/main.go index e53d89c..e20e31e 100644 --- a/main.go +++ b/main.go @@ -5,6 +5,7 @@ import ( "io" "os" "path/filepath" + "strings" "github.com/arbourd/git-get/get" ) @@ -12,7 +13,7 @@ import ( // Version is set via -ldflags at build time. var Version = "dev" -const usage = `Usage: git-get +const usage = `Usage: git-get [options] Clone a git repository to GETPATH (%s). @@ -21,28 +22,18 @@ Arguments: Options: -h, --help Show this help message - -v, --version Show version` + -v, --verbose Show the git commands being run and their output + --version Show version (also -v when no repository is given)` func main() { - if err := run(os.Args[1:], os.Stdout); err != nil { + if err := run(os.Args[1:], os.Stdout, os.Stderr); err != nil { fmt.Fprintf(os.Stderr, "error: %s\n", err) os.Exit(1) } } -func run(args []string, stdout io.Writer) error { - if len(args) == 0 { - return fmt.Errorf("no repository specified\n\n%s", buildUsage()) - } - - switch args[0] { - case "--help", "-h": - fmt.Fprintln(stdout, buildUsage()) - return nil - case "--version", "-v": - fmt.Fprintf(stdout, "git-get %s\n", Version) - return nil - case "--complete": +func run(args []string, stdout, stderr io.Writer) error { + if len(args) > 0 && args[0] == "--complete" { // Internal protocol used by shell completion scripts (completions/); not a user-facing flag. prefix := "" if len(args) > 1 { @@ -58,10 +49,51 @@ func run(args []string, stdout io.Writer) error { return nil } - return clone(args[0], stdout) + // Help and version win wherever they appear. A lone -v prints the version; + // alongside a repository it means --verbose. + for _, a := range args { + if a == "--" { + break + } + switch { + case a == "--help" || a == "-h": + fmt.Fprintln(stdout, buildUsage()) + return nil + case a == "--version" || (a == "-v" && len(args) == 1): + fmt.Fprintf(stdout, "git-get %s\n", Version) + return nil + } + } + + var remote string + var verbose io.Writer + positional := false + for _, a := range args { + switch { + case positional || !strings.HasPrefix(a, "-"): + if remote != "" { + return fmt.Errorf("too many arguments: %q\n\n%s", get.RedactRemote(a), buildUsage()) + } + remote = a + case a == "--": + positional = true + case a == "-v" || a == "--verbose": + if verbose != nil { + return fmt.Errorf("duplicate option %q\n\n%s", a, buildUsage()) + } + verbose = stderr + default: + return fmt.Errorf("unknown option %q\n\n%s", get.RedactRemote(a), buildUsage()) + } + } + if remote == "" { + return fmt.Errorf("no repository specified\n\n%s", buildUsage()) + } + + return clone(remote, stdout, verbose) } -func clone(remote string, stdout io.Writer) error { +func clone(remote string, stdout, verbose io.Writer) error { path, err := get.AbsolutePath() if err != nil { return fmt.Errorf("resolving GETPATH: %w", err) @@ -69,16 +101,16 @@ func clone(remote string, stdout io.Writer) error { url, err := get.ParseURL(remote) if err != nil { - return fmt.Errorf("unable to parse repository url %q: %w", remote, err) + return fmt.Errorf("unable to parse repository url %q: %w", get.RedactRemote(remote), err) } relDir, err := get.Directory(url) if err != nil { - return fmt.Errorf("unable to determine directory for url %q: %w", remote, err) + return fmt.Errorf("unable to determine directory for url %q: %w", get.RedactRemote(remote), err) } dir := filepath.Join(path, relDir) - result, err := get.Clone(url, dir) + result, err := get.Clone(url, dir, verbose) if err != nil { return fmt.Errorf("cloning repository: %w", err) } diff --git a/main_test.go b/main_test.go index 2d78f72..7204276 100644 --- a/main_test.go +++ b/main_test.go @@ -3,19 +3,24 @@ package main import ( "bytes" "fmt" + "io" "os" "path/filepath" "strings" "testing" + + "github.com/ldez/go-git-cmd-wrapper/v2/git" + "github.com/ldez/go-git-cmd-wrapper/v2/types" ) func TestRun(t *testing.T) { cases := map[string]struct { - args []string - wantStdout string - wantRunErr bool - wantErrContains string - setup func(t *testing.T) + args []string + wantStdout string + wantRunErr bool + wantErrContains string + wantErrNotContains string + setup func(t *testing.T) }{ "no args": { args: []string{}, @@ -38,6 +43,81 @@ func TestRun(t *testing.T) { args: []string{"-v"}, wantStdout: "git-get testversion\n", }, + "--verbose without repository": { + args: []string{"--verbose"}, + wantRunErr: true, + wantErrContains: "no repository specified", + }, + "-v with repository is not version": { + args: []string{"-v", "github.com/arbourd/git-get"}, + wantStdout: filepath.FromSlash("github.com/arbourd/git-get\n"), + setup: setupGetpath, + }, + "--verbose after repository": { + args: []string{"github.com/arbourd/git-get", "--verbose"}, + wantStdout: filepath.FromSlash("github.com/arbourd/git-get\n"), + setup: setupGetpath, + }, + "--help after -v": { + args: []string{"-v", "--help"}, + wantStdout: "Usage: git-get", + }, + "-h after repository": { + args: []string{"github.com/arbourd/git-get", "-h"}, + wantStdout: "Usage: git-get", + }, + "--version after --verbose": { + args: []string{"--verbose", "--version"}, + wantStdout: "testversion\n", + }, + "unknown option first": { + args: []string{"-vv", "github.com/arbourd/git-get"}, + wantRunErr: true, + wantErrContains: `unknown option "-vv"`, + }, + "unknown option last": { + args: []string{"github.com/arbourd/git-get", "--verbos"}, + wantRunErr: true, + wantErrContains: `unknown option "--verbos"`, + }, + "duplicate -v": { + args: []string{"-v", "-v"}, + wantRunErr: true, + wantErrContains: `duplicate option "-v"`, + }, + "duplicate -v and --verbose": { + args: []string{"-v", "--verbose", "github.com/arbourd/git-get"}, + wantRunErr: true, + wantErrContains: `duplicate option "--verbose"`, + }, + "too many arguments": { + args: []string{"github.com/arbourd/git-get", "github.com/arbourd/other"}, + wantRunErr: true, + wantErrContains: "too many arguments", + }, + "too many arguments redacts credentials": { + args: []string{"github.com/arbourd/git-get", "https://user:s3cret@host/o/r"}, + wantRunErr: true, + wantErrContains: "too many arguments", + wantErrNotContains: "s3cret", + }, + "unknown option redacts credentials": { + args: []string{"-https://user:s3cret@host/o/r"}, + wantRunErr: true, + wantErrContains: "unknown option", + wantErrNotContains: "s3cret", + }, + "unparseable url redacts credentials": { + args: []string{"https://user:s3cret@host:badport/o/r"}, + wantRunErr: true, + wantErrContains: "unable to parse repository url", + wantErrNotContains: "s3cret", + }, + "-- ends options": { + args: []string{"--", "github.com/arbourd/git-get"}, + wantStdout: filepath.FromSlash("github.com/arbourd/git-get\n"), + setup: setupGetpath, + }, "--complete empty prefix": { args: []string{"--complete"}, wantStdout: "github.com/\n", @@ -66,7 +146,7 @@ func TestRun(t *testing.T) { } var stdout bytes.Buffer - err := run(c.args, &stdout) + err := run(c.args, &stdout, io.Discard) if c.wantRunErr && err == nil { t.Fatal("expected run() to return an error, got nil") @@ -78,6 +158,10 @@ func TestRun(t *testing.T) { t.Fatalf("unexpected error:\n\t(GOT): %v\n\t(WNT): contains %q", err, c.wantErrContains) } + if c.wantErrNotContains != "" && err != nil && strings.Contains(err.Error(), c.wantErrNotContains) { + t.Fatalf("error leaked %q: %v", c.wantErrNotContains, err) + } + 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) } @@ -88,6 +172,80 @@ func TestRun(t *testing.T) { } } +// TestRunVerbose clones from a local bare repository, mapped from a fake host +// with url.insteadOf, to check that git's commands and output reach stderr. +func TestRunVerbose(t *testing.T) { + cases := map[string]struct { + args []string + preclone bool + wantStderr []string + notWantStderr []string + }{ + "-v": { + args: []string{"-v", "example.test/o/r"}, + wantStderr: []string{"+ git ls-remote https://example.test/o/r HEAD\n", "+ git clone https://example.test/o/r ", "Cloning into"}, + }, + "--verbose after repository": { + args: []string{"example.test/o/r", "--verbose"}, + wantStderr: []string{"+ git ls-remote https://example.test/o/r HEAD\n", "+ git clone https://example.test/o/r "}, + }, + "quiet by default": { + args: []string{"example.test/o/r"}, + }, + "already cloned": { + args: []string{"-v", "example.test/o/r"}, + preclone: true, + wantStderr: []string{"already cloned: "}, + notWantStderr: []string{"+ git"}, + }, + } + + for name, c := range cases { + t.Run(name, func(t *testing.T) { + if err := gitConfigGlobalFixture(t); err != nil { + t.Fatalf("setup: %v", err) + } + + remotes := t.TempDir() + if _, err := git.Raw("init", func(g *types.Cmd) { + g.AddOptions("--bare") + g.AddOptions(filepath.Join(remotes, "o", "r")) + }); err != nil { + t.Fatalf("setup: git init: %v", err) + } + // file:///C:/... on Windows, file:///tmp/... elsewhere. + remoteURL := "file:///" + strings.TrimPrefix(filepath.ToSlash(remotes), "/") + "/" + config := fmt.Sprintf("[url %q]\n\tinsteadOf = https://example.test/\n", remoteURL) + if err := os.WriteFile(os.Getenv("GIT_CONFIG_GLOBAL"), []byte(config), 0o600); err != nil { + t.Fatalf("setup: %v", err) + } + + getpath := t.TempDir() + t.Setenv("GETPATH", getpath) + + if c.preclone { + if err := run([]string{"example.test/o/r"}, io.Discard, io.Discard); err != nil { + t.Fatalf("setup: initial clone: %v", err) + } + } + + var stdout, stderr bytes.Buffer + if err := run(c.args, &stdout, &stderr); err != nil { + t.Fatalf("unexpected error: %v\nstderr: %s", err, stderr.String()) + } + + want := filepath.Join(getpath, "example.test", "o", "r") + "\n" + if stdout.String() != want { + t.Fatalf("unexpected stdout:\n\t(GOT): %q\n\t(WNT): %q", stdout.String(), want) + } + if len(c.wantStderr) == 0 && stderr.Len() > 0 { + t.Fatalf("expected no stderr, got: %q", stderr.String()) + } + assertContains(t, stderr.String(), c.wantStderr, c.notWantStderr) + }) + } +} + func gitConfigGlobalFixture(t *testing.T) error { t.Helper() gitconfig := filepath.Join(t.TempDir(), ".gitconfig") diff --git a/man/git-get.1 b/man/git-get.1 index a66ebad..6388ac8 100644 --- a/man/git-get.1 +++ b/man/git-get.1 @@ -28,8 +28,13 @@ exits without re-cloning. .BR \-h ", " \-\-help Print usage information and exit. .TP -.BR \-v ", " \-\-version +.BR \-v ", " \-\-verbose +Print each git command that is run, and its output, to standard error. +.TP +.B \-\-version Print the version and exit. +.B \-v +alone (without a repository) also prints the version. .SH ARGUMENTS .TP .I repository