diff --git a/AGENTS.md b/AGENTS.md index 1ce4a71..e517479 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,10 +18,13 @@ not repeated here. repository on disk for the profile it actually carries. - `internal/gitconfig/`: renders the profile files and the include file `apply` writes; it never writes a file itself. -- `internal/shell/`: the per shell init snippet `shell-init` prints. +- `internal/shell/`: the per shell init snippet `shell-init` prints, including the `cd` tab + completion registered for zsh and fish. +- `internal/scan/`: walks the configured root into the repository values `list` and `cd` + consume, stopping at a repository boundary, a `.git` entry, file or directory alike. -`list`, `cd`, `doctor`, `adopt`, `status` and `sync` are later milestones, tracked as issues; -their own packages land with them. +`doctor`, `adopt`, `status` and `sync` are later milestones, tracked as issues; their own +packages land with them. ## Commands @@ -41,7 +44,7 @@ task run -- clone github.com/acme/widget # run the CLI from source dependency needs a reason in the pull request. - No CLI framework. `flag` and hand written dispatch, as in `release-me`. - A command writes nothing but its result to stdout; everything else, including every log - line, goes to stderr. `clone` and `path` print the resolved path on stdout, so a shell + line, goes to stderr. `clone`, `path` and `cd` print the resolved path on stdout, so a shell function can wrap them. - Every command that writes supports `--dry-run`. - Tests: table driven, git repositories generated under a temporary directory, `HOME`, diff --git a/cmd/gitcd/cd.go b/cmd/gitcd/cd.go new file mode 100644 index 0000000..095bec7 --- /dev/null +++ b/cmd/gitcd/cd.go @@ -0,0 +1,211 @@ +// Copyright 2026 Simon Ostendorf +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "bufio" + "context" + "flag" + "fmt" + "io" + "log/slog" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + + "github.com/ostendorf-cloud/gitcd/internal/config" + "github.com/ostendorf-cloud/gitcd/internal/gitconfig" + "github.com/ostendorf-cloud/gitcd/internal/scan" +) + +// cdCandidate is one repository "gitcd cd" can jump to: its absolute path +// together with the two strings a query is matched against. A linked +// worktree is a candidate exactly like any other managed repository: "cd" +// never hides one, unlike "list", because jumping to one is the whole +// point of having it. +type cdCandidate struct { + path string + // rel is path relative to the configured root, "/" separated + // regardless of host OS, so a query like "acme/widget" disambiguates + // by owner. + rel string + // name is rel's final segment, the repository directory's own name. + name string +} + +// runCd implements "gitcd cd ": it resolves query against every +// managed repository and prints the match on stdout. stdin and isTerminal +// are threaded through explicitly, rather than read from os.Stdin inside, +// so a test can exercise the interactive picker without a real terminal. +func runCd(args []string, stdin io.Reader, isTerminal bool, stdout, stderr io.Writer) int { + fset := flag.NewFlagSet("gitcd cd", flag.ContinueOnError) + fset.SetOutput(stderr) + verbose := fset.Bool("verbose", false, "enable debug logging on stderr") + if err := fset.Parse(args); err != nil { + return 2 + } + if fset.NArg() != 1 { + fmt.Fprintln(stderr, "gitcd cd: exactly one query is required") + fset.Usage() + return 2 + } + query := fset.Arg(0) + logger := newLogger(*verbose, stderr) + ctx := context.Background() + + cfg, err := config.Load() + if err != nil { + fmt.Fprintf(stderr, "gitcd cd: %v\n", err) + return 1 + } + home, err := os.UserHomeDir() + if err != nil { + fmt.Fprintf(stderr, "gitcd cd: %v\n", err) + return 1 + } + + candidates, err := cdCandidates(ctx, cfg, gitconfig.Dir(home), logger) + if err != nil { + fmt.Fprintf(stderr, "gitcd cd: %v\n", err) + return 1 + } + + match, err := resolveQuery(query, candidates, stdin, isTerminal, stderr) + if err != nil { + fmt.Fprintf(stderr, "gitcd cd: %v\n", err) + return 1 + } + + fmt.Fprintln(stdout, match.path) + return 0 +} + +// cdCandidates scans cfg.Root into every cdCandidate "cd" may resolve a +// query to, skipping, with a warning on stderr through logger, a +// repository scan.Walk could not inspect. +func cdCandidates(ctx context.Context, cfg config.Config, managedDir string, logger *slog.Logger) ([]cdCandidate, error) { + results, err := scan.Walk(ctx, cfg.Root, cfg, managedDir) + if err != nil { + return nil, fmt.Errorf("cd: %w", err) + } + + candidates := make([]cdCandidate, 0, len(results)) + for _, res := range results { + if res.Err != nil { + logger.Warn("skipped a repository scan could not inspect", "path", res.Repo.Path, "error", res.Err) + continue + } + rel, err := filepath.Rel(cfg.Root, res.Repo.Path) + if err != nil { + rel = res.Repo.Path + } + rel = filepath.ToSlash(rel) + candidates = append(candidates, cdCandidate{ + path: res.Repo.Path, + rel: rel, + name: filepath.Base(rel), + }) + } + sort.Slice(candidates, func(i, j int) bool { return candidates[i].rel < candidates[j].rel }) + return candidates, nil +} + +// resolveQuery resolves query against candidates in two tiers. +// +// An exact match, against a candidate's absolute path, its path relative +// to root, or its final name segment, case insensitively, always wins +// outright, even when query also substring-matches other candidates: a +// query that spells out a full name should never fall into a picker just +// because a longer, unrelated path happens to contain it. When more than +// one candidate matches exactly, a name shared by two different +// repositories under different hosts or owners, that exact set becomes the +// ambiguous candidates rather than falling back further. +// +// Lacking any exact match, every candidate whose relative path contains +// query as a case insensitive substring, anywhere, is a fuzzy match: a +// plain, predictable rule a shell function's caller can reason about +// without seeing the matching code, deliberately simpler than a scored +// subsequence match. +// +// Zero matches is an error naming query. More than one is resolved by +// pick. +func resolveQuery(query string, candidates []cdCandidate, stdin io.Reader, isTerminal bool, stderr io.Writer) (cdCandidate, error) { + var exact []cdCandidate + for _, c := range candidates { + if c.path == query || strings.EqualFold(c.rel, query) || strings.EqualFold(c.name, query) { + exact = append(exact, c) + } + } + + pool := exact + if len(pool) == 0 { + lower := strings.ToLower(query) + for _, c := range candidates { + if strings.Contains(strings.ToLower(c.rel), lower) { + pool = append(pool, c) + } + } + } + + switch len(pool) { + case 0: + return cdCandidate{}, fmt.Errorf("no repository matches %q", query) + case 1: + return pool[0], nil + default: + return pick(query, pool, stdin, isTerminal, stderr) + } +} + +// pick prints candidates as a numbered list on stderr. With a terminal on +// stdin, it prompts for and reads a selection from stdin, returning an +// error on an empty read or an out of range choice rather than +// re-prompting, predictable over clever. Without a terminal, it returns an +// error naming query without reading anything: the candidates are already +// listed on stderr by the time that error is returned. +func pick(query string, candidates []cdCandidate, stdin io.Reader, isTerminal bool, stderr io.Writer) (cdCandidate, error) { + fmt.Fprintf(stderr, "%q matches more than one repository:\n", query) + for i, c := range candidates { + fmt.Fprintf(stderr, " %d) %s\n", i+1, c.path) + } + + if !isTerminal { + return cdCandidate{}, fmt.Errorf("more than one repository matches %q, and stdin is not a terminal to pick one", query) + } + + fmt.Fprintf(stderr, "pick one [1-%d]: ", len(candidates)) + scanner := bufio.NewScanner(stdin) + if !scanner.Scan() { + // isTerminal came from isTerminalFile, which cannot tell a real + // terminal that produced no input from a character device such + // as /dev/null or /dev/zero that was never a terminal at all: an + // empty read here honestly could be either, so the message + // names both rather than asserting the one isTerminal claimed. + return cdCandidate{}, fmt.Errorf("no selection was read for %q: stdin produced no input, which also happens when it is not actually an interactive terminal", query) + } + choice := strings.TrimSpace(scanner.Text()) + idx, err := strconv.Atoi(choice) + if err != nil || idx < 1 || idx > len(candidates) { + return cdCandidate{}, fmt.Errorf("%q is not a valid choice between 1 and %d", choice, len(candidates)) + } + return candidates[idx-1], nil +} + +// isTerminalFile reports whether f is likely connected to an interactive +// terminal, tested as a character device rather than a pipe or a +// redirected regular file. It is the standard library only way to detect +// this, avoiding a dependency on golang.org/x/term for the one bit "cd" +// needs from it. The test is not exact: /dev/null and /dev/zero are +// themselves character devices, so a script that redirects stdin from +// either is misread as a terminal. pick's own failure message stays +// honest about that rather than claiming stdin definitely is not one. +func isTerminalFile(f *os.File) bool { + info, err := f.Stat() + if err != nil { + return false + } + return info.Mode()&os.ModeCharDevice != 0 +} diff --git a/cmd/gitcd/cd_test.go b/cmd/gitcd/cd_test.go new file mode 100644 index 0000000..edb7666 --- /dev/null +++ b/cmd/gitcd/cd_test.go @@ -0,0 +1,257 @@ +// Copyright 2026 Simon Ostendorf +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestRunCd_ResolvesAUniqueFuzzyMatch(t *testing.T) { + home := setupEnv(t) + root := filepath.Join(home, "Code") + + dest := filepath.Join(root, "github.com", "acme", "widget") + initRepository(t, dest) + addRemote(t, dest, "https://github.com/acme/widget.git") + + var stdout, stderr bytes.Buffer + code := run([]string{"cd", "widg"}, &stdout, &stderr) + + if code != 0 { + t.Fatalf("cd widg = %d, want 0, stderr: %s", code, stderr.String()) + } + if got := strings.TrimSpace(stdout.String()); got != dest { + t.Fatalf("cd widg stdout = %q, want %q", got, dest) + } +} + +func TestRunCd_ExactNameWinsOverABroaderFuzzyMatch(t *testing.T) { + home := setupEnv(t) + root := filepath.Join(home, "Code") + + // "widget" is an exact name match; "widget-extra"'s relative path + // also contains the substring "widget" and would otherwise tie. + exact := filepath.Join(root, "github.com", "acme", "widget") + initRepository(t, exact) + addRemote(t, exact, "https://github.com/acme/widget.git") + + longer := filepath.Join(root, "github.com", "acme", "widget-extra") + initRepository(t, longer) + addRemote(t, longer, "https://github.com/acme/widget-extra.git") + + var stdout, stderr bytes.Buffer + code := run([]string{"cd", "widget"}, &stdout, &stderr) + + if code != 0 { + t.Fatalf("cd widget = %d, want 0, stderr: %s", code, stderr.String()) + } + if got := strings.TrimSpace(stdout.String()); got != exact { + t.Fatalf("cd widget stdout = %q, want the exact name match %q", got, exact) + } +} + +func TestRunCd_IncludesALinkedWorktreeAsACandidate(t *testing.T) { + home := setupEnv(t) + root := filepath.Join(home, "Code") + + main := filepath.Join(root, "github.com", "acme", "widget") + initRepository(t, main) + addRemote(t, main, "https://github.com/acme/widget.git") + worktree := filepath.Join(root, "github.com", "acme", "widget-feature-x") + runGit(t, main, "worktree", "add", worktree) + + var stdout, stderr bytes.Buffer + code := run([]string{"cd", "feature-x"}, &stdout, &stderr) + + if code != 0 { + t.Fatalf("cd feature-x = %d, want 0, stderr: %s", code, stderr.String()) + } + if got := strings.TrimSpace(stdout.String()); got != worktree { + t.Fatalf("cd feature-x stdout = %q, want %q: cd must reach a linked worktree even though list hides it by default", got, worktree) + } +} + +func TestRunCd_NoMatchFailsNamingTheQuery(t *testing.T) { + setupEnv(t) + + var stdout, stderr bytes.Buffer + code := run([]string{"cd", "nowhere"}, &stdout, &stderr) + + if code == 0 { + t.Fatal("cd nowhere = 0, want a failure for a query that matches nothing") + } + if stdout.Len() != 0 { + t.Fatalf("cd nowhere wrote to stdout: %q", stdout.String()) + } + if !strings.Contains(stderr.String(), "nowhere") { + t.Fatalf("cd nowhere stderr = %q, want it to name the query", stderr.String()) + } +} + +func TestRunCd_AmbiguousQueryWithoutATerminalFailsListingEveryCandidate(t *testing.T) { + home := setupEnv(t) + root := filepath.Join(home, "Code") + + first := filepath.Join(root, "github.com", "acme", "widget") + second := filepath.Join(root, "gitlab.com", "acme", "widget") + initRepository(t, first) + addRemote(t, first, "https://github.com/acme/widget.git") + initRepository(t, second) + addRemote(t, second, "https://gitlab.com/acme/widget.git") + + var stdout, stderr bytes.Buffer + // stdin carries a perfectly valid choice, "1", deliberately: with + // isTerminal false, that choice must never be read at all, since a + // script piping a plain file or command output into "gitcd cd" has no + // business being treated as an interactive pick. If the terminal + // guard were ever removed, this would read "1" and succeed instead + // of failing, which is exactly what this test must catch. + code := runCd([]string{"widget"}, strings.NewReader("1\n"), false, &stdout, &stderr) + + if code == 0 { + t.Fatal("cd widget = 0, want a failure for an ambiguous query with no terminal") + } + if stdout.Len() != 0 { + t.Fatalf("cd widget wrote to stdout: %q, want nothing: a non-terminal stdin must never be read for a pick", stdout.String()) + } + if !strings.Contains(stderr.String(), first) || !strings.Contains(stderr.String(), second) { + t.Fatalf("cd widget stderr = %q, want it to list both candidates", stderr.String()) + } +} + +func TestRunCd_AmbiguousQueryWithATerminalReadsAPickFromStdin(t *testing.T) { + home := setupEnv(t) + root := filepath.Join(home, "Code") + + first := filepath.Join(root, "github.com", "acme", "widget") + second := filepath.Join(root, "gitlab.com", "acme", "widget") + initRepository(t, first) + addRemote(t, first, "https://github.com/acme/widget.git") + initRepository(t, second) + addRemote(t, second, "https://gitlab.com/acme/widget.git") + + var stdout, stderr bytes.Buffer + // isTerminal is forced true here: the point under test is the picker + // reading a numbered choice from whatever reader it is given, not + // real terminal detection, which isTerminalFile already covers on its + // own. + code := runCd([]string{"widget"}, strings.NewReader("2\n"), true, &stdout, &stderr) + + if code != 0 { + t.Fatalf("cd widget = %d, want 0 once a valid pick is read, stderr: %s", code, stderr.String()) + } + got := strings.TrimSpace(stdout.String()) + // The two candidates sort by their path relative to root ("github.com/..." + // before "gitlab.com/..."), so choice "2" is the second, gitlab.com one. + if got != second { + t.Fatalf("cd widget stdout = %q, want the second listed candidate %q", got, second) + } + if !strings.Contains(stderr.String(), "1) "+first) || !strings.Contains(stderr.String(), "2) "+second) { + t.Fatalf("cd widget stderr = %q, want a numbered picker listing both candidates", stderr.String()) + } +} + +// TestRunCd_AmbiguousQueryWithATerminalButNoInputFailsWithAnHonestMessage +// covers the case isTerminalFile cannot tell apart: stdin redirected from +// /dev/null or /dev/zero is a character device, so isTerminalFile = true +// is correct by its own test, but it is not an interactive terminal, so no +// selection is ever read. Before the fix, the failure claimed "no +// selection was read" as if a real terminal had simply been given nothing, +// which is not what happened here: stdin never was a terminal to begin +// with. The message must say so is possible instead of asserting the +// wrong certainty. +func TestRunCd_AmbiguousQueryWithATerminalButNoInputFailsWithAnHonestMessage(t *testing.T) { + home := setupEnv(t) + root := filepath.Join(home, "Code") + + first := filepath.Join(root, "github.com", "acme", "widget") + second := filepath.Join(root, "gitlab.com", "acme", "widget") + initRepository(t, first) + addRemote(t, first, "https://github.com/acme/widget.git") + initRepository(t, second) + addRemote(t, second, "https://gitlab.com/acme/widget.git") + + var stdout, stderr bytes.Buffer + // isTerminal is forced true, as isTerminalFile would wrongly report + // for /dev/null or /dev/zero, while stdin itself carries no input at + // all, exactly what reading from either of those produces. + code := runCd([]string{"widget"}, strings.NewReader(""), true, &stdout, &stderr) + + if code == 0 { + t.Fatal("cd widget = 0, want a failure when no selection can be read") + } + if stdout.Len() != 0 { + t.Fatalf("cd widget wrote to stdout: %q", stdout.String()) + } + if !strings.Contains(stderr.String(), "not actually an interactive terminal") { + t.Fatalf("cd widget stderr = %q, want it to admit stdin may not actually be a terminal rather than assuming it is one", stderr.String()) + } +} + +func TestRunCd_InvalidPickFails(t *testing.T) { + home := setupEnv(t) + root := filepath.Join(home, "Code") + + first := filepath.Join(root, "github.com", "acme", "widget") + second := filepath.Join(root, "gitlab.com", "acme", "widget") + initRepository(t, first) + addRemote(t, first, "https://github.com/acme/widget.git") + initRepository(t, second) + addRemote(t, second, "https://gitlab.com/acme/widget.git") + + var stdout, stderr bytes.Buffer + code := runCd([]string{"widget"}, strings.NewReader("9\n"), true, &stdout, &stderr) + + if code == 0 { + t.Fatal("cd widget = 0, want a failure for a choice out of range") + } + if stdout.Len() != 0 { + t.Fatalf("cd widget wrote to stdout: %q", stdout.String()) + } +} + +func TestRunCd_RequiresExactlyOneArgument(t *testing.T) { + setupEnv(t) + + var stdout, stderr bytes.Buffer + code := run([]string{"cd"}, &stdout, &stderr) + + if code != 2 { + t.Fatalf("cd with no argument = %d, want 2", code) + } +} + +// TestIsTerminalFile_APipeIsNotATerminal exercises the real terminal +// detection runCd wires to os.Stdin, rather than the isTerminal bool the +// other tests in this file inject directly. A pipe is the realistic shape +// of "gitcd cd" being piped into from a script or another command, the +// case the acceptance criterion means by "without a terminal". Detecting +// an actual terminal needs a real pty, which the standard library alone +// cannot allocate, so that side is covered indirectly: isTerminalFile +// returning false for the one kind of file descriptor this test can +// construct is the primary path this function has. +func TestIsTerminalFile_APipeIsNotATerminal(t *testing.T) { + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("create a pipe: %v", err) + } + defer func() { + if err := r.Close(); err != nil { + t.Fatalf("close the pipe's read end: %v", err) + } + }() + defer func() { + if err := w.Close(); err != nil { + t.Fatalf("close the pipe's write end: %v", err) + } + }() + + if isTerminalFile(r) { + t.Fatal("isTerminalFile(pipe) = true, want false") + } +} diff --git a/cmd/gitcd/list.go b/cmd/gitcd/list.go new file mode 100644 index 0000000..70bbf9a --- /dev/null +++ b/cmd/gitcd/list.go @@ -0,0 +1,111 @@ +// Copyright 2026 Simon Ostendorf +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "io" + "log/slog" + "os" + "sort" + + "github.com/ostendorf-cloud/gitcd/internal/config" + "github.com/ostendorf-cloud/gitcd/internal/gitconfig" + "github.com/ostendorf-cloud/gitcd/internal/scan" +) + +// listEntry is one repository "gitcd list" reports, the same shape for +// plain text and "--json". +type listEntry struct { + Path string `json:"path"` + Remote string `json:"remote,omitempty"` + Worktree bool `json:"worktree"` +} + +// runList implements "gitcd list": it prints every repository scan.Walk +// finds under the configured root, one path per line, hiding a linked +// worktree unless "--worktrees" is given. "--json" prints the same +// repositories as a JSON array instead of plain text. +func runList(args []string, stdout, stderr io.Writer) int { + fset := flag.NewFlagSet("gitcd list", flag.ContinueOnError) + fset.SetOutput(stderr) + jsonOut := fset.Bool("json", false, "print the result as a JSON array instead of plain text") + withWorktrees := fset.Bool("worktrees", false, "include a linked worktree, hidden by default") + verbose := fset.Bool("verbose", false, "enable debug logging on stderr") + if err := fset.Parse(args); err != nil { + return 2 + } + if fset.NArg() != 0 { + fmt.Fprintln(stderr, "gitcd list: no arguments are accepted") + fset.Usage() + return 2 + } + logger := newLogger(*verbose, stderr) + ctx := context.Background() + + cfg, err := config.Load() + if err != nil { + fmt.Fprintf(stderr, "gitcd list: %v\n", err) + return 1 + } + home, err := os.UserHomeDir() + if err != nil { + fmt.Fprintf(stderr, "gitcd list: %v\n", err) + return 1 + } + + entries, err := listEntries(ctx, cfg, gitconfig.Dir(home), *withWorktrees, logger) + if err != nil { + fmt.Fprintf(stderr, "gitcd list: %v\n", err) + return 1 + } + + if *jsonOut { + enc := json.NewEncoder(stdout) + enc.SetIndent("", " ") + if err := enc.Encode(entries); err != nil { + fmt.Fprintf(stderr, "gitcd list: %v\n", err) + return 1 + } + return 0 + } + + for _, e := range entries { + fmt.Fprintln(stdout, e.Path) + } + return 0 +} + +// listEntries scans cfg.Root and reports one listEntry per repository scan.Walk +// found, skipping a linked worktree unless withWorktrees is set and +// skipping, with a warning on stderr through logger, a repository +// scan.Walk could not inspect, most commonly one with no configured +// remote. +func listEntries(ctx context.Context, cfg config.Config, managedDir string, withWorktrees bool, logger *slog.Logger) ([]listEntry, error) { + results, err := scan.Walk(ctx, cfg.Root, cfg, managedDir) + if err != nil { + return nil, fmt.Errorf("list: %w", err) + } + + entries := make([]listEntry, 0, len(results)) + for _, res := range results { + if res.Err != nil { + logger.Warn("skipped a repository scan could not inspect", "path", res.Repo.Path, "error", res.Err) + continue + } + if res.Repo.IsWorktree && !withWorktrees { + continue + } + entries = append(entries, listEntry{ + Path: res.Repo.Path, + Remote: res.Repo.RemoteURL, + Worktree: res.Repo.IsWorktree, + }) + } + sort.Slice(entries, func(i, j int) bool { return entries[i].Path < entries[j].Path }) + return entries, nil +} diff --git a/cmd/gitcd/list_test.go b/cmd/gitcd/list_test.go new file mode 100644 index 0000000..928263f --- /dev/null +++ b/cmd/gitcd/list_test.go @@ -0,0 +1,145 @@ +// Copyright 2026 Simon Ostendorf +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "bytes" + "encoding/json" + "path/filepath" + "strings" + "testing" +) + +func TestRunList_PrintsEveryRepositoryUnderRoot(t *testing.T) { + home := setupEnv(t) + root := filepath.Join(home, "Code") + + first := filepath.Join(root, "github.com", "acme", "widget") + second := filepath.Join(root, "gitlab.com", "acme", "other") + initRepository(t, first) + addRemote(t, first, "https://github.com/acme/widget.git") + initRepository(t, second) + addRemote(t, second, "https://gitlab.com/acme/other.git") + + var stdout, stderr bytes.Buffer + code := run([]string{"list"}, &stdout, &stderr) + + if code != 0 { + t.Fatalf("list = %d, want 0, stderr: %s", code, stderr.String()) + } + lines := strings.Fields(strings.TrimSpace(stdout.String())) + if len(lines) != 2 || lines[0] != first || lines[1] != second { + t.Fatalf("list stdout = %v, want %v", lines, []string{first, second}) + } +} + +func TestRunList_HidesALinkedWorktreeByDefault(t *testing.T) { + home := setupEnv(t) + root := filepath.Join(home, "Code") + + main := filepath.Join(root, "github.com", "acme", "widget") + initRepository(t, main) + addRemote(t, main, "https://github.com/acme/widget.git") + worktree := filepath.Join(root, "github.com", "acme", "widget-feature") + runGit(t, main, "worktree", "add", worktree) + + var stdout, stderr bytes.Buffer + code := run([]string{"list"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("list = %d, want 0, stderr: %s", code, stderr.String()) + } + got := strings.TrimSpace(stdout.String()) + if got != main { + t.Fatalf("list stdout = %q, want only %q, a linked worktree must be hidden by default", got, main) + } +} + +func TestRunList_WorktreesFlagIncludesALinkedWorktree(t *testing.T) { + home := setupEnv(t) + root := filepath.Join(home, "Code") + + main := filepath.Join(root, "github.com", "acme", "widget") + initRepository(t, main) + addRemote(t, main, "https://github.com/acme/widget.git") + worktree := filepath.Join(root, "github.com", "acme", "widget-feature") + runGit(t, main, "worktree", "add", worktree) + + var stdout, stderr bytes.Buffer + code := run([]string{"list", "--worktrees"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("list --worktrees = %d, want 0, stderr: %s", code, stderr.String()) + } + lines := strings.Fields(strings.TrimSpace(stdout.String())) + if len(lines) != 2 || lines[0] != main || lines[1] != worktree { + t.Fatalf("list --worktrees stdout = %v, want %v", lines, []string{main, worktree}) + } +} + +func TestRunList_JSONReportsTheSameRepositoriesStructured(t *testing.T) { + home := setupEnv(t) + root := filepath.Join(home, "Code") + + main := filepath.Join(root, "github.com", "acme", "widget") + initRepository(t, main) + addRemote(t, main, "https://github.com/acme/widget.git") + worktree := filepath.Join(root, "github.com", "acme", "widget-feature") + runGit(t, main, "worktree", "add", worktree) + + var stdout, stderr bytes.Buffer + code := run([]string{"list", "--worktrees", "--json"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("list --worktrees --json = %d, want 0, stderr: %s", code, stderr.String()) + } + + var entries []listEntry + if err := json.Unmarshal(stdout.Bytes(), &entries); err != nil { + t.Fatalf("list --worktrees --json did not print valid JSON: %v: %s", err, stdout.String()) + } + if len(entries) != 2 { + t.Fatalf("list --worktrees --json = %d entries, want 2", len(entries)) + } + byPath := map[string]listEntry{} + for _, e := range entries { + byPath[e.Path] = e + } + if e, ok := byPath[main]; !ok || e.Worktree { + t.Fatalf("list --worktrees --json entry for %s = %+v, want worktree=false", main, e) + } + if e, ok := byPath[worktree]; !ok || !e.Worktree { + t.Fatalf("list --worktrees --json entry for %s = %+v, want worktree=true", worktree, e) + } +} + +func TestRunList_SkipsARepositoryWithNoRemoteRatherThanFailing(t *testing.T) { + home := setupEnv(t) + root := filepath.Join(home, "Code") + + withRemote := filepath.Join(root, "github.com", "acme", "widget") + initRepository(t, withRemote) + addRemote(t, withRemote, "https://github.com/acme/widget.git") + + noRemote := filepath.Join(root, "local-only") + initRepository(t, noRemote) + + var stdout, stderr bytes.Buffer + code := run([]string{"list"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("list = %d, want 0, stderr: %s", code, stderr.String()) + } + got := strings.TrimSpace(stdout.String()) + if got != withRemote { + t.Fatalf("list stdout = %q, want only %q", got, withRemote) + } +} + +func TestRunList_NoArgumentsAccepted(t *testing.T) { + setupEnv(t) + + var stdout, stderr bytes.Buffer + code := run([]string{"list", "extra"}, &stdout, &stderr) + + if code != 2 { + t.Fatalf("list extra = %d, want 2", code) + } +} diff --git a/cmd/gitcd/main.go b/cmd/gitcd/main.go index fd7c7cb..c0c0d5f 100644 --- a/cmd/gitcd/main.go +++ b/cmd/gitcd/main.go @@ -24,6 +24,8 @@ Usage: Commands: clone clone a repository into its rendered path, printing the path path resolve a repository name or host/owner/repo to its path + list list every repository under the configured root + cd resolve a partial repository name to its path, printing it init add the identity profile include to the global git config apply render and write the identity profile files profile pin, remove or inspect a repository's identity profile @@ -50,6 +52,10 @@ func run(args []string, stdout, stderr io.Writer) int { return runClone(args[1:], stdout, stderr) case "path": return runPath(args[1:], stdout, stderr) + case "list": + return runList(args[1:], stdout, stderr) + case "cd": + return runCd(args[1:], os.Stdin, isTerminalFile(os.Stdin), stdout, stderr) case "init": return runInit(args[1:], stdout, stderr) case "apply": diff --git a/cmd/gitcd/testutil_test.go b/cmd/gitcd/testutil_test.go index 1557aef..4ed6800 100644 --- a/cmd/gitcd/testutil_test.go +++ b/cmd/gitcd/testutil_test.go @@ -70,6 +70,14 @@ func initRepository(t *testing.T, dir string) { runGit(t, dir, "commit", "-m", "add README") } +// addRemote sets dir's "origin" remote to remoteURL, needed by any +// repository "gitcd list" or "gitcd cd" must find: both walk through +// internal/profile.Inspect, which requires a configured remote to resolve. +func addRemote(t *testing.T, dir, remoteURL string) { + t.Helper() + runGit(t, dir, "remote", "add", "origin", remoteURL) +} + // goEnv pins the toolchain's own GOPATH, GOCACHE and GOMODCACHE, read once // here before any test's setupEnv can point HOME at a fresh temporary // directory. buildGitcd needs them read before that happens: "go build" diff --git a/internal/git/git.go b/internal/git/git.go index f33709f..2653c91 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -75,3 +75,27 @@ func IsRepository(path string) bool { _, err := os.Stat(filepath.Join(path, ".git")) return err == nil } + +// Worktrees runs "git -C path worktree list --porcelain" and returns the +// absolute path of every worktree git has registered for the repository at +// path, path's own main checkout included: git's own registry is +// authoritative for this, and reading it back costs one subprocess call +// per repository rather than a directory walk that would have to look +// inside a path a repository boundary rule otherwise always skips past, +// such as a linked worktree nested inside its own repository's working +// tree. +func Worktrees(ctx context.Context, path string) ([]string, error) { + cmd := exec.CommandContext(ctx, "git", "-C", path, "worktree", "list", "--porcelain") + out, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("git: list the worktrees of %s: %w", path, err) + } + + var worktrees []string + for _, line := range strings.Split(string(out), "\n") { + if p, ok := strings.CutPrefix(line, "worktree "); ok { + worktrees = append(worktrees, p) + } + } + return worktrees, nil +} diff --git a/internal/scan/scan.go b/internal/scan/scan.go new file mode 100644 index 0000000..379bf37 --- /dev/null +++ b/internal/scan/scan.go @@ -0,0 +1,167 @@ +// Copyright 2026 Simon Ostendorf +// SPDX-License-Identifier: Apache-2.0 + +// Package scan walks the configured clone root into the repository values +// "list", "doctor" and "status" consume. A directory carrying a ".git" +// entry, file or directory alike, is a repository boundary: scan reports it +// and does not descend any further, so a linked worktree, whose own ".git" +// is a file pointing back at its main checkout, is never mistaken for a +// nested repository to walk into, the same rule that also keeps a +// submodule from being reported twice. That boundary rule also means the +// walk itself can never see a linked worktree that lives inside its own +// repository's working tree, such as this organisation's own +// "/.claude/worktrees/" convention: once a repository +// boundary is found, scan asks git itself which worktrees it has +// registered for it, rather than looking any further inside a path the +// boundary rule always skips past. +package scan + +import ( + "context" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + + "github.com/ostendorf-cloud/gitcd/internal/config" + "github.com/ostendorf-cloud/gitcd/internal/git" + "github.com/ostendorf-cloud/gitcd/internal/profile" +) + +// Result is one repository boundary Walk found under root, inspected +// against cfg. Err is set, with Repo carrying only Path, when +// profile.Inspect could not resolve the repository, most commonly because +// it carries no "remote.origin.url" at all: Walk still reports it rather +// than dropping it silently, so a caller can decide whether to skip it or +// fail. +type Result struct { + Repo profile.Repo + Err error +} + +// Walk walks root for every repository boundary under it and reports one +// Result per match, in the deterministic lexical order filepath.WalkDir +// already visits directories in. managedDir is profile.Inspect's own +// "gitcd apply" managed directory, passed through unchanged. A root that +// does not exist yet yields no results and no error: a clone root nothing +// has ever been cloned into is not a failure. +func Walk(ctx context.Context, root string, cfg config.Config, managedDir string) ([]Result, error) { + if _, err := os.Stat(root); err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("scan: stat %s: %w", root, err) + } + + // git reports a worktree's own path resolved through any symlink, + // while filepath.WalkDir never resolves one in root itself: root is + // resolved once here purely to recognise a path git reports as lying + // under root, never to change a Result's own Path, which always stays + // expressed against root exactly as given. + resolvedRoot := root + if resolved, err := filepath.EvalSymlinks(root); err == nil { + resolvedRoot = resolved + } + + var results []Result + seen := make(map[string]bool) + err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return fmt.Errorf("scan: walk %s: %w", path, err) + } + if !d.IsDir() { + return nil + } + if d.Name() == ".git" { + // Never descend into a ".git" directory's own contents: it is + // never itself a repository boundary one level further down. + return filepath.SkipDir + } + if path == root { + return nil + } + if !git.IsRepository(path) { + return nil + } + + if err := inspectBoundary(ctx, path, root, resolvedRoot, cfg, managedDir, &results, seen); err != nil { + return err + } + // This path is a repository boundary: whatever it holds beneath + // it, a linked worktree's private metadata, a submodule or a + // nested checkout, is never a second, separate match. + return filepath.SkipDir + }) + if err != nil { + return nil, err + } + return results, nil +} + +// inspectBoundary inspects the repository boundary at path, appending its +// Result to results unless path was already reported. When path is a +// repository's main checkout, rather than a linked worktree itself, it +// also asks git which worktrees it has registered for that repository and +// recurses into every one of them that sits under root and was not +// already seen, so a linked worktree nested inside its own repository's +// working tree is still reported even though filepath.WalkDir's own +// boundary rule, applied by the caller, never looks inside path to find +// it. seen is shared across the whole walk, keyed by path, so a sibling +// worktree WalkDir also visits independently is never reported twice. +// resolvedRoot is root with any symlink resolved, matching the form git +// itself reports a worktree's path in, used only to recognise which +// worktree sits under root; every path actually inspected and stored +// stays expressed against root exactly as given. +func inspectBoundary(ctx context.Context, path, root, resolvedRoot string, cfg config.Config, managedDir string, results *[]Result, seen map[string]bool) error { + if seen[path] { + return nil + } + seen[path] = true + + repo, inspectErr := profile.Inspect(ctx, path, cfg, managedDir) + if inspectErr != nil { + *results = append(*results, Result{Repo: profile.Repo{Path: path}, Err: inspectErr}) + return nil + } + *results = append(*results, Result{Repo: repo}) + + if repo.IsWorktree { + // A linked worktree shares its main checkout's registry: asking + // it again for the same list would only repeat work already done + // from the main checkout, found either earlier or later in this + // same walk. + return nil + } + + worktrees, err := git.Worktrees(ctx, path) + if err != nil { + return fmt.Errorf("scan: list the worktrees of %s: %w", path, err) + } + for _, worktree := range worktrees { + candidate, ok := relocateUnderRoot(filepath.Clean(worktree), root, resolvedRoot) + if !ok || candidate == path || seen[candidate] { + continue + } + if err := inspectBoundary(ctx, candidate, root, resolvedRoot, cfg, managedDir, results, seen); err != nil { + return err + } + } + return nil +} + +// relocateUnderRoot reports whether worktree, a path git reported with any +// symlink in root already resolved, lies under resolvedRoot, and, when it +// does, returns it rejoined onto root exactly as given: the same literal +// form every other Result's Path already carries, since root's own callers +// configure and compare it unresolved. +func relocateUnderRoot(worktree, root, resolvedRoot string) (path string, ok bool) { + rel, err := filepath.Rel(resolvedRoot, worktree) + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return "", false + } + if rel == "." { + return root, true + } + return filepath.Join(root, rel), true +} diff --git a/internal/scan/scan_test.go b/internal/scan/scan_test.go new file mode 100644 index 0000000..67ace01 --- /dev/null +++ b/internal/scan/scan_test.go @@ -0,0 +1,260 @@ +// Copyright 2026 Simon Ostendorf +// SPDX-License-Identifier: Apache-2.0 + +package scan_test + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "testing" + + "github.com/ostendorf-cloud/gitcd/internal/config" + "github.com/ostendorf-cloud/gitcd/internal/scan" +) + +// setupGitEnv isolates every git invocation the test makes from the real +// user's configuration and from the network. +func setupGitEnv(t *testing.T) string { + t.Helper() + home := t.TempDir() + globalConfig := filepath.Join(home, ".gitconfig") + if err := os.WriteFile(globalConfig, []byte("[user]\n\tname = Test User\n\temail = [email protected]\n"), 0o644); err != nil { + t.Fatalf("write %s: %v", globalConfig, err) + } + t.Setenv("HOME", home) + t.Setenv("GIT_CONFIG_GLOBAL", globalConfig) + t.Setenv("GIT_CONFIG_NOSYSTEM", "1") + return home +} + +func runGit(t *testing.T, dir string, args ...string) { + t.Helper() + cmd := exec.CommandContext(context.Background(), "git", args...) + cmd.Dir = dir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %s: %v: %s", strings.Join(args, " "), err, out) + } +} + +// initRepository creates a git repository under dir with one committed +// file and an origin remote, since profile.Inspect, which Walk calls for +// every match, needs one to resolve. +func initRepository(t *testing.T, dir, remoteURL string) { + t.Helper() + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("create %s: %v", dir, err) + } + runGit(t, dir, "init", "--initial-branch=main") + if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("example\n"), 0o644); err != nil { + t.Fatalf("write README.md: %v", err) + } + runGit(t, dir, "add", "README.md") + runGit(t, dir, "commit", "-m", "add README") + runGit(t, dir, "remote", "add", "origin", remoteURL) +} + +func paths(results []scan.Result) []string { + out := make([]string, len(results)) + for i, r := range results { + out[i] = r.Repo.Path + } + sort.Strings(out) + return out +} + +func TestWalk_FindsEveryRepositoryUnderRoot(t *testing.T) { + home := setupGitEnv(t) + root := filepath.Join(home, "Code") + + first := filepath.Join(root, "github.com", "acme", "widget") + second := filepath.Join(root, "gitlab.com", "acme", "widget") + initRepository(t, first, "https://github.com/acme/widget.git") + initRepository(t, second, "https://gitlab.com/acme/widget.git") + + results, err := scan.Walk(context.Background(), root, config.Config{}, filepath.Join(home, ".config", "gitcd")) + if err != nil { + t.Fatalf("Walk() returned an unexpected error: %v", err) + } + + got := paths(results) + want := []string{first, second} + sort.Strings(want) + if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] { + t.Fatalf("Walk() paths = %v, want %v", got, want) + } + for _, r := range results { + if r.Err != nil { + t.Fatalf("Walk() result for %s carries an unexpected error: %v", r.Repo.Path, r.Err) + } + if r.Repo.IsWorktree { + t.Fatalf("Walk() marked %s as a worktree, want an ordinary clone", r.Repo.Path) + } + } +} + +func TestWalk_MissingRootYieldsNoResultsAndNoError(t *testing.T) { + home := setupGitEnv(t) + root := filepath.Join(home, "never-created") + + results, err := scan.Walk(context.Background(), root, config.Config{}, filepath.Join(home, ".config", "gitcd")) + if err != nil { + t.Fatalf("Walk() returned an unexpected error for a missing root: %v", err) + } + if len(results) != 0 { + t.Fatalf("Walk() = %v, want no results for a missing root", results) + } +} + +// TestWalk_StopsDescendingAtARepositoryBoundary plants a second, +// independent repository nested inside the working tree of the first, the +// shape a vendored checkout or an unregistered submodule takes on disk, and +// asserts Walk never reports the nested one: the outer repository's own +// ".git" is the boundary, and Walk must not look any further inside it. +func TestWalk_StopsDescendingAtARepositoryBoundary(t *testing.T) { + home := setupGitEnv(t) + root := filepath.Join(home, "Code") + + outer := filepath.Join(root, "github.com", "acme", "outer") + initRepository(t, outer, "https://github.com/acme/outer.git") + + nested := filepath.Join(outer, "vendor", "inner") + initRepository(t, nested, "https://github.com/acme/inner.git") + + results, err := scan.Walk(context.Background(), root, config.Config{}, filepath.Join(home, ".config", "gitcd")) + if err != nil { + t.Fatalf("Walk() returned an unexpected error: %v", err) + } + + got := paths(results) + if len(got) != 1 || got[0] != outer { + t.Fatalf("Walk() paths = %v, want only %v, the nested repository must not be descended into", got, []string{outer}) + } +} + +// TestWalk_ReportsALinkedWorktreeWithoutDescendingIntoIt covers the +// acceptance criterion directly: a linked worktree's own ".git" is a file, +// not a directory, and Walk must still treat it as a repository boundary +// rather than silently skipping it or walking into whatever it names. +func TestWalk_ReportsALinkedWorktreeWithoutDescendingIntoIt(t *testing.T) { + home := setupGitEnv(t) + root := filepath.Join(home, "Code") + + main := filepath.Join(root, "github.com", "acme", "widget") + initRepository(t, main, "https://github.com/acme/widget.git") + + worktree := filepath.Join(root, "github.com", "acme", "widget-feature") + runGit(t, main, "worktree", "add", worktree) + + info, err := os.Lstat(filepath.Join(worktree, ".git")) + if err != nil { + t.Fatalf("stat the worktree's own .git: %v", err) + } + if info.IsDir() { + t.Fatalf("%s/.git is a directory, want a file: the probe this test relies on no longer holds", worktree) + } + + results, err := scan.Walk(context.Background(), root, config.Config{}, filepath.Join(home, ".config", "gitcd")) + if err != nil { + t.Fatalf("Walk() returned an unexpected error: %v", err) + } + + got := paths(results) + want := []string{main, worktree} + sort.Strings(want) + if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] { + t.Fatalf("Walk() paths = %v, want %v", got, want) + } + + for _, r := range results { + if r.Err != nil { + t.Fatalf("Walk() result for %s carries an unexpected error: %v", r.Repo.Path, r.Err) + } + if r.Repo.Path == worktree && !r.Repo.IsWorktree { + t.Fatalf("Walk() did not mark %s as a worktree", worktree) + } + if r.Repo.Path == main && r.Repo.IsWorktree { + t.Fatalf("Walk() marked the main checkout %s as a worktree", main) + } + } +} + +// TestWalk_ReportsALinkedWorktreeNestedInsideItsOwnRepository covers the +// shape TestWalk_ReportsALinkedWorktreeWithoutDescendingIntoIt does not: a +// linked worktree placed inside its own repository's working tree, at +// "/.claude/worktrees/", exactly the layout this +// organisation's own worktree convention produces. The repository +// boundary rule's "filepath.SkipDir" means Walk's own directory walk never +// looks inside the main checkout to find it, so Walk must surface it some +// other way, not by walking. +func TestWalk_ReportsALinkedWorktreeNestedInsideItsOwnRepository(t *testing.T) { + home := setupGitEnv(t) + root := filepath.Join(home, "Code") + + main := filepath.Join(root, "github.com", "acme", "widget") + initRepository(t, main, "https://github.com/acme/widget.git") + + worktree := filepath.Join(main, ".claude", "worktrees", "feat-x") + runGit(t, main, "worktree", "add", worktree, "-b", "feat-x") + + results, err := scan.Walk(context.Background(), root, config.Config{}, filepath.Join(home, ".config", "gitcd")) + if err != nil { + t.Fatalf("Walk() returned an unexpected error: %v", err) + } + + got := paths(results) + want := []string{main, worktree} + sort.Strings(want) + if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] { + t.Fatalf("Walk() paths = %v, want %v: a worktree nested inside its own repository must still be reported", got, want) + } + + for _, r := range results { + if r.Err != nil { + t.Fatalf("Walk() result for %s carries an unexpected error: %v", r.Repo.Path, r.Err) + } + if r.Repo.Path == worktree && !r.Repo.IsWorktree { + t.Fatalf("Walk() did not mark the nested worktree %s as a worktree", worktree) + } + if r.Repo.Path == main && r.Repo.IsWorktree { + t.Fatalf("Walk() marked the main checkout %s as a worktree", main) + } + } +} + +// TestWalk_ReportsAnUninspectableRepositoryRatherThanDroppingIt covers a +// repository Walk finds but profile.Inspect cannot resolve, a clone with +// no origin remote configured: Walk must still report it, with Err set, +// rather than silently excluding it from the result. +func TestWalk_ReportsAnUninspectableRepositoryRatherThanDroppingIt(t *testing.T) { + home := setupGitEnv(t) + root := filepath.Join(home, "Code") + + noRemote := filepath.Join(root, "local-only") + if err := os.MkdirAll(noRemote, 0o755); err != nil { + t.Fatalf("create %s: %v", noRemote, err) + } + runGit(t, noRemote, "init", "--initial-branch=main") + if err := os.WriteFile(filepath.Join(noRemote, "README.md"), []byte("example\n"), 0o644); err != nil { + t.Fatalf("write README.md: %v", err) + } + runGit(t, noRemote, "add", "README.md") + runGit(t, noRemote, "commit", "-m", "add README") + + results, err := scan.Walk(context.Background(), root, config.Config{}, filepath.Join(home, ".config", "gitcd")) + if err != nil { + t.Fatalf("Walk() returned an unexpected error: %v", err) + } + if len(results) != 1 { + t.Fatalf("Walk() = %d results, want 1", len(results)) + } + if results[0].Repo.Path != noRemote { + t.Fatalf("Walk() result path = %q, want %q", results[0].Repo.Path, noRemote) + } + if results[0].Err == nil { + t.Fatal("Walk() result carries no error for a repository with no configured remote, want one") + } +} diff --git a/internal/shell/shell.go b/internal/shell/shell.go index d374a7c..aefd2a3 100644 --- a/internal/shell/shell.go +++ b/internal/shell/shell.go @@ -3,12 +3,15 @@ // Package shell renders the per shell init snippet "gitcd shell-init" // prints. Each snippet defines a "gitcd" function that shadows the binary: -// it calls through to the real "gitcd" for every command, and for "clone" -// and "path", the two commands that print a path on stdout, changes the +// it calls through to the real "gitcd" for every command, and for "clone", +// "path" and "cd", the commands that print a path on stdout, changes the // shell's working directory to it when that path actually exists, since // "clone --dry-run" prints a path it deliberately never created. Every // other command's output passes through unchanged, because the binary -// itself already put it on stdout for exactly this purpose. +// itself already put it on stdout for exactly this purpose. The zsh and +// fish snippets also register tab completion for "cd", shelling out to +// "gitcd list --worktrees" fresh on every request rather than caching it, +// since a repository just cloned should complete immediately. package shell import "fmt" @@ -19,7 +22,7 @@ const bashLike = `gitcd() { local real real="$(command gitcd "$@")" || return $? case "$1" in - clone|path) + clone|path|cd) if [ -d "$real" ]; then cd -- "$real" else @@ -33,6 +36,34 @@ const bashLike = `gitcd() { } ` +// zshCompletion registers tab completion for "gitcd cd": pressing tab +// shells out to "gitcd list --worktrees" fresh every time, trading one +// local process start for never showing a repository cloned, or a +// worktree added, moments ago as stale. "${repos[@]:t}" takes each path's +// final segment, so completion offers short names rather than full paths, +// composing with "cd"'s own name based exact match; a name two +// repositories share completes to either and "cd" itself then falls back +// to its ambiguous match picker. +const zshCompletion = `_gitcd() { + local -a repos + case "${words[2]}" in + cd) + repos=("${(@f)$(command gitcd list --worktrees 2>/dev/null)}") + compadd -- "${repos[@]:t}" + ;; + esac +} +compdef _gitcd gitcd +` + +// fishCompletion is zshCompletion's fish equivalent: "string replace -r" +// strips every path down to its final "/" separated segment, since fish +// has no direct basename expansion modifier, and the command substitution +// fish writes inside "(...)" is re-run on every completion request, the +// same "no caching" choice zshCompletion makes. +const fishCompletion = `complete -c gitcd -n '__fish_seen_subcommand_from cd' -f -a "(command gitcd list --worktrees 2>/dev/null | string replace -r '^.*/' '')" +` + // fish is the "gitcd" function body for fish, whose syntax for a local // variable, a command substitution and a switch differs from bashLike's. // "set -l code $status" reads the exit status of the command substitution @@ -52,7 +83,7 @@ const fish = `function gitcd return $code end switch $argv[1] - case clone path + case clone path cd if test -d "$real" cd $real else @@ -64,14 +95,18 @@ const fish = `function gitcd end ` -// Bash is the init snippet for bash. +// Bash is the init snippet for bash. Bash has no tab completion snippet +// here: only zsh and fish do, per the "cd" completion acceptance +// criterion. func Bash() string { return bashLike } -// Zsh is the init snippet for zsh. -func Zsh() string { return bashLike } +// Zsh is the init snippet for zsh: the shared wrapper function plus "cd" +// tab completion. +func Zsh() string { return bashLike + zshCompletion } -// Fish is the init snippet for fish. -func Fish() string { return fish } +// Fish is the init snippet for fish: the wrapper function plus "cd" tab +// completion. +func Fish() string { return fish + fishCompletion } // Init returns the init snippet for shell, one of "bash", "zsh" or "fish". func Init(shell string) (string, error) { diff --git a/internal/shell/shell_test.go b/internal/shell/shell_test.go index 378a59d..ca48865 100644 --- a/internal/shell/shell_test.go +++ b/internal/shell/shell_test.go @@ -112,3 +112,74 @@ func TestBashAndZshSnippetsAreSyntacticallyValid(t *testing.T) { } } } + +// TestZsh_CdCompletionOffersEveryRepositoryByItsShortName runs the real +// zsh completion function the Zsh snippet defines, with "compadd" and +// "compdef" stubbed out since neither is available outside a terminal +// completion session, and a fake "gitcd" on PATH standing in for the real +// binary "gitcd list --worktrees" shells out to. It is a behavioural test, +// not a string match: it would still pass if the function's body were +// replaced by anything else that called compadd with the right names, and +// would fail if "_gitcd" stopped reducing each path to its final segment, +// or stopped going through "gitcd list --worktrees" at all. +func TestZsh_CdCompletionOffersEveryRepositoryByItsShortName(t *testing.T) { + t.Parallel() + + zshPath, err := exec.LookPath("zsh") + if err != nil { + t.Skip("zsh is not installed") + } + + dir := t.TempDir() + snippetFile := filepath.Join(dir, "init.zsh") + if err := os.WriteFile(snippetFile, []byte(shell.Zsh()), 0o644); err != nil { + t.Fatalf("write %s: %v", snippetFile, err) + } + + fakeBin := t.TempDir() + fakeGitcd := filepath.Join(fakeBin, "gitcd") + fakeScript := "#!/bin/sh\n" + + "if [ \"$1\" = list ] && [ \"$2\" = --worktrees ]; then\n" + + " printf '%s\\n' /code/github.com/acme/widget /code/gitlab.com/acme/other\n" + + "fi\n" + if err := os.WriteFile(fakeGitcd, []byte(fakeScript), 0o755); err != nil { + t.Fatalf("write %s: %v", fakeGitcd, err) + } + + script := `compdef() { :; } +compadd() { for a in "$@"; do [ "$a" = "--" ] && continue; print -r -- "$a"; done } +source "$1" +words=(gitcd cd) +_gitcd +` + cmd := exec.CommandContext(context.Background(), zshPath, "-c", script, "--", snippetFile) + cmd.Env = append(os.Environ(), "PATH="+fakeBin+string(os.PathListSeparator)+os.Getenv("PATH")) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("run the zsh completion function: %v: %s", err, out) + } + + got := strings.Fields(strings.TrimSpace(string(out))) + want := []string{"widget", "other"} + if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] { + t.Fatalf("_gitcd compadd arguments = %v, want %v", got, want) + } +} + +// TestFish_RegistersCdCompletion checks the fish completion block by +// string match only: fish is not installed in this environment and is not +// in the mise tool registry, so no real fish process can execute it. +func TestFish_RegistersCdCompletion(t *testing.T) { + t.Parallel() + + snippet := shell.Fish() + for _, want := range []string{ + "complete -c gitcd", + "__fish_seen_subcommand_from cd", + "gitcd list --worktrees", + } { + if !strings.Contains(snippet, want) { + t.Fatalf("fish snippet does not contain %q: %s", want, snippet) + } + } +}