Skip to content

feat: list repositories and cd to one by name - #13

Draft
simonostendorf wants to merge 6 commits into
mainfrom
feat/3-list-and-cd
Draft

simonostendorf wants to merge 6 commits into
mainfrom
feat/3-list-and-cd

Conversation

@simonostendorf

@simonostendorf simonostendorf commented Nov 13, 2026 •

Copy link
Copy Markdown
Member

What

  • internal/scan walks root into repositories, reusing profile.Inspect rather than
    duplicating remote and worktree handling.
  • cmd/gitcd gains list with --json and --worktrees, and cd with matching and a picker.
  • internal/shell wraps cd in all three shells and adds completion for zsh and fish.

Why

  • Closes feat: list and cd with fuzzy matching #3.
  • A .git entry is a repository boundary whether it is a directory or a file. A linked worktree
    leaves a file, so one rule covers both linked worktrees and .claude/worktrees instead of two
    special cases. Probed against a real linked worktree before the code was written.
  • Matching is a case insensitive substring of the path relative to root, and an exact name wins
    outright over any broader match. Predictable beats clever here, because this runs inside a
    shell function the user cannot see into.
  • Completion shells out to list on every tab rather than caching, so a repository cloned a
    moment ago completes immediately.
  • A repository Inspect cannot resolve is reported with its error rather than silently dropped.

Verification

  • task check exit 0.
  • Against a generated tree holding a plain repository, a repository with a linked worktree and
    two repositories both named widget:
list                 3 paths, the linked worktree hidden
list --worktrees     4 paths, the linked worktree included
cd gitcd             resolves, exit 0
cd nothing           exit 1, "no repository matches"
cd widget | pipe     exit 1, both candidates listed on stderr
  • Exit codes were checked on their own, because the shell wrapper reads them: 0 for a resolved
    cd and for list, 1 for no match and for an ambiguous query without a terminal.

Risk

  • Terminal detection uses the standard library only, testing for a character device, because a
    real isatty needs a dependency this repository does not carry. A pipe or a redirected file is
    detected correctly, which is the case that matters. /dev/null and /dev/zero are themselves
    character devices, so they are misread as a terminal: the command then fails safely with
    "no selection was read" and exit 1, but for the wrong stated reason.
  • cd considers linked worktrees as candidates while list hides them by default. That is
    deliberate, since jumping into a worktree is the point, but the two commands do disagree about
    what they show.
  • internal/scan lands here and feat: status, sync and open across repositories #5 needs the same walk. It was told to build the minimum it
    needs separately rather than wait, so the two need reconciling once both are in.

Closes #3


Autofix with [code]smith
Open in [code]smith · Need help on this PR? Tag @codesmith-bot with what you need.

@simonostendorf simonostendorf added feature New feature or capability stack: go Related to Go code labels Nov 13, 2026
@simonostendorf

Copy link
Copy Markdown
Member Author

Verdict: changes requested

Reviewed at the pushed head, commits bdbe80b..f93d885. task check passes: fmt, vet, golangci-lint, markdownlint, yamllint, actionlint, goreleaser check, gitleaks, go test -race across all packages, build, all green.

Acceptance criteria from issue #3, checked one by one against the real binary, not just the diff:

  • scan walks root into Repo values and stops at a boundary, with a worktree test: met for a worktree that sits beside its main checkout, not met for one nested inside it. See Blocker below.
  • list prints every repository under root: met for the topologies tested (plain repo, nested vendored repo correctly hidden, sibling worktree hidden by default). Incomplete for the nested case, same Blocker.
  • list --json: met, verified structurally and via TestRunList_JSONReportsTheSameRepositoriesStructured.
  • list --worktrees includes a linked worktree: met for a sibling worktree, verified directly (list showed 4 paths, list --worktrees added the fifth). Not met for a worktree nested inside its own repository's working tree.
  • cd <query> resolves a unique partial match on stdout, exit 0: met, verified directly (cd widg -> resolved path, exit 0; cd foo -> exit 0).
  • Ambiguous query with a terminal shows a numbered picker on stderr: met, verified via TestRunCd_AmbiguousQueryWithATerminalReadsAPickFromStdin and by reading pick().
  • Ambiguous query without a terminal exits non-zero and lists candidates on stderr: met, verified directly through a real pipe (printf '' | gitcd cd acme), exit 1, both candidates listed.
  • Query matching nothing exits non-zero naming the query: met, verified directly (cd doesnotexistatall -> exit 1, message names the query).
  • Shell completion for cd in zsh and fish through shell-init: met. Ran the real zsh completion function in a real zsh process with compadd/compdef stubbed and a fake gitcd on PATH; it correctly reduced two full paths to their short names. Fish stays a string assertion since fish is not installed here, consistent with the acceptance criterion and the repository's own constraints.
  • Tests cover matching, ambiguity and the non-terminal path: met, and two of the three guards I reverted in a detached scratch copy genuinely failed without their code (see Tests below).
  • task check passes: met, see above.

Boundary probe (built a real tree under $TMPDIR: a plain repository, a repository with a sibling linked worktree, a repository with a nested repository in vendor/, and a repository whose own .claude/worktrees/<branch> holds a linked worktree, matching this very project's own worktree convention):

  • Plain repository: found by list. Correct.
  • Nested repository inside another repository's working tree (outer/vendor/innerlib): never reported, by list or list --worktrees. Correct, no double reporting.
  • Sibling linked worktree (widget / widget-feature): hidden by list, shown by list --worktrees. Correct, matches the acceptance criterion.
  • Linked worktree nested inside its own repository's working tree, at <repo>/.claude/worktrees/<branch>: invisible to both list and list --worktrees, and cd <branch> fails with "no repository matches". This is the exact shape this organization's own worktree convention produces, and the one the issue text names explicitly ("covers linked worktrees and .claude/worktrees/ in one rule"). It does not cover it; see Blocker.

Ranking attack probes (built a second tree: a repository named git whose name is a literal substring of another repository gitcd's path; a repository widget/api whose owner equals another repository's exact name widget; owners shared by several repositories with no repository literally named acme):

  • cd git resolved only acme/git, not acme/gitcd. Exact name won outright, no bleed-through.
  • cd gitcd resolved only acme/gitcd.
  • cd widget resolved only acme/widget, not widget/api, even though widget/api's rel path also starts with "widget". Exact name won outright, as documented.
  • cd acme (an owner, not a repository name) fell through to the ambiguous picker across all five repositories under that owner, as expected; no owner ever won as if it were a repository.

No break found in matching or ranking. The claim in the pull request description holds under all four probes.

Exit codes measured directly, not just asserted in tests:

  • list: 0.
  • cd with a unique match: 0.
  • cd with no match: 1.
  • cd ambiguous through a real pipe (not a terminal): 1, candidates listed on stderr.
  • cd ambiguous with a terminal and a valid pick (via the injected isTerminal test path): 0.
  • cd ambiguous with a terminal and an invalid pick: 1.
    All match the acceptance criteria and the pull request's own table.

Terminal detection limitation: confirmed directly. cd acme < /dev/null and cd acme < /dev/zero (the latter bounded, it returns in well under a second, not a hang) both get misread as a terminal, print the numbered picker, then fail with "no selection was read for ...", exit 1. Functionally this still satisfies the acceptance criterion: non-zero exit, candidates already listed on stderr before the failure. The only problem is the stated reason being wrong. A script that redirects stdin from /dev/null to guarantee non-interactive behavior gets the right exit code for the right practical outcome, with a confusing message. I judge this a Should, not a Blocker, as scoped in the Risk section.

Tests, revert-and-fail, in a detached copy of the source tree only (never the worktree, since its .git is a file pointing at the shared git dir), removed afterward:

  • internal/git.IsRepository required the .git entry to be a directory (reverting the file-or-directory boundary rule): TestWalk_ReportsALinkedWorktreeWithoutDescendingIntoIt failed, lost the worktree from the result. Guard is real.
  • Removed the !isTerminal check in cd.go's pick(): TestRunCd_AmbiguousQueryWithoutATerminalFailsListingEveryCandidate failed (exit 0 instead of the expected failure). This is the test the author reported fixing after discovering it passed for the wrong reason with the guard deleted, because stdin was an already-closed pipe. Confirmed: the fixed version now fails for the right reason when the guard is gone.
  • Removed the exact-match tier in resolveQuery, falling back to substring-only matching: TestRunCd_ExactNameWinsOverABroaderFuzzyMatch failed, fell into the ambiguous picker instead of resolving outright. Guard is real.

All three reverts were restored before moving on; the scratch copy was deleted.

Isolation: ran every probe with HOME under $TMPDIR and GIT_CONFIG_NOSYSTEM=1. Checked the real /Users/simonostendorf/.gitconfig by its absolute path without overriding HOME: 537 bytes, mtime Oct 17 08:38:06 2026, no occurrence of the string gitcd. Unchanged throughout.

Blocker

  • internal/scan/scan.go:79 (and the boundary comment at internal/scan/scan.go:76-79): a repository boundary's SkipDir stops Walk from ever looking inside that repository's own working tree, which is also where this organization's own worktree convention places linked worktrees, under <repo>/.claude/worktrees/<branch>. Issue feat: list and cd with fuzzy matching #3 states the boundary rule is meant to cover "linked worktrees and .claude/worktrees/ in one rule rather than two special cases"; it only covers a worktree that sits beside its main checkout as a sibling path, the one shape internal/scan/scan_test.go:142-183 tests. A worktree nested inside its own repository's working tree, reproduced directly under $TMPDIR with the exact relative layout this repository's own worktrees use, is invisible to list --worktrees and unreachable by cd, which fails with "no repository matches". Fix: have Walk (or profile.Inspect) also surface a repository's registered linked worktrees directly, for instance from git -C <path> worktree list --porcelain, rather than relying on physically walking into a path that the boundary rule always skips past. Add a test that nests a linked worktree inside its own main checkout's working tree, not only beside it, since that is the layout actually in use.

Should

  • cmd/gitcd/cd.go:196-202 (isTerminalFile): confirmed that /dev/null and /dev/zero are misread as a terminal, producing "no selection was read for %q" instead of the correct "stdin is not a terminal" message, though the exit code and the candidate listing are still correct. Worth a one-line note in isTerminalFile's doc comment pointing at this, or a narrow check for these two well-known device files, next time this code is touched; not worth blocking on since the practical behavior for a script is already correct.

A .git entry is a repository boundary whether it is a directory or a file,
which is what a linked worktree leaves behind, so one rule covers both and
.claude/worktrees with it.

Refs #3
An exact name wins outright over any broader match, so a repository is never
shadowed by a longer path that happens to contain the query. An ambiguous
query opens a numbered picker on a terminal and exits non zero listing the
candidates anywhere else, because the shell function reads the exit code.

Refs #3
Completion shells out to list on every tab rather than caching, so a
repository cloned a moment ago completes immediately.

Refs #3
The boundary rule stopped the walk at a repository, so a worktree under
<repo>/.claude/worktrees/<branch> was never seen. That is the layout this
organisation mandates, so the common shape was the one that did not work.

Each repository is now asked for its own worktrees through git, which is
authoritative and costs one subprocess per repository rather than a deeper
walk. A nested independent repository is still not reported.

Refs #3
A character device test cannot tell /dev/null from a terminal, so the message
no longer claims a certainty it does not have.

Refs #3
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature New feature or capability stack: go Related to Go code

Development

Successfully merging this pull request may close these issues.

feat: list and cd with fuzzy matching

1 participant