From 35d3b6405856302bde5d158a96e4b9c9e0b1afb9 Mon Sep 17 00:00:00 2001 From: Logan Thomas Date: Mon, 20 Jul 2026 15:39:37 -0500 Subject: [PATCH 1/2] =?UTF-8?q?Phase=203:=20shell=20integration=20&=20navi?= =?UTF-8?q?gation=20=E2=80=94=20shell-init,=20go,=20picker=20(#8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(nav): fuzzy tree resolution with deterministic ambiguity * feat(ls): add --porcelain stable machine output * feat(gitx): add ShortStatus and LastCommit preview helpers * feat(go): fuzzy-jump navigation with picker and non-TTY porcelain fallback * feat(shell-init): zsh shim with cd protocol, completions, opt-in prompt hook * docs: shell integration page, README nav commands, agent porcelain contract * refactor(nav): pin byte-based score normalization with a multibyte tie test * feat(root): point bare wt at --help when run outside a repository * fix(shell-init): pin zsh emulation and builtins in the shim * style(picker): drop redundant import alias * fix(shell-init): hand the path through when stdout is captured * fix(shell-init): survive a pre-existing wt alias at eval time * fix(shell-init): bootstrap completions and hooks under stock zsh emulation * fix(shell-init): recompute the prompt indicator instead of caching stale answers * test(shell-init): lock exit-status propagation through the wrapper * fix(tty): treat a dumb or unset TERM as non-interactive * fix(go): reject an explicitly empty query as usage * refactor(nav): drop the no-op sort and dedupe from candidate names * refactor(nav): own the exact-spelling tier for every name-resolving command * refactor(shell-init): replace the one-branch template with plain zsh embeds * perf(shell-init): register completions lazily to keep startup to one spawn * fix(shell-init): run the completion bootstrap without sticky emulation * fix(shell-init): keep err_exit shells alive when no wt alias exists * docs(plan): record the uncached prompt hook and plain-zsh shim * refactor(cli): fold the shell-init usage case into the exit-two table * docs(cli): rewrite kept comments without em-dashes * refactor(tty): unify terminal detection on the ioctl probe * docs(plan): record the TERM guard and the ls --porcelain flag --- .changes/8.doc.md | 1 + .changes/8.enh.md | 1 + .changes/8.maint.md | 1 + .github/workflows/test.yml | 5 + PLAN.md | 47 +++-- README.md | 19 +- docs/agents.md | 13 ++ docs/index.md | 2 +- docs/shell.md | 129 ++++++++++++ go.mod | 13 +- go.sum | 67 ++++++- internal/cli/go.go | 85 ++++++++ internal/cli/init.go | 8 +- internal/cli/ls.go | 32 ++- internal/cli/ls_test.go | 14 ++ internal/cli/picker.go | 64 ++++++ internal/cli/root.go | 18 +- internal/cli/root_test.go | 1 + internal/cli/shellinit.go | 48 +++++ internal/cli/shellinit_test.go | 80 ++++++++ internal/cli/shim.zsh | 56 ++++++ internal/cli/shim_prompt.zsh | 31 +++ .../cli/testdata/golden/shell-init-prompt.zsh | 87 ++++++++ internal/cli/testdata/golden/shell-init.zsh | 56 ++++++ internal/cli/testdata/script/go.txtar | 74 +++++++ internal/cli/testdata/script/ls.txtar | 8 + internal/cli/testdata/script/shellinit.txtar | 99 ++++++++++ internal/cli/tree.go | 36 ++-- internal/cli/tty.go | 31 +++ internal/gitx/gitx.go | 17 ++ internal/gitx/gitx_test.go | 30 +++ internal/nav/nav.go | 130 ++++++++++++ internal/nav/nav_test.go | 186 ++++++++++++++++++ 33 files changed, 1438 insertions(+), 51 deletions(-) create mode 100644 .changes/8.doc.md create mode 100644 .changes/8.enh.md create mode 100644 .changes/8.maint.md create mode 100644 docs/shell.md create mode 100644 internal/cli/go.go create mode 100644 internal/cli/picker.go create mode 100644 internal/cli/shellinit.go create mode 100644 internal/cli/shellinit_test.go create mode 100644 internal/cli/shim.zsh create mode 100644 internal/cli/shim_prompt.zsh create mode 100644 internal/cli/testdata/golden/shell-init-prompt.zsh create mode 100644 internal/cli/testdata/golden/shell-init.zsh create mode 100644 internal/cli/testdata/script/go.txtar create mode 100644 internal/cli/testdata/script/shellinit.txtar create mode 100644 internal/cli/tty.go create mode 100644 internal/nav/nav.go create mode 100644 internal/nav/nav_test.go diff --git a/.changes/8.doc.md b/.changes/8.doc.md new file mode 100644 index 0000000..f204ef5 --- /dev/null +++ b/.changes/8.doc.md @@ -0,0 +1 @@ +A shell-integration docs page covers the cd protocol, fuzzy-matching rules, completions, and the `WT_PROMPT`/starship prompt recipes. (#8) diff --git a/.changes/8.enh.md b/.changes/8.enh.md new file mode 100644 index 0000000..656a719 --- /dev/null +++ b/.changes/8.enh.md @@ -0,0 +1 @@ +One `eval "$(wt shell-init zsh)"` line in `.zshrc` makes bare `wt` an interactive tree picker, `wt go` a fuzzy cd, and `wt new` land in the fresh tree — with zsh completions, an opt-in `WT_PROMPT` indicator, and a script-safe `wt ls --porcelain` fallback whenever no TTY is present. (#8) diff --git a/.changes/8.maint.md b/.changes/8.maint.md new file mode 100644 index 0000000..e2b6eb7 --- /dev/null +++ b/.changes/8.maint.md @@ -0,0 +1 @@ +The ubuntu CI runner installs zsh, so the shell-integration smoke tests run on both platforms. (#8) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7f76368..391d994 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -26,6 +26,11 @@ jobs: runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 + # macOS ships zsh; ubuntu needs it installed so the + # shell-integration smoke tests run on both platforms + # instead of silently skipping on one. + - if: runner.os == 'Linux' + run: sudo apt-get update -qq && sudo apt-get install -y -qq zsh - uses: actions/setup-go@v5 with: go-version: stable diff --git a/PLAN.md b/PLAN.md index 5a24ead..0ba9880 100644 --- a/PLAN.md +++ b/PLAN.md @@ -284,8 +284,9 @@ so `wt shell-init zsh` emits: (binary prints target path on stdout; function does the `cd`); 2. cobra-generated zsh completions; 3. an opt-in prompt hook: a zsh `chpwd` hook exporting `WT_PROMPT` - from a cached path check against known tree roots — - no git subprocess per prompt render — + from a live, builtins-only inspection of the tree's `.git` file — + no git subprocess and no cache to go stale, + recomputed only on cd, never per prompt render — plus a documented starship `custom`-segment alternative. The prompt indicator ships as **optional** @@ -356,7 +357,7 @@ merged-branch slots, and `wt clean -n` previews every action. | `wt` | Interactive fuzzy picker over trees → cd. Non-TTY: porcelain list. | | `wt init` | Interactive setup: base branch, trees dir, pool y/n + size, prompt indicator, copy list; writes `.git/wt.toml`. | | `wt new [--base ]` | Default: create worktree + branch off base. Pool: claim a slot, reset, branch there. Prints tree path on stdout. | -| `wt ls [--json]` | List trees: branch, path, age, ahead/behind base, dirty, slot/lease state. | +| `wt ls [--porcelain] [--json]` | List trees: branch, path, age, ahead/behind base, dirty, slot/lease state. | | `wt go [query]` | Fuzzy-jump: best match cd (with query) or picker (without). | | `wt done [name] [--keep-branch]` | Finish a tree: safety checks, then remove (default) or release+reset slot (pool). Alias: `wt rm`. | | `wt sync [--all]` | Fetch base, fast-forward it, re-park idle slots, report behind-counts. Never touches branches with user commits. | @@ -490,7 +491,8 @@ branch protection live on `main` and `dev`. - **Exit:** full default-mode lifecycle usable day-to-day from the raw binary (no cd yet). Tag `v0.1.0-alpha.2`. -**Status (2026-07-20):** code complete, PR open against `dev`. +**Status (2026-07-20): complete.** +Merged to `main`; `v0.1.0-alpha.2` tagged and released. Notable additions beyond the checklist: `wt done` sweeps wt-planted `copy` files when their content still matches the main checkout (an edited copy still trips the guard), @@ -506,28 +508,47 @@ instead of leaking through the D13 contract; tracked copy-list entries are left to git on both the plant and sweep sides; and `wt done` points prunable trees at `git worktree prune`. -Remaining before exit is met: merge, batch fragments, -tag `v0.1.0-alpha.2`. -Phase 3 (shell integration & navigation) is ready to be taken up -once the tag is cut. ### Phase 3 — Shell integration & navigation (M) -- [ ] `wt shell-init zsh`: `go:embed` shim template — +- [x] `wt shell-init zsh`: `go:embed` shim script — `wt()` function, cd protocol, completions; golden-file test of the emitted script plus a zsh smoke test (`zsh -c 'eval "$(wt shell-init zsh)"; wt go x'`) under testscript. -- [ ] `wt go `: sahilm/fuzzy over branch names + slot tickets; +- [x] `wt go `: sahilm/fuzzy over branch names + slot tickets; ambiguous → top-5 disambiguation on stderr, exit 3. -- [ ] Bare `wt` and bare `wt go`: go-fuzzyfinder picker with preview pane; +- [x] Bare `wt` and bare `wt go`: go-fuzzyfinder picker with preview pane; **non-TTY fallback to porcelain list** (testscript covers the non-TTY path; the picker itself gets a manual test note). -- [ ] Optional prompt segment: chpwd-cached `WT_PROMPT`, +- [x] Optional prompt segment: chpwd-refreshed `WT_PROMPT`, `--prompt` flag on shell-init; starship recipe in docs. - **Exit:** the eval line in `.zshrc` gives cd-on-select, completions, optional prompt. Tag `v0.1.0-alpha.3`. +**Status (2026-07-20):** code complete, PR open against `dev`. +Two D12 refinements surfaced by implementation: +the interactivity probe is **stdin + stderr**, never stdout — +the shim captures stdout to implement the cd protocol, +so a stdout check would make the picker unreachable +(the picker renders on `/dev/tty`; +a dumb or unset `TERM` also counts as non-interactive; +the porcelain fallback for scripts and agents is unchanged) — +and the porcelain fallback landed as an explicit +`wt ls --porcelain` flag so scripts can ask for it by name. +Fuzzy ranking normalizes away the matcher's name-length penalty: +a jump is decided by match quality alone, +so `feature` refuses to pick `feature/login` over `feature/logout` +just because it is a letter shorter. +The shim's cd set is bare `wt`, `wt go`, and `wt new`; +`wt path` stays cd-free plumbing. +Completions are bootstrapped via `eval "$(wt completion zsh)"` +inside the shim rather than inlined, +so they track the installed binary and golden files stay stable. +Remaining before exit is met: merge, batch fragments, +tag `v0.1.0-alpha.3`. +Phase 4 (pool mode) is ready to be taken up once the tag is cut. + ### Phase 4 — Pool mode (L) - [ ] `internal/lease`: atomic-mkdir lease + PID/start-time liveness — @@ -673,7 +694,7 @@ stays short): | R11 | Shared object store: gc/repack while trees are in use | Docs note; `wt clean` never triggers gc; watch-item, not v1 machinery | 6 | | R12 | Locked worktrees (`git worktree lock`) | `ls` shows the lock flag; `done` refuses locked trees with an explanation | 6 | | R13 | Unsigned-binary quarantine on macOS | Cask `postflight` xattr hook; notarization post-1.0 | 1 | -| R14 | Prompt hook slows every prompt render | chpwd caching, no git subprocess in the prompt path; feature optional | 3 | +| R14 | Prompt hook slows every prompt render | chpwd hook of zsh builtins only — no subprocess, recomputed on cd not per render; feature optional | 3 | | R15 | Picker hangs agents/scripts | Non-TTY → porcelain, enforced by testscript | 3 | | R16 | IDE state (VS Code) doesn't follow trees | Docs: `code $(wt path )`; out of scope for v1 | 7 | | R17 | Dev-server port collisions across parallel trees | Docs pattern: derive port from slot number in `.envrc`; not wt machinery | 7 | diff --git a/README.md b/README.md index 4f3ddd3..d043821 100644 --- a/README.md +++ b/README.md @@ -32,19 +32,32 @@ Fallback, no Homebrew required: go install github.com/loganthomas/wt/cmd/wt@latest ``` +Then wire up the shell integration — +cd-on-select, completions, optional prompt indicator — +with one line in `~/.zshrc`: + +```sh +eval "$(wt shell-init zsh)" +``` + +Details in [docs/shell.md](docs/shell.md). + ## Commands | Command | One-liner | | -------------------------------- | ----------------------------------------------------------------------------- | +| `wt` | Interactive fuzzy picker over trees → cd. Without a TTY: porcelain list. | | `wt init` | Set up wt for a repo (prompts, or `--yes` + flags); writes `.git/wt.toml`. | -| `wt new [--base ]` | Create a worktree + branch off the base; prints the tree path on stdout. | -| `wt ls` | List worktrees: branch, path, state. | +| `wt new [--base ]` | Create a worktree + branch off the base, and cd there under the shim. | +| `wt ls [--porcelain]` | List worktrees: branch, path, state. | +| `wt go [query]` | Fuzzy-jump: best match cds (with a query) or picker (without). | | `wt done [name] [--keep-branch]` | Finish a tree: safety checks, remove it, delete its branch. Alias: `wt rm`. | | `wt path [name]` | Print a tree's absolute path (plumbing). | | `wt config [--edit]` | Show active config paths and merged values; `--edit` opens `$VISUAL`/`$EDITOR`. | +| `wt shell-init zsh [--prompt]` | Emit the shim, completions, and optional prompt hook for eval in `.zshrc`. | | `wt --version` | Version, commit, build date. | -The full surface (`go`, `sync`, `clean`, pool mode, …) +The full surface (`sync`, `clean`, pool mode, …) lands phase by phase; see [PLAN.md](PLAN.md). Configuration reference: [docs/configuration.md](docs/configuration.md). Scripting and agent contract: [docs/agents.md](docs/agents.md). diff --git a/docs/agents.md b/docs/agents.md index ece74f3..ce7a843 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -38,7 +38,11 @@ then retry", never "wt is broken". | ----------- | --------------------------------------------- | | `wt new` | The new tree's absolute path, one line. | | `wt path` | The resolved tree's absolute path, one line. | +| `wt go ` | The matched tree's absolute path, one line. Ambiguous: contenders on stderr, exit 3. No match: exit 1. | | `wt ls` | One aligned row per tree. | +| `wt ls --porcelain` | One tree per line, three tab-separated fields: branch label, absolute path, comma-joined states (`-` when none). The field count never varies. | +| bare `wt`, bare `wt go` | Without a TTY on stdin and stderr: exactly the `--porcelain` listing, so agents never hang on the interactive picker. | +| `wt shell-init zsh` | The zsh integration script itself (it is the machine output — meant for `eval`). | | `wt config` | Merged config as TOML; the two config file paths ride along as `#` comments, so the whole document stays parseable TOML. | | `wt init` | Nothing (chatter on stderr). Non-interactive use requires `--yes` plus value flags; without a TTY, prompting is refused (exit 2) rather than hanging. | | `wt done` | Nothing (chatter on stderr). | @@ -46,6 +50,15 @@ then retry", never "wt is broken". `--json` on `ls`/`status`/`doctor` and the pool plumbing (`wt claim` / `wt release`) land in later phases. +A porcelain line looks like: + +``` +feature/login /Users/you/acme.trees/feature-login - +main /Users/you/acme locked,prunable +``` + +Detached trees carry the literal branch label `(detached)`. + ## Hooks `hooks.setup` runs via `sh -c` inside the new tree. diff --git a/docs/index.md b/docs/index.md index 25324ba..c69f934 100644 --- a/docs/index.md +++ b/docs/index.md @@ -7,5 +7,5 @@ Pages land alongside the features they document - [`agents.md`](agents.md) — machine-output contract: streams, exit codes - `pool-mode.md` — monorepo pool setup and lifecycle (Phase 4) - `recipes.md` — per-ecosystem examples (Phase 5+) -- `shell.md` — shim internals, prompt, completions (Phase 3) +- [`shell.md`](shell.md) — shim internals, cd protocol, prompt indicator, completions - `faq.md` — doctor-adjacent questions (Phase 6) diff --git a/docs/shell.md b/docs/shell.md new file mode 100644 index 0000000..ece00a6 --- /dev/null +++ b/docs/shell.md @@ -0,0 +1,129 @@ +# Shell integration + +One line in `~/.zshrc` turns `wt` from a path-printing binary +into a navigator: + +```sh +eval "$(wt shell-init zsh)" +``` + +Add `--prompt` to also get the [prompt indicator](#prompt-indicator). +`wt` never edits `.zshrc` for you, and `wt uninstall` (Phase 7) +tells you the exact line to delete. + +## The cd protocol + +A child process cannot change its parent shell's directory, +so the binary and the shim split the work +(the zoxide/starship pattern): +the binary prints the target path on stdout, +and the `wt()` function the eval line installed performs the `cd`. + +Only three invocations produce a cd — +bare `wt`, `wt go`, and `wt new`: + +```sh +wt # pick a tree interactively → cd +wt go login # fuzzy-jump to the best match → cd +wt new feature/pay # create tree + branch → cd into it +``` + +Every other command passes through the wrapper untouched. +`wt path` deliberately never cds — it is plumbing, +built for things like `code "$(wt path login)"`. + +Without the shim everything still works; +cd-producing commands simply print the path instead: + +```sh +cd "$(wt go login)" +``` + +Capture works with the shim installed too: +`$(wt go login)` runs the wrapper in a subshell, +so the wrapper hands the path through +whenever stdout is not a terminal. + +## Fuzzy matching rules + +`wt go ` matches against each tree's branch name, +its sanitized directory form (`feature/login` → `feature-login`), +and its directory basename: + +1. An exact spelling wins outright, branch names before directory names. +2. Otherwise the best fuzzy match wins — + but only when it strictly beats the runner-up on match quality. +3. A tie is ambiguous: the contenders are listed on stderr + and `wt` exits 3 without guessing. + +Match quality ignores name length, +so `wt go feature` refuses to choose between +`feature/login` and `feature/logout` +rather than "winning" on the shorter name. + +## The picker + +Bare `wt` (and bare `wt go`) opens a fuzzy picker over all trees; +the preview pane shows each tree's `git status -sb` and last commit. +Enter cds to the selection, Esc cancels. + +The picker only appears when stdin and stderr are TTYs. +Anything piped or captured — scripts, CI, coding agents — +gets the stable `wt ls --porcelain` listing on stdout instead, +so nothing ever hangs waiting for a human +(see [agents.md](agents.md)). + +The picker TUI itself is verified by hand +(create a few trees, run bare `wt`, check preview/select/cancel); +everything around it — resolution, fallback, the cd protocol — +is covered by the test suite. + +## Completions + +The shim registers cobra-generated zsh completions lazily: +the real completion script is loaded from the binary +on the first `wt `, +so completions always match the installed binary +and shell startup never pays for them. +They activate only if `compinit` has run before the eval line. + +## Prompt indicator + +Opt in with: + +```sh +eval "$(wt shell-init zsh --prompt)" +``` + +A `chpwd` hook exports `WT_PROMPT` with the tree's directory name +while the cwd is inside a linked worktree, and unsets it elsewhere. +The hook is pure zsh — file stats and one `read`, +recomputed only when the directory changes; +it never runs a subprocess, +so your prompt latency is untouched. + +Use it anywhere zsh expands prompts: + +```sh +setopt prompt_subst +PROMPT='%~${WT_PROMPT:+ (⌂ $WT_PROMPT)} %# ' +``` + +### Starship + +Prefer [starship](https://starship.rs)? Skip `--prompt` and add a +[custom command](https://starship.rs/config/#custom-commands) +segment instead: + +```toml +[custom.wt] +command = "basename $PWD" +when = '[ -f .git ] && grep -q "gitdir:.*worktrees" .git' +symbol = "⌂ " +style = "bold blue" +``` + +Starship runs `when` per prompt render; +it is two stats and a grep, comfortably under its 500ms budget. +Unlike the `WT_PROMPT` hook it only fires at a tree's root — +subdirectories of a tree show no segment. diff --git a/go.mod b/go.mod index 803fa85..baafd29 100644 --- a/go.mod +++ b/go.mod @@ -5,9 +5,12 @@ go 1.25.8 require ( charm.land/huh/v2 v2.0.3 github.com/google/go-cmp v0.7.0 + github.com/ktr0731/go-fuzzyfinder v0.9.0 github.com/pelletier/go-toml/v2 v2.4.3 github.com/rogpeppe/go-internal v1.15.0 + github.com/sahilm/fuzzy v0.1.3 github.com/spf13/cobra v1.10.2 + golang.org/x/term v0.45.0 ) require ( @@ -27,15 +30,21 @@ require ( github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect + github.com/gdamore/encoding v1.0.1 // indirect + github.com/gdamore/tcell/v2 v2.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/ktr0731/go-ansisgr v0.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-runewidth v0.0.20 // indirect github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/muesli/cancelreader v0.2.2 // indirect + github.com/nsf/termbox-go v1.1.1 // indirect + github.com/pkg/errors v0.9.1 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/spf13/pflag v1.0.9 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/sync v0.19.0 // indirect - golang.org/x/sys v0.42.0 // indirect - golang.org/x/tools v0.26.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.24.0 // indirect + golang.org/x/tools v0.35.0 // indirect ) diff --git a/go.sum b/go.sum index 3f1e0cf..61d8f4d 100644 --- a/go.sum +++ b/go.sum @@ -47,38 +47,97 @@ github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg= +github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw= +github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo= +github.com/gdamore/tcell/v2 v2.6.0 h1:OKbluoP9VYmJwZwq/iLb4BxwKcwGthaa1YNBJIyCySg= +github.com/gdamore/tcell/v2 v2.6.0/go.mod h1:be9omFATkdr0D9qewWW3d+MEvl5dha+Etb5y65J2H8Y= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/ktr0731/go-ansisgr v0.1.0 h1:fbuupput8739hQbEmZn1cEKjqQFwtCCZNznnF6ANo5w= +github.com/ktr0731/go-ansisgr v0.1.0/go.mod h1:G9lxwgBwH0iey0Dw5YQd7n6PmQTwTuTM/X5Sgm/UrzE= +github.com/ktr0731/go-fuzzyfinder v0.9.0 h1:JV8S118RABzRl3Lh/RsPhXReJWc2q0rbuipzXQH7L4c= +github.com/ktr0731/go-fuzzyfinder v0.9.0/go.mod h1:uybx+5PZFCgMCSDHJDQ9M3nNKx/vccPmGffsXPn2ad8= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= +github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ= github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/nsf/termbox-go v1.1.1 h1:nksUPLCb73Q++DwbYUBEglYBRPZyoXJdrj5L+TkjyZY= +github.com/nsf/termbox-go v1.1.1/go.mod h1:T0cTdVuOwf7pHQNtfhnEbzHbcNyCEcVU4YPpouCbVxo= github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY= github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc= github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sahilm/fuzzy v0.1.3 h1:juByESSS32nVD81vr6tHmKmA/8zde7gE+x5CLxrzXPU= +github.com/sahilm/fuzzy v0.1.3/go.mod h1:au6//VbVSqu6DFrkL2CfjlJ5iURpNCPeE+1GwY3XsT8= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/tools v0.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ= -golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= +golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0= +golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/cli/go.go b/internal/cli/go.go new file mode 100644 index 0000000..da419c6 --- /dev/null +++ b/internal/cli/go.go @@ -0,0 +1,85 @@ +package cli + +import ( + "errors" + "fmt" + + "github.com/spf13/cobra" + + "github.com/loganthomas/wt/internal/gitx" + "github.com/loganthomas/wt/internal/nav" +) + +func newGoCmd() *cobra.Command { + return &cobra.Command{ + Use: "go [query]", + Short: "Fuzzy-jump to a tree", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + // An explicitly empty query must not alias the bare + // form: `cd "$(wt go "$Q")"` with Q unset would get + // exit 0 and a listing instead of a loud failure. + if len(args) == 1 && args[0] == "" { + return usageError{errors.New("empty query — drop the argument for the picker")} + } + return runJump(cmd, nameArg(args)) + }, + } +} + +// runJump is the one navigation path behind bare `wt`, bare +// `wt go`, and `wt go `: it ends with a single tree path +// on stdout, which the shell shim turns into a cd (D11). +func runJump(cmd *cobra.Command, query string) error { + trees, err := repoTrees(cmd.Context()) + if err != nil { + return err + } + if query == "" { + return jumpInteractive(cmd, trees) + } + winner, contenders := nav.Resolve(jumpCandidates(trees), query) + switch { + case winner != nil: + fmt.Fprintln(cmd.OutOrStdout(), winner.Path) + return nil + case len(contenders) > 0: + chatter := cmd.ErrOrStderr() + fmt.Fprintf(chatter, "closest matches for %q:\n", query) + for _, c := range contenders { + fmt.Fprintf(chatter, " %-24s %s\n", c.Display(), c.Path) + } + return preconditionf("%q is ambiguous — narrow the query, or run bare `wt` to pick", query) + default: + return errNoTreeMatches(query) + } +} + +// jumpInteractive opens the picker when a human is present; +// otherwise it degrades to the porcelain listing so scripts and +// agents never hang on a TUI (D12, R15). +func jumpInteractive(cmd *cobra.Command, trees []gitx.Worktree) error { + if !interactive() { + _, err := fmt.Fprint(cmd.OutOrStdout(), formatPorcelain(trees)) + return err + } + choice, err := pickTree(cmd.Context(), jumpCandidates(trees)) + if err != nil { + return err + } + fmt.Fprintln(cmd.OutOrStdout(), choice.Path) + return nil +} + +// jumpCandidates converts worktrees into jump targets. +// Bare entries are dropped: they have no checkout to land in. +func jumpCandidates(trees []gitx.Worktree) []nav.Candidate { + cands := make([]nav.Candidate, 0, len(trees)) + for _, t := range trees { + if t.Bare { + continue + } + cands = append(cands, nav.Candidate{Branch: t.Branch, Path: t.Path}) + } + return cands +} diff --git a/internal/cli/init.go b/internal/cli/init.go index 15d5f5e..dd2c3e7 100644 --- a/internal/cli/init.go +++ b/internal/cli/init.go @@ -73,7 +73,7 @@ func runInit(cmd *cobra.Command, opts initOptions) error { } if !opts.yes { - if !stdinIsTerminal() { + if !isTerminal(os.Stdin) { return usageError{errors.New( "stdin is not a terminal; run `wt init --yes` with value flags instead")} } @@ -171,9 +171,3 @@ func splitList(s string) []string { } return out } - -// stdinIsTerminal reports whether prompts can be shown at all. -func stdinIsTerminal() bool { - info, err := os.Stdin.Stat() - return err == nil && info.Mode()&os.ModeCharDevice != 0 -} diff --git a/internal/cli/ls.go b/internal/cli/ls.go index 6cb3343..1ff05ff 100644 --- a/internal/cli/ls.go +++ b/internal/cli/ls.go @@ -1,6 +1,7 @@ package cli import ( + "cmp" "fmt" "strings" "unicode/utf8" @@ -11,23 +12,46 @@ import ( ) func newLsCmd() *cobra.Command { - return &cobra.Command{ + var porcelain bool + cmd := &cobra.Command{ Use: "ls", Short: "List worktrees", Args: cobra.NoArgs, - RunE: runLs, + RunE: func(cmd *cobra.Command, _ []string) error { + return runLs(cmd, porcelain) + }, } + cmd.Flags().BoolVar(&porcelain, "porcelain", false, + "stable tab-separated output for scripts") + return cmd } -func runLs(cmd *cobra.Command, _ []string) error { +func runLs(cmd *cobra.Command, porcelain bool) error { trees, err := repoTrees(cmd.Context()) if err != nil { return err } - _, err = fmt.Fprint(cmd.OutOrStdout(), formatRows(trees)) + format := formatRows + if porcelain { + format = formatPorcelain + } + _, err = fmt.Fprint(cmd.OutOrStdout(), format(trees)) return err } +// formatPorcelain renders the stable machine format: +// one line per tree, three tab-separated fields +// (branch label, path, comma-joined states). +// An empty state becomes "-" so the field count never varies +// and awk/cut consumers can rely on positions (D13). +func formatPorcelain(trees []gitx.Worktree) string { + var out strings.Builder + for _, t := range trees { + fmt.Fprintf(&out, "%s\t%s\t%s\n", branchLabel(t), t.Path, cmp.Or(stateLabel(t), "-")) + } + return out.String() +} + // formatRows renders one aligned row per worktree. // Widths are computed by hand rather than with text/tabwriter: // padding must only ever sit between cells, diff --git a/internal/cli/ls_test.go b/internal/cli/ls_test.go index f1f50d6..22086ac 100644 --- a/internal/cli/ls_test.go +++ b/internal/cli/ls_test.go @@ -37,6 +37,20 @@ func TestFormatRowsPreservesPathTrailingSpace(t *testing.T) { } } +func TestFormatPorcelainEmitsFixedTabSeparatedFields(t *testing.T) { + got := formatPorcelain([]gitx.Worktree{ + {Branch: "main", Path: "/repo", Locked: true, Prunable: true}, + {Branch: "feature/login", Path: "/repo.trees/feature-login"}, + {Detached: true, Path: "/repo.trees/scratch"}, + }) + want := "main\t/repo\tlocked,prunable\n" + + "feature/login\t/repo.trees/feature-login\t-\n" + + "(detached)\t/repo.trees/scratch\t-\n" + if got != want { + t.Errorf("formatPorcelain() = %q, want %q", got, want) + } +} + func TestFormatRowsJoinsMultipleStates(t *testing.T) { rows := formatRows([]gitx.Worktree{ {Branch: "old", Path: "/gone", Locked: true, Prunable: true}, diff --git a/internal/cli/picker.go b/internal/cli/picker.go new file mode 100644 index 0000000..d0b39bb --- /dev/null +++ b/internal/cli/picker.go @@ -0,0 +1,64 @@ +package cli + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/ktr0731/go-fuzzyfinder" + + "github.com/loganthomas/wt/internal/gitx" + "github.com/loganthomas/wt/internal/nav" +) + +// pickTree opens the fuzzy picker over all jump targets and +// returns the chosen one. Only ever called when interactive() +// holds; the TUI renders on /dev/tty, so a shim-captured stdout +// stays clean for the cd protocol. +// The TUI itself is verified by hand (see docs/shell.md); +// everything around it is covered by the non-TTY tests. +func pickTree(ctx context.Context, cands []nav.Candidate) (nav.Candidate, error) { + if len(cands) == 0 { + return nav.Candidate{}, errors.New("no trees to pick from — `wt new ` creates one") + } + // Previews shell out to git, so each is rendered once and + // cached: the picker re-asks on every keystroke. + previews := make([]string, len(cands)) + idx, err := fuzzyfinder.Find(cands, + func(i int) string { return cands[i].Display() }, + fuzzyfinder.WithPreviewWindow(func(i, _, _ int) string { + if i < 0 { + return "" + } + if previews[i] == "" { + previews[i] = preview(ctx, cands[i]) + } + return previews[i] + }), + ) + if err != nil { + if errors.Is(err, fuzzyfinder.ErrAbort) { + return nav.Candidate{}, errors.New("cancelled") + } + return nav.Candidate{}, err + } + return cands[idx], nil +} + +// preview renders one tree's pane: where it is, what state it's +// in, and what it last did. Errors degrade to omission; a +// broken tree should still be pickable, if only to jump in and +// repair it. +func preview(ctx context.Context, c nav.Candidate) string { + g := gitx.New(c.Path) + var b strings.Builder + fmt.Fprintf(&b, "%s\n%s\n", c.Display(), c.Path) + if status, err := g.ShortStatus(ctx); err == nil { + fmt.Fprintf(&b, "\n%s\n", status) + } + if last, err := g.LastCommit(ctx); err == nil { + fmt.Fprintf(&b, "\n%s\n", last) + } + return b.String() +} diff --git a/internal/cli/root.go b/internal/cli/root.go index 4633cef..1f141da 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -12,6 +12,8 @@ import ( "os" "github.com/spf13/cobra" + + "github.com/loganthomas/wt/internal/repo" ) const ( @@ -83,9 +85,11 @@ func newRootCmd(info BuildInfo) *cobra.Command { newInitCmd(), newNewCmd(), newLsCmd(), + newGoCmd(), newDoneCmd(), newPathCmd(), newConfigCmd(), + newShellInitCmd(), ) // Argument validators are wrapped centrally so bad arguments // exit 2 (D13) on every command, present and future: @@ -106,9 +110,19 @@ func wrapUsageArgs(cmd *cobra.Command) { } } -// runRoot handles bare `wt`: help for now, the fuzzy picker in Phase 3. +// runRoot handles bare `wt`: the most frequent intent is +// "take me to a tree", so the picker is the default (D12). +// Bare `wt` is also how newcomers poke at the tool, and it +// replaced the old show-help default, so the not-a-repo error +// alone would be a dead end; point at --help while keeping the +// error and its exit 4 intact. func runRoot(cmd *cobra.Command, _ []string) error { - return cmd.Help() + err := runJump(cmd, "") + var notRepo *repo.NotARepoError + if errors.As(err, ¬Repo) { + return fmt.Errorf("%w — `wt --help` shows usage", err) + } + return err } func wrapFlagError(_ *cobra.Command, err error) error { diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index b56c21b..99fefce 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -16,6 +16,7 @@ func TestUsageErrorsExitTwo(t *testing.T) { {"unknown flag", []string{"--definitely-not-a-flag"}}, {"unknown command", []string{"bogus"}}, {"unexpected argument", []string{"ls", "unexpected"}}, + {"unsupported shell", []string{"shell-init", "bash"}}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/internal/cli/shellinit.go b/internal/cli/shellinit.go new file mode 100644 index 0000000..df923c1 --- /dev/null +++ b/internal/cli/shellinit.go @@ -0,0 +1,48 @@ +package cli + +import ( + _ "embed" + "fmt" + + "github.com/spf13/cobra" +) + +// The shim ships as plain zsh in two pieces, base and opt-in +// prompt hook, so each file stays directly readable and +// checkable as zsh; --prompt simply appends the second. +// +//go:embed shim.zsh +var shimZsh string + +//go:embed shim_prompt.zsh +var shimPromptZsh string + +func newShellInitCmd() *cobra.Command { + var prompt bool + cmd := &cobra.Command{ + Use: "shell-init zsh", + Short: "Emit the shell integration for eval in ~/.zshrc", + Args: cobra.MatchAll(cobra.ExactArgs(1), func(_ *cobra.Command, args []string) error { + if args[0] != "zsh" { + return fmt.Errorf("unsupported shell %q — v1 supports zsh only", args[0]) + } + return nil + }), + RunE: func(cmd *cobra.Command, _ []string) error { + return runShellInit(cmd, prompt) + }, + } + cmd.Flags().BoolVar(&prompt, "prompt", false, "include the WT_PROMPT indicator hook") + return cmd +} + +// runShellInit writes the shim to stdout: the script itself is +// the machine output here, consumed by eval in .zshrc (D11). +func runShellInit(cmd *cobra.Command, prompt bool) error { + out := shimZsh + if prompt { + out += shimPromptZsh + } + _, err := fmt.Fprint(cmd.OutOrStdout(), out) + return err +} diff --git a/internal/cli/shellinit_test.go b/internal/cli/shellinit_test.go new file mode 100644 index 0000000..b79b37b --- /dev/null +++ b/internal/cli/shellinit_test.go @@ -0,0 +1,80 @@ +package cli + +import ( + "bytes" + "flag" + "io" + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/google/go-cmp/cmp" +) + +var update = flag.Bool("update", false, "rewrite golden files") + +var shellInitVariants = []struct { + name string + golden string + args []string +}{ + {"plain", "shell-init.zsh", []string{"shell-init", "zsh"}}, + {"prompt", "shell-init-prompt.zsh", []string{"shell-init", "zsh", "--prompt"}}, +} + +func TestShellInitMatchesGolden(t *testing.T) { + for _, tt := range shellInitVariants { + t.Run(tt.name, func(t *testing.T) { + got := execute(t, tt.args...) + golden := filepath.Join("testdata", "golden", tt.golden) + if *update { + if err := os.WriteFile(golden, []byte(got), 0o644); err != nil { + t.Fatal(err) + } + } + want, err := os.ReadFile(golden) + if err != nil { + t.Fatal(err) + } + if diff := cmp.Diff(string(want), got); diff != "" { + t.Errorf("shell-init output drifted from %s (-want +got):\n%s"+ + "\nrun `go test ./internal/cli -update` if the change is intended", golden, diff) + } + }) + } +} + +// The emitted script must parse before it can be trusted in +// anyone's .zshrc; zsh -n is the cheapest honest check. +func TestShellInitEmitsValidZsh(t *testing.T) { + zsh, err := exec.LookPath("zsh") + if err != nil { + t.Skip("zsh not on PATH") + } + for _, tt := range shellInitVariants { + t.Run(tt.name, func(t *testing.T) { + script := filepath.Join(t.TempDir(), "shim.zsh") + if err := os.WriteFile(script, []byte(execute(t, tt.args...)), 0o644); err != nil { + t.Fatal(err) + } + if out, err := exec.Command(zsh, "-n", script).CombinedOutput(); err != nil { + t.Errorf("zsh -n rejected the emitted script: %v\n%s", err, out) + } + }) + } +} + +// execute runs the wt command tree in-process and returns stdout. +func execute(t *testing.T, args ...string) string { + t.Helper() + root := newRootCmd(BuildInfo{}) + root.SetArgs(args) + var out bytes.Buffer + root.SetOut(&out) + root.SetErr(io.Discard) + if err := root.Execute(); err != nil { + t.Fatalf("wt %v: %v", args, err) + } + return out.String() +} diff --git a/internal/cli/shim.zsh b/internal/cli/shim.zsh new file mode 100644 index 0000000..73d21c1 --- /dev/null +++ b/internal/cli/shim.zsh @@ -0,0 +1,56 @@ +# wt zsh integration, generated by `wt shell-init zsh`. +# Wire it up with one line in ~/.zshrc: +# +# eval "$(wt shell-init zsh)" + +# The cd protocol (PLAN.md D11): cd-producing commands print their +# target path on stdout, and this wrapper performs the cd the +# binary cannot. Every other command passes through untouched, so +# scripts that call `wt` under the wrapper see identical behavior. +# A pre-existing `wt` alias would expand inside this very eval and +# abort it wholesale; clear it, and use the `function` keyword, +# whose name position is never alias-expanded. +# The `|| :` keeps err_exit shells alive when no alias exists. +builtin unalias wt 2>/dev/null || : +function wt { + # emulate pins zsh semantics against exotic user setopts; + # builtins dodge same-named user functions and aliases. + emulate -L zsh + case "${1-}" in + ""|go|new) + local out + out="$(command wt "$@")" || return $? + if [[ -n "$out" && -d "$out" ]]; then + builtin cd -- "$out" || return $? + # $(wt ...) runs this wrapper in a subshell where the cd is + # invisible; hand the path through so capture keeps working. + [[ -t 1 ]] || builtin print -r -- "$out" + elif [[ -n "$out" ]]; then + builtin print -r -- "$out" + fi + ;; + *) + command wt "$@" + ;; + esac +} + +# Completions come from the binary itself, so they can never go +# stale; compdef only exists once compinit has run, and without it +# there is nothing to register with. Registration is lazy, the +# real script loads on the first completion, so shells that never +# tab-complete wt pay no extra binary spawn at startup. +if (( ${+functions[compdef]} )); then + _wt_bootstrap() { + # By the time a completion runs, compsys has already + # sanitized exotic setopts (ksh_arrays and friends), so a + # plain eval is safe here, and deliberately unwrapped: + # sticky emulation would leak into compsys internals and + # leave _describe unable to offer matches. The script's + # final compdef rebinds wt straight to _wt for later Tabs. + eval "$(command wt completion zsh)" + builtin unfunction _wt_bootstrap + _wt "$@" + } + compdef _wt_bootstrap wt +fi diff --git a/internal/cli/shim_prompt.zsh b/internal/cli/shim_prompt.zsh new file mode 100644 index 0000000..ad062ec --- /dev/null +++ b/internal/cli/shim_prompt.zsh @@ -0,0 +1,31 @@ + +# Prompt indicator (opt-in): WT_PROMPT carries the tree's name +# while the cwd is inside a linked worktree, for use like +# PROMPT='...${WT_PROMPT:+ ⌂$WT_PROMPT}...' (with setopt prompt_subst) +# Pure zsh: a few stats and one builtin read, recomputed only on +# cd, never per prompt render (PLAN.md R14). Deliberately uncached: +# trees come and go, and a cache would pin a dead answer for the +# life of the shell. +_wt_prompt_update() { + emulate -L zsh + unset WT_PROMPT + local dir="$PWD" line + while [[ -n "$dir" && "$dir" != / ]]; do + if [[ -f "$dir/.git" ]]; then + # a .git *file* marks a linked worktree: its gitdir line + # points into the repository's shared .git/worktrees/ + IFS= read -r line < "$dir/.git" 2>/dev/null + if [[ "$line" == gitdir:*/worktrees/* ]]; then + export WT_PROMPT="${dir:t}" + fi + break + elif [[ -e "$dir/.git" ]]; then + break + fi + dir="${dir:h}" + done + return 0 +} +builtin emulate zsh -c 'autoload -Uz add-zsh-hook' +add-zsh-hook chpwd _wt_prompt_update +_wt_prompt_update diff --git a/internal/cli/testdata/golden/shell-init-prompt.zsh b/internal/cli/testdata/golden/shell-init-prompt.zsh new file mode 100644 index 0000000..b3832a1 --- /dev/null +++ b/internal/cli/testdata/golden/shell-init-prompt.zsh @@ -0,0 +1,87 @@ +# wt zsh integration, generated by `wt shell-init zsh`. +# Wire it up with one line in ~/.zshrc: +# +# eval "$(wt shell-init zsh)" + +# The cd protocol (PLAN.md D11): cd-producing commands print their +# target path on stdout, and this wrapper performs the cd the +# binary cannot. Every other command passes through untouched, so +# scripts that call `wt` under the wrapper see identical behavior. +# A pre-existing `wt` alias would expand inside this very eval and +# abort it wholesale; clear it, and use the `function` keyword, +# whose name position is never alias-expanded. +# The `|| :` keeps err_exit shells alive when no alias exists. +builtin unalias wt 2>/dev/null || : +function wt { + # emulate pins zsh semantics against exotic user setopts; + # builtins dodge same-named user functions and aliases. + emulate -L zsh + case "${1-}" in + ""|go|new) + local out + out="$(command wt "$@")" || return $? + if [[ -n "$out" && -d "$out" ]]; then + builtin cd -- "$out" || return $? + # $(wt ...) runs this wrapper in a subshell where the cd is + # invisible; hand the path through so capture keeps working. + [[ -t 1 ]] || builtin print -r -- "$out" + elif [[ -n "$out" ]]; then + builtin print -r -- "$out" + fi + ;; + *) + command wt "$@" + ;; + esac +} + +# Completions come from the binary itself, so they can never go +# stale; compdef only exists once compinit has run, and without it +# there is nothing to register with. Registration is lazy, the +# real script loads on the first completion, so shells that never +# tab-complete wt pay no extra binary spawn at startup. +if (( ${+functions[compdef]} )); then + _wt_bootstrap() { + # By the time a completion runs, compsys has already + # sanitized exotic setopts (ksh_arrays and friends), so a + # plain eval is safe here, and deliberately unwrapped: + # sticky emulation would leak into compsys internals and + # leave _describe unable to offer matches. The script's + # final compdef rebinds wt straight to _wt for later Tabs. + eval "$(command wt completion zsh)" + builtin unfunction _wt_bootstrap + _wt "$@" + } + compdef _wt_bootstrap wt +fi + +# Prompt indicator (opt-in): WT_PROMPT carries the tree's name +# while the cwd is inside a linked worktree, for use like +# PROMPT='...${WT_PROMPT:+ ⌂$WT_PROMPT}...' (with setopt prompt_subst) +# Pure zsh: a few stats and one builtin read, recomputed only on +# cd, never per prompt render (PLAN.md R14). Deliberately uncached: +# trees come and go, and a cache would pin a dead answer for the +# life of the shell. +_wt_prompt_update() { + emulate -L zsh + unset WT_PROMPT + local dir="$PWD" line + while [[ -n "$dir" && "$dir" != / ]]; do + if [[ -f "$dir/.git" ]]; then + # a .git *file* marks a linked worktree: its gitdir line + # points into the repository's shared .git/worktrees/ + IFS= read -r line < "$dir/.git" 2>/dev/null + if [[ "$line" == gitdir:*/worktrees/* ]]; then + export WT_PROMPT="${dir:t}" + fi + break + elif [[ -e "$dir/.git" ]]; then + break + fi + dir="${dir:h}" + done + return 0 +} +builtin emulate zsh -c 'autoload -Uz add-zsh-hook' +add-zsh-hook chpwd _wt_prompt_update +_wt_prompt_update diff --git a/internal/cli/testdata/golden/shell-init.zsh b/internal/cli/testdata/golden/shell-init.zsh new file mode 100644 index 0000000..73d21c1 --- /dev/null +++ b/internal/cli/testdata/golden/shell-init.zsh @@ -0,0 +1,56 @@ +# wt zsh integration, generated by `wt shell-init zsh`. +# Wire it up with one line in ~/.zshrc: +# +# eval "$(wt shell-init zsh)" + +# The cd protocol (PLAN.md D11): cd-producing commands print their +# target path on stdout, and this wrapper performs the cd the +# binary cannot. Every other command passes through untouched, so +# scripts that call `wt` under the wrapper see identical behavior. +# A pre-existing `wt` alias would expand inside this very eval and +# abort it wholesale; clear it, and use the `function` keyword, +# whose name position is never alias-expanded. +# The `|| :` keeps err_exit shells alive when no alias exists. +builtin unalias wt 2>/dev/null || : +function wt { + # emulate pins zsh semantics against exotic user setopts; + # builtins dodge same-named user functions and aliases. + emulate -L zsh + case "${1-}" in + ""|go|new) + local out + out="$(command wt "$@")" || return $? + if [[ -n "$out" && -d "$out" ]]; then + builtin cd -- "$out" || return $? + # $(wt ...) runs this wrapper in a subshell where the cd is + # invisible; hand the path through so capture keeps working. + [[ -t 1 ]] || builtin print -r -- "$out" + elif [[ -n "$out" ]]; then + builtin print -r -- "$out" + fi + ;; + *) + command wt "$@" + ;; + esac +} + +# Completions come from the binary itself, so they can never go +# stale; compdef only exists once compinit has run, and without it +# there is nothing to register with. Registration is lazy, the +# real script loads on the first completion, so shells that never +# tab-complete wt pay no extra binary spawn at startup. +if (( ${+functions[compdef]} )); then + _wt_bootstrap() { + # By the time a completion runs, compsys has already + # sanitized exotic setopts (ksh_arrays and friends), so a + # plain eval is safe here, and deliberately unwrapped: + # sticky emulation would leak into compsys internals and + # leave _describe unable to offer matches. The script's + # final compdef rebinds wt straight to _wt for later Tabs. + eval "$(command wt completion zsh)" + builtin unfunction _wt_bootstrap + _wt "$@" + } + compdef _wt_bootstrap wt +fi diff --git a/internal/cli/testdata/script/go.txtar b/internal/cli/testdata/script/go.txtar new file mode 100644 index 0000000..5f9d43b --- /dev/null +++ b/internal/cli/testdata/script/go.txtar @@ -0,0 +1,74 @@ +# wt go resolves a fuzzy query to one tree path on stdout. +# Without a TTY the bare forms fall back to the porcelain listing, +# so scripts and agents never hang on a picker (D12, R15). + +[!exec:git] skip 'git not on PATH' + +exec git init -q -b main acme +cd acme +exec git commit -q --allow-empty -m 'initial' +exec wt init --yes +exec wt new feature/login +exec wt new feature/logout + +# a decisive query prints the tree path, nothing else +exec wt go login +stdout '[/\\]feature-login$' +! stderr . + +# exact spellings win outright: full branch and sanitized form +exec wt go feature/login +stdout '[/\\]feature-login$' +exec wt go feature-logout +stdout '[/\\]feature-logout$' + +# the main checkout is a jump target too +exec wt go main +stdout '[/\\]acme$' + +# an ambiguous query lists the contenders on stderr and exits 3 +exitcode 3 wt go feature +stderr 'ambiguous' +stderr 'feature/login' +stderr 'feature/logout' +! stdout . + +# no match: exit 1 with a hint, empty stdout +exitcode 1 wt go zzz +stderr 'no tree matches' +! stdout . + +# an explicitly empty query is a usage error, never the bare form: +# `cd "$(wt go "$Q")"` with Q unset must fail loudly, not exit 0 +# with a listing on stdout +exitcode 2 wt go '' +stderr 'empty query' +! stdout . + +# bare `wt go` without a TTY prints the porcelain listing +exec wt go +stdout '^main\t.*[/\\]acme\t-$' +stdout '^feature/login\t' +stdout '^feature/logout\t' +! stderr . + +# bare `wt` behaves identically +exec wt +stdout '^main\t.*[/\\]acme\t-$' +stdout '^feature/login\t' +! stderr . + +# outside any repository the bare form still honors exit 4, and +# since bare `wt` replaced the old help default it also points +# newcomers at --help +mkdir $WORK/elsewhere +cd $WORK/elsewhere +exitcode 4 wt +stderr 'not inside a git repository' +stderr 'wt --help' +! stdout . + +# subcommands keep the plain message: the user already knows wt +exitcode 4 wt go login +stderr 'not inside a git repository' +! stderr 'wt --help' diff --git a/internal/cli/testdata/script/ls.txtar b/internal/cli/testdata/script/ls.txtar index 78d3ad7..6a03fd6 100644 --- a/internal/cli/testdata/script/ls.txtar +++ b/internal/cli/testdata/script/ls.txtar @@ -18,6 +18,14 @@ exec git worktree add -q --detach ../trees/scratch exec wt ls stdout '\(detached\)\s+.*[/\\]scratch' +# --porcelain: stable tab-separated fields, '-' for an empty state, +# so field count never varies for awk/cut consumers +exec wt ls --porcelain +stdout '^main\t.*[/\\]repo\t-$' +stdout '^feature/login\t.*[/\\]login\t-$' +stdout '^\(detached\)\t.*[/\\]scratch\t-$' +! stderr . + # outside any repository: exit 4 (D13), explanation on stderr only mkdir $WORK/elsewhere cd $WORK/elsewhere diff --git a/internal/cli/testdata/script/shellinit.txtar b/internal/cli/testdata/script/shellinit.txtar new file mode 100644 index 0000000..b1dc2ec --- /dev/null +++ b/internal/cli/testdata/script/shellinit.txtar @@ -0,0 +1,99 @@ +# The eval line is the product of Phase 3: a zsh that evals +# `wt shell-init zsh` gets cd-on-select, pass-through for every +# other command, and (opted in) the WT_PROMPT indicator. +# The scripts live in files so their $variables reach zsh intact. + +[!exec:zsh] skip 'zsh not on PATH' +[!exec:git] skip 'git not on PATH' + +exec git init -q -b main acme +cd acme +exec git commit -q --allow-empty -m 'initial' +exec wt init --yes +exec wt new feature/login +cd $WORK + +# a decisive `wt go` becomes a cd in the calling shell +exec zsh jump.zsh +stdout '[/\\]feature-login$' +! stderr . + +# non-cd commands pass through the wrapper untouched +exec zsh passthrough.zsh +stdout '^feature/login\t' + +# `wt new` lands the shell in the fresh tree +exec zsh new.zsh +stdout '[/\\]feature-pay$' + +# the prompt hook tracks entering and leaving a tree +exec zsh prompt.zsh +stdout '^in=feature-login$' +stdout '^out=unset$' +stdout '^gone=unset$' +! stderr . + +# capture keeps working under the shim: $(wt ...) runs the wrapper +# in a subshell where the cd is invisible, so the wrapper hands the +# path through whenever stdout is not a terminal +exec zsh capture.zsh +stdout '^captured=.*[/\\]feature-login$' + +# bare `wt` through the wrapper re-emits the porcelain listing +exec zsh bare.zsh +stdout '^feature/login\t' +stdout '^main\t' + +# failure exit codes survive the wrapper's capture-or-cd plumbing: +# no match stays 1, ambiguity stays 3 (D13) +exec zsh status.zsh +stdout '^nomatch=1$' +stdout '^ambiguous=3$' + +-- jump.zsh -- +eval "$(command wt shell-init zsh)" +cd acme +wt go login +pwd + +-- passthrough.zsh -- +eval "$(command wt shell-init zsh)" +cd acme +wt ls --porcelain + +-- new.zsh -- +eval "$(command wt shell-init zsh)" +cd acme +wt new feature/pay 2>/dev/null +pwd + +-- capture.zsh -- +eval "$(command wt shell-init zsh)" +cd acme +p="$(wt go login)" +print -r -- "captured=$p" + +-- bare.zsh -- +eval "$(command wt shell-init zsh)" +cd acme +wt + +-- status.zsh -- +eval "$(command wt shell-init zsh)" +cd acme +wt go zzz 2>/dev/null +print -r -- "nomatch=$?" +wt go feature 2>/dev/null +print -r -- "ambiguous=$?" + +-- prompt.zsh -- +eval "$(command wt shell-init zsh --prompt)" +cd acme.trees/feature-login +print -r -- "in=${WT_PROMPT-unset}" +cd ../../acme +print -r -- "out=${WT_PROMPT-unset}" +# a revisited path must be re-inspected, never answered from a +# cache: the tree may have died in between +rm -f ../acme.trees/feature-login/.git +cd ../acme.trees/feature-login +print -r -- "gone=${WT_PROMPT-unset}" diff --git a/internal/cli/tree.go b/internal/cli/tree.go index cbcb5e6..0fe4bcd 100644 --- a/internal/cli/tree.go +++ b/internal/cli/tree.go @@ -3,16 +3,15 @@ package cli import ( "context" "fmt" - "path/filepath" "github.com/loganthomas/wt/internal/gitx" - "github.com/loganthomas/wt/internal/repo" + "github.com/loganthomas/wt/internal/nav" ) // resolveTree picks the worktree a command should act on. // An empty name means the tree containing the working directory; -// otherwise a tree matches by branch name, sanitized branch name, -// or directory basename, the three spellings a user might reach for. +// otherwise the accepted spellings are nav's exact tier, the +// same rule `wt go` applies, so the two can never drift apart. func resolveTree(ctx context.Context, trees []gitx.Worktree, name string) (gitx.Worktree, error) { if name == "" { top, err := gitx.New("").TopLevel(ctx) @@ -26,20 +25,27 @@ func resolveTree(ctx context.Context, trees []gitx.Worktree, name string) (gitx. } return gitx.Worktree{}, fmt.Errorf("git does not list the current tree %s", top) } - // Branch matches win over directory names: when one tree's - // directory happens to carry another tree's branch name, - // the user almost certainly means the branch. - for _, t := range trees { - if t.Branch != "" && (t.Branch == name || repo.SanitizeBranch(t.Branch) == name) { - return t, nil - } + // Every tree is a candidate here, bare entries included: + // exact-name commands may need to name states a jump never + // targets. + cands := make([]nav.Candidate, len(trees)) + for i, t := range trees { + cands[i] = nav.Candidate{Branch: t.Branch, Path: t.Path} } - for _, t := range trees { - if filepath.Base(t.Path) == name { - return t, nil + if winner := nav.ResolveExact(cands, name); winner != nil { + for _, t := range trees { + if t.Path == winner.Path { + return t, nil + } } } - return gitx.Worktree{}, fmt.Errorf("no tree matches %q — `wt ls` shows what exists", name) + return gitx.Worktree{}, errNoTreeMatches(name) +} + +// errNoTreeMatches is the one spelling of the miss error, shared +// by exact resolution and fuzzy jumps. +func errNoTreeMatches(name string) error { + return fmt.Errorf("no tree matches %q — `wt ls` shows what exists", name) } // nameArg unpacks the optional [name] positional argument. diff --git a/internal/cli/tty.go b/internal/cli/tty.go new file mode 100644 index 0000000..3d78011 --- /dev/null +++ b/internal/cli/tty.go @@ -0,0 +1,31 @@ +package cli + +import ( + "os" + + "golang.org/x/term" +) + +// interactive reports whether wt may open a TUI. +// Stdin and stderr are the probes, deliberately not stdout: the +// shell shim captures stdout to implement the cd protocol, so a +// TTY there can never be required, and the picker itself renders +// on /dev/tty. Scripts and agents run with piped or redirected +// streams and fall through to porcelain output instead (D12, R15). +// +// A dumb or unset TERM (emacs M-x shell, some CI PTYs) would pass +// the TTY probes and then hard-fail the picker's terminfo lookup; +// degrading to porcelain keeps the fallback graceful there too. +func interactive() bool { + if t := os.Getenv("TERM"); t == "" || t == "dumb" { + return false + } + return isTerminal(os.Stdin) && isTerminal(os.Stderr) +} + +// isTerminal is the one TTY probe in the package. The ioctl asks +// the fd itself; a stat-based ModeCharDevice check would call +// /dev/null a terminal and happily prompt into it. +func isTerminal(f *os.File) bool { + return term.IsTerminal(int(f.Fd())) +} diff --git a/internal/gitx/gitx.go b/internal/gitx/gitx.go index 93fda0d..6d952f7 100644 --- a/internal/gitx/gitx.go +++ b/internal/gitx/gitx.go @@ -73,6 +73,23 @@ func (g *Git) DeleteBranch(ctx context.Context, branch string) error { return err } +// ShortStatus returns `git status -sb` as git prints it: +// the branch line plus one line per change. +// It exists for the picker's preview pane, so the text is for +// human eyes and is never parsed. +func (g *Git) ShortStatus(ctx context.Context) (string, error) { + out, err := g.run(ctx, "status", "-sb") + if err != nil { + return "", err + } + return strings.TrimRight(string(out), "\n"), nil +} + +// LastCommit returns a one-line summary of the tree's HEAD. +func (g *Git) LastCommit(ctx context.Context) (string, error) { + return g.runLine(ctx, "log", "-1", "--format=%h %s (%cr)") +} + // StatusEntry is one line of `git status --porcelain -z` output. type StatusEntry struct { Code string // two-character XY status, e.g. "??", " M" diff --git a/internal/gitx/gitx_test.go b/internal/gitx/gitx_test.go index 78c1f12..5948168 100644 --- a/internal/gitx/gitx_test.go +++ b/internal/gitx/gitx_test.go @@ -4,6 +4,7 @@ import ( "os" "path/filepath" "slices" + "strings" "testing" "github.com/loganthomas/wt/internal/gittest" @@ -33,6 +34,35 @@ func TestStatusSkipsWorktreeRenameOrigin(t *testing.T) { } } +func TestShortStatusReportsBranchAndChanges(t *testing.T) { + gittest.Scrub(t) + dir := gittest.Repo(t, gittest.TempDir(t)) + gittest.WriteFile(t, dir, "a.txt", "hi\n") + + out, err := New(dir).ShortStatus(t.Context()) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{"## main", "?? a.txt"} { + if !strings.Contains(out, want) { + t.Errorf("ShortStatus() = %q, want it to contain %q", out, want) + } + } +} + +func TestLastCommitSummarizesHead(t *testing.T) { + gittest.Scrub(t) + dir := gittest.Repo(t, gittest.TempDir(t)) + + out, err := New(dir).LastCommit(t.Context()) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(out, "initial") || strings.Contains(out, "\n") { + t.Errorf("LastCommit() = %q, want a one-line summary of the initial commit", out) + } +} + // A hook environment exports GIT_DIR and friends for its own // repo; wt commands anchored elsewhere must not be retargeted. func TestRunScrubsRepoLocalEnv(t *testing.T) { diff --git a/internal/nav/nav.go b/internal/nav/nav.go new file mode 100644 index 0000000..c468381 --- /dev/null +++ b/internal/nav/nav.go @@ -0,0 +1,130 @@ +// Package nav resolves a user's query to the worktree they mean. +// It layers fuzzy matching (PLAN.md Phase 3) on top of the exact +// spellings the rest of wt accepts, with one deterministic rule: +// a query only ever jumps somewhere unambiguous. +package nav + +import ( + "cmp" + "path/filepath" + "slices" + + "github.com/sahilm/fuzzy" + + "github.com/loganthomas/wt/internal/repo" +) + +// maxContenders bounds the disambiguation list shown for an +// ambiguous query (PLAN.md Phase 3: top-5 on stderr). +const maxContenders = 5 + +// Candidate is one jump target: a worktree the user can land in. +type Candidate struct { + Branch string // checked-out branch; empty when detached + Path string // absolute tree root +} + +// Display names the candidate for human-facing lists. +func (c Candidate) Display() string { + if c.Branch != "" { + return c.Branch + } + return filepath.Base(c.Path) + " (detached)" +} + +// names are the spellings a query is matched against: +// the branch, its sanitized directory form, and the directory +// basename, the same three the exact-name commands accept. +// Overlapping spellings are harmless: scoring takes the best +// name, so a duplicate can never change the result. +func (c Candidate) names() []string { + names := []string{filepath.Base(c.Path)} + if c.Branch != "" { + names = append(names, c.Branch, repo.SanitizeBranch(c.Branch)) + } + return names +} + +// Resolve picks the candidate best matching query. +// Exact spellings win outright, branch names before directory +// names. Otherwise the best fuzzy score wins, but only when it +// strictly beats the runner-up: a tie never guesses, it returns +// the ranked contenders (at most five) instead. +// No match at all returns (nil, nil). +func Resolve(cands []Candidate, query string) (winner *Candidate, contenders []Candidate) { + if query == "" { + return nil, nil + } + if w := ResolveExact(cands, query); w != nil { + return w, nil + } + ranked := rank(cands, query) + switch { + case len(ranked) == 0: + return nil, nil + case len(ranked) == 1 || ranked[0].score > ranked[1].score: + return &ranked[0].Candidate, nil + default: + for _, r := range ranked[:min(len(ranked), maxContenders)] { + contenders = append(contenders, r.Candidate) + } + return nil, contenders + } +} + +// ResolveExact picks the candidate that name spells exactly, or +// nil. Branch spellings (the branch itself or its sanitized +// directory form) win over directory basenames: when one tree's +// directory carries another tree's branch name, the user almost +// certainly means the branch. +// This is the single owner of wt's accepted-spellings rule; +// the exact-name commands resolve through it too, so they can +// never drift from what `wt go` accepts. +func ResolveExact(cands []Candidate, name string) *Candidate { + if name == "" { + return nil + } + for i, c := range cands { + if c.Branch != "" && (c.Branch == name || repo.SanitizeBranch(c.Branch) == name) { + return &cands[i] + } + } + for i, c := range cands { + if filepath.Base(c.Path) == name { + return &cands[i] + } + } + return nil +} + +type scored struct { + Candidate + score int +} + +// rank orders the fuzzily matching candidates best-first, +// scoring each candidate by the best of its names. +// Ties keep input order, so the result is deterministic. +// +// Scores are match quality only: sahilm/fuzzy penalizes one +// point per leftover byte, and adding the byte length back +// cancels that. How well the query hits is what decides between +// trees, never how long the rest of a name happens to be; +// otherwise "feature" would "decisively" pick feature/login over +// feature/logout just because login is a letter shorter. +func rank(cands []Candidate, query string) []scored { + var ranked []scored + for _, c := range cands { + best, matched := 0, false + for _, m := range fuzzy.Find(query, c.names()) { + if quality := m.Score + len(m.Str); !matched || quality > best { + best, matched = quality, true + } + } + if matched { + ranked = append(ranked, scored{Candidate: c, score: best}) + } + } + slices.SortStableFunc(ranked, func(a, b scored) int { return cmp.Compare(b.score, a.score) }) + return ranked +} diff --git a/internal/nav/nav_test.go b/internal/nav/nav_test.go new file mode 100644 index 0000000..1786f92 --- /dev/null +++ b/internal/nav/nav_test.go @@ -0,0 +1,186 @@ +package nav + +import ( + "fmt" + "testing" + + "github.com/google/go-cmp/cmp" +) + +func candidates() []Candidate { + return []Candidate{ + {Branch: "main", Path: "/repos/acme"}, + {Branch: "feature/login", Path: "/repos/acme.trees/feature-login"}, + {Branch: "feature/logout", Path: "/repos/acme.trees/feature-logout"}, + {Branch: "", Path: "/repos/acme.trees/pool-3"}, + } +} + +func TestResolveExactBranchWins(t *testing.T) { + winner, contenders := Resolve(candidates(), "feature/login") + if winner == nil || winner.Branch != "feature/login" { + t.Fatalf("Resolve() winner = %v, contenders = %v; want feature/login", winner, contenders) + } +} + +func TestResolveExactSanitizedBranchWins(t *testing.T) { + winner, _ := Resolve(candidates(), "feature-login") + if winner == nil || winner.Branch != "feature/login" { + t.Fatalf("Resolve() winner = %v; want feature/login", winner) + } +} + +func TestResolveExactBasenameWinsForDetachedTrees(t *testing.T) { + winner, _ := Resolve(candidates(), "pool-3") + if winner == nil || winner.Path != "/repos/acme.trees/pool-3" { + t.Fatalf("Resolve() winner = %v; want the pool-3 tree", winner) + } +} + +// A branch-name match outranks another tree's directory name: +// when one tree's directory carries another tree's branch name, +// the user almost certainly means the branch. +func TestResolveExactBranchBeatsExactBasename(t *testing.T) { + cands := []Candidate{ + {Branch: "fix", Path: "/trees/confusing"}, + {Branch: "other", Path: "/trees/fix"}, + } + winner, _ := Resolve(cands, "fix") + if winner == nil || winner.Branch != "fix" { + t.Fatalf("Resolve() winner = %v; want the fix branch", winner) + } +} + +func TestResolveDecisiveFuzzyMatch(t *testing.T) { + winner, contenders := Resolve(candidates(), "login") + if winner == nil || winner.Branch != "feature/login" { + t.Fatalf("Resolve() winner = %v, contenders = %v; want feature/login", winner, contenders) + } +} + +func TestResolveIsCaseInsensitive(t *testing.T) { + winner, _ := Resolve(candidates(), "LOGIN") + if winner == nil || winner.Branch != "feature/login" { + t.Fatalf("Resolve() winner = %v; want feature/login", winner) + } +} + +func TestResolveMatchesDirectoryBasename(t *testing.T) { + winner, _ := Resolve(candidates(), "pool") + if winner == nil || winner.Path != "/repos/acme.trees/pool-3" { + t.Fatalf("Resolve() winner = %v; want the pool-3 tree", winner) + } +} + +func TestResolveTieIsAmbiguous(t *testing.T) { + cands := []Candidate{ + {Branch: "app-1", Path: "/trees/app-1"}, + {Branch: "app-2", Path: "/trees/app-2"}, + } + winner, contenders := Resolve(cands, "app") + if winner != nil { + t.Fatalf("Resolve() winner = %v; want ambiguity", winner) + } + want := []Candidate{ + {Branch: "app-1", Path: "/trees/app-1"}, + {Branch: "app-2", Path: "/trees/app-2"}, + } + if diff := cmp.Diff(want, contenders); diff != "" { + t.Errorf("Resolve() contenders mismatch (-want +got):\n%s", diff) + } +} + +// A name-length difference alone must never decide a jump: +// `feature` says nothing about login vs logout, even though the +// shorter name scores a length-penalty point higher in the raw +// fuzzy ranking. +func TestResolveLengthDifferenceAloneIsAmbiguous(t *testing.T) { + winner, contenders := Resolve(candidates(), "feature") + if winner != nil { + t.Fatalf("Resolve() winner = %v; want ambiguity", winner) + } + if len(contenders) != 2 { + t.Fatalf("Resolve() contenders = %v; want feature/login and feature/logout", contenders) + } +} + +// The length normalization must count in the unit the matcher +// penalizes, bytes, so multibyte names neither gain nor lose: +// this genuine tie must never become a decisive jump. +func TestResolveTiesSurviveMultibyteNames(t *testing.T) { + cands := []Candidate{ + {Branch: "fix-é", Path: "/trees/fix-é"}, // 5 runes, 6 bytes + {Branch: "fix-ab", Path: "/trees/fix-ab"}, // 6 runes, 6 bytes + } + winner, contenders := Resolve(cands, "fix") + if winner != nil { + t.Fatalf("Resolve() winner = %v; want ambiguity", winner) + } + if len(contenders) != 2 { + t.Fatalf("Resolve() contenders = %v; want both fix-* trees", contenders) + } +} + +func TestResolveAmbiguityCapsAtFiveContenders(t *testing.T) { + var cands []Candidate + for i := range 7 { + name := fmt.Sprintf("app-%d", i) + cands = append(cands, Candidate{Branch: name, Path: "/trees/" + name}) + } + winner, contenders := Resolve(cands, "app") + if winner != nil { + t.Fatalf("Resolve() winner = %v; want ambiguity", winner) + } + if len(contenders) != 5 { + t.Fatalf("Resolve() returned %d contenders, want 5", len(contenders)) + } +} + +func TestResolveNoMatch(t *testing.T) { + winner, contenders := Resolve(candidates(), "zzz") + if winner != nil || contenders != nil { + t.Fatalf("Resolve() = %v, %v; want no match", winner, contenders) + } +} + +func TestResolveEmptyQueryMatchesNothing(t *testing.T) { + winner, contenders := Resolve(candidates(), "") + if winner != nil || contenders != nil { + t.Fatalf("Resolve() = %v, %v; want no match", winner, contenders) + } +} + +// One candidate matching through several of its names +// (branch and directory) must still count once. +func TestResolveDeduplicatesNamesPerCandidate(t *testing.T) { + cands := []Candidate{ + {Branch: "feature/login", Path: "/trees/feature-login"}, + {Branch: "main", Path: "/trees/acme"}, + } + winner, contenders := Resolve(cands, "login") + if winner == nil { + t.Fatalf("Resolve() ambiguous with contenders %v; want a decisive winner", contenders) + } +} + +// ResolveExact never falls back to fuzzy spellings: the +// exact-name commands built on it must miss loudly instead. +func TestResolveExactRejectsFuzzySpellings(t *testing.T) { + if got := ResolveExact(candidates(), "login"); got != nil { + t.Fatalf("ResolveExact() = %v, want nil for a fuzzy-only spelling", got) + } +} + +func TestDisplayPrefersBranch(t *testing.T) { + c := Candidate{Branch: "feature/login", Path: "/trees/feature-login"} + if got := c.Display(); got != "feature/login" { + t.Errorf("Display() = %q, want feature/login", got) + } +} + +func TestDisplayFallsBackToBasename(t *testing.T) { + c := Candidate{Path: "/trees/pool-3"} + if got := c.Display(); got != "pool-3 (detached)" { + t.Errorf("Display() = %q, want 'pool-3 (detached)'", got) + } +} From 6e7bce91f55f95685fb418be4d8eff88d8d8bd9b Mon Sep 17 00:00:00 2001 From: Logan Thomas Date: Mon, 20 Jul 2026 15:45:36 -0500 Subject: [PATCH 2/2] chore: collect release notes for v0.1.0-alpha.3 (#9) --- .changes/8.doc.md | 1 - .changes/8.enh.md | 1 - .changes/8.maint.md | 1 - CHANGELOG.md | 14 ++++++++++++++ 4 files changed, 14 insertions(+), 3 deletions(-) delete mode 100644 .changes/8.doc.md delete mode 100644 .changes/8.enh.md delete mode 100644 .changes/8.maint.md diff --git a/.changes/8.doc.md b/.changes/8.doc.md deleted file mode 100644 index f204ef5..0000000 --- a/.changes/8.doc.md +++ /dev/null @@ -1 +0,0 @@ -A shell-integration docs page covers the cd protocol, fuzzy-matching rules, completions, and the `WT_PROMPT`/starship prompt recipes. (#8) diff --git a/.changes/8.enh.md b/.changes/8.enh.md deleted file mode 100644 index 656a719..0000000 --- a/.changes/8.enh.md +++ /dev/null @@ -1 +0,0 @@ -One `eval "$(wt shell-init zsh)"` line in `.zshrc` makes bare `wt` an interactive tree picker, `wt go` a fuzzy cd, and `wt new` land in the fresh tree — with zsh completions, an opt-in `WT_PROMPT` indicator, and a script-safe `wt ls --porcelain` fallback whenever no TTY is present. (#8) diff --git a/.changes/8.maint.md b/.changes/8.maint.md deleted file mode 100644 index e2b6eb7..0000000 --- a/.changes/8.maint.md +++ /dev/null @@ -1 +0,0 @@ -The ubuntu CI runner installs zsh, so the shell-integration smoke tests run on both platforms. (#8) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8681cb1..6f8b63e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## v0.1.0-alpha.3 - 2026-07-20 + +### Enhancements + +- One `eval "$(wt shell-init zsh)"` line in `.zshrc` makes bare `wt` an interactive tree picker, `wt go` a fuzzy cd, and `wt new` land in the fresh tree — with zsh completions, an opt-in `WT_PROMPT` indicator, and a script-safe `wt ls --porcelain` fallback whenever no TTY is present. (#8) + +### Documentation + +- A shell-integration docs page covers the cd protocol, fuzzy-matching rules, completions, and the `WT_PROMPT`/starship prompt recipes. (#8) + +### Infrastructure + +- The ubuntu CI runner installs zsh, so the shell-integration smoke tests run on both platforms. (#8) + ## v0.1.0-alpha.2 - 2026-07-20 ### Enhancements