From 04d9fd722651a0aaa8cf4170e2f3bddb372e9267 Mon Sep 17 00:00:00 2001 From: Sahil Ahuja Date: Sat, 20 Jun 2026 12:38:51 +0530 Subject: [PATCH 1/4] feat: add wt go command and wt open --go flag with shared selectWorktree helper --- docs/memory/wt-cli/go-command-contract.md | 289 ++++++++++++++++++ docs/memory/wt-cli/index.md | 1 + .../memory/wt-cli/menu-navigation-contract.md | 9 +- .../wt-cli/recency-ordering-contract.md | 35 ++- docs/specs/cli-surface.md | 44 ++- docs/specs/launcher-contract.md | 11 + .../.history.jsonl | 10 + .../.status.yaml | 51 ++++ .../intake.md | 237 ++++++++++++++ .../plan.md | 247 +++++++++++++++ src/cmd/wt/go.go | 137 +++++++++ src/cmd/wt/go_test.go | 121 ++++++++ src/cmd/wt/help_dump_test.go | 8 +- src/cmd/wt/integration_test.go | 97 ++++++ src/cmd/wt/main.go | 1 + src/cmd/wt/open.go | 223 ++++++++++---- 16 files changed, 1455 insertions(+), 66 deletions(-) create mode 100644 docs/memory/wt-cli/go-command-contract.md create mode 100644 fab/changes/260620-3pp5-open-worktree-from-worktree/.history.jsonl create mode 100644 fab/changes/260620-3pp5-open-worktree-from-worktree/.status.yaml create mode 100644 fab/changes/260620-3pp5-open-worktree-from-worktree/intake.md create mode 100644 fab/changes/260620-3pp5-open-worktree-from-worktree/plan.md create mode 100644 src/cmd/wt/go.go create mode 100644 src/cmd/wt/go_test.go diff --git a/docs/memory/wt-cli/go-command-contract.md b/docs/memory/wt-cli/go-command-contract.md new file mode 100644 index 0000000..bbed2b8 --- /dev/null +++ b/docs/memory/wt-cli/go-command-contract.md @@ -0,0 +1,289 @@ +--- +type: memory +description: "`wt go` worktree-selection contract — selection-only navigation via `WT_CD_FILE`/stdout (no launch), exit codes, the current-worktree-included menu, and the `wt open --go` composition." +--- +# wt-cli: Go Command Contract + +> Post-implementation behavior capture for the `wt go` worktree-selection verb +> and the `wt open --go` select-then-launch composition. +> Source change: `260620-3pp5-open-worktree-from-worktree`. + +This file documents the contract `wt go` honors and how `wt open --go` composes +it. `wt go` is the **selector** half of the selector/launcher split: it picks a +worktree of the current repo and navigates there, launching nothing. `wt open` +remains the **launcher** (`go` selects, `open` launches) — the launcher surface +is unchanged and is documented in `docs/specs/launcher-contract.md`. Future +changes touching `src/cmd/wt/go.go`, the `--go` path in `src/cmd/wt/open.go`, or +the shared `selectWorktree` helper should preserve these invariants unless an +explicit spec amendment supersedes them. + +## Requirements + +### `wt go` is a registered, contract-conformant subcommand + +- `goCmd() *cobra.Command` is defined in `src/cmd/wt/go.go` and registered on the + root command in `src/cmd/wt/main.go`'s `root.AddCommand(...)`, alongside the + other verbs. +- `Use: "go [name]"`, `Args: cobra.MaximumNArgs(1)`, `SilenceUsage: true`, + `SilenceErrors: true` — domain errors return via `RunE` / `wt.ExitWithError` + and render through the root handler, never as cobra usage (Constitution II). +- `wt go --help` long text describes current-repo worktree selection, the + open=launcher / go=selector framing, and the `WT_CD_FILE` / stdout navigation + contract. + +### `wt go ` resolves by name and navigates (no launch) + +- `wt go ` resolves `` as a worktree of the current repo + case-insensitively via the **shared** `resolveWorktreeByName` (the same + resolver `wt open ` uses, in `src/cmd/wt/open.go`) and navigates there. + It launches **no** application — navigation is the only effect. +- On success it routes through `navigateTo(path)` (below): writes the resolved + absolute path to `WT_CD_FILE` when set AND prints it to stdout as the last line. + +### `wt go` (no arg) shows the current-repo selection menu from anywhere in the repo + +- No-arg `wt go` renders the worktree-selection menu via the shared + `selectWorktree` helper (`src/cmd/wt/open.go`) with the prompt + **`"Select worktree to go to:"`**, on a fresh one-shot `wt.MenuSession`. +- The menu is **reachable from anywhere in the repository** — the main repo *or* + inside another worktree. This is the capability the pre-change menu lacked: + `wt open`'s no-arg menu was gated to the main repo (in-worktree no-arg `open` + opens the current folder). `wt go` has no such gate. +- The menu lists non-main worktrees newest-first (see + `/wt-cli/recency-ordering-contract.md` for the shared `SortByRecency` + ordering), branch shown per entry, newest pre-selected as the default + (`defaultIdx = 1`). +- **The current worktree IS included in the menu.** `selectWorktree` filters out + only the main repo (`e.path == ctx.RepoRoot`); it does NOT filter out the cwd's + own worktree. So when `wt go` is run from inside worktree `alpha`, `alpha` still + appears as a selectable row. This is **behavior-preserving** with `wt open`'s + shared menu (which has always listed every non-main worktree), not a `wt go` + special case — the single shared helper guarantees both verbs show the identical + set. Navigating to the worktree you are already in is a harmless no-op `cd`. +- On selection, `wt go` navigates to the chosen worktree via `navigateTo`. +- On Cancel (menu choice `0`), `wt go` prints `Cancelled.` and exits `0` without + navigating. (When there are no non-main worktrees, `selectWorktree` prints + `No worktrees found.` itself and the `Cancelled.` line is suppressed — see the + cancel/no-worktrees split below.) + +### `wt go` requires a git repository + +- `wt go` (with or without a name) gates on `wt.ValidateGitRepo()` at the top of + `RunE`. From a non-git cwd it exits `ExitGitError` (3) with a what/why/fix + message ("Not a git repository" / needs a git repo / run from inside one) — + worktree resolution walks the repo's worktree list, unreachable outside a repo. +- This is **stricter than `wt open`**: `wt open` softened its git gate so a bare + path arg / no-arg-cwd works outside a repo, but `wt go` is selection-only and + selection always needs the worktree list, so the hard git gate is correct. + +### `wt go ` exits `ExitGeneralError`; a git-list failure exits `ExitGitError` + +- The not-found vs. git-failure distinction routes on the `errWorktreeNotFound` + sentinel returned by `resolveWorktreeByName` (defined in `src/cmd/wt/open.go`, + shared with `wt open`): + - `errors.Is(err, errWorktreeNotFound)` → `ExitGeneralError` (1), message + "Worktree '' not found" + "Use 'wt list' to see available worktrees" + (same structure as `wt open`'s not-found path). + - any other error (a genuine `git worktree list` failure) → `ExitGitError` (3), + "git worktree list failed" + the underlying error + "Check 'git worktree list' + from this repo". +- This mirrors `wt open`'s exit-code mapping for the same two failure modes + (`launcher-contract.md` §5) so `wt go` and `wt open ` never disagree. + +### `wt go` navigates via `WT_CD_FILE` + stdout — `navigateTo`, never `OpenInApp` + +- `navigateTo(path string)` in `src/cmd/wt/go.go` is the dedicated navigation + helper. It: + 1. Writes `path` to `WT_CD_FILE` (mode `0600`, truncate-on-write) when the env + var is set — the identical semantics `launcher-contract.md` §3 fixes for + "Open here". A write failure exits `ExitGeneralError`. + 2. **Always** prints the resolved absolute path to stdout as the **last line**, + so the no-wrapper scripting form `cd "$(command wt go some-name)"` works. + 3. When `WT_CD_FILE` is unset **and** `WT_WRAPPER != "1"`, prints the same + two-line "shell wrapper required / `eval "$(wt shell-init)"`" hint to stderr + that the launcher's "Open here" emits (the `WT_WRAPPER`-gated hint + convention, `launcher-contract.md` §4). +- `wt go` NEVER `cd`s the parent shell directly — it cooperates via + `WT_CD_FILE` / stdout and the shell wrapper evaluates the result + (Constitution VII). +- **It does NOT route through `OpenInApp("open_here", ...)`.** `OpenInApp`'s + "open_here" path writes `WT_CD_FILE` **OR** prints a `cd -- ''` line + (mutually exclusive, and the bare path is not always emitted), and lives in the + launcher/app subsystem. `wt go` must do **both** (write the file when set AND + always print the bare path) and has no app concept, so a small dedicated helper + is correct — reusing `OpenInApp` would emit the wrong output contract. +- **No new env var** is introduced. `wt go` reuses `WT_CD_FILE` / `WT_WRAPPER` + verbatim, so the launcher-contract stability guarantees (§6) are unchanged and + no constitution amendment is required. + +### `wt go` no-arg under `--non-interactive` / non-TTY is deterministic and non-prompting + +- `wt go` accepts a `--non-interactive` bool flag (Constitution VI). +- **No arg + `--non-interactive`**: it does NOT prompt. It exits + `ExitGeneralError` (1) with a what/why/fix message ("No worktree specified" / + a no-arg menu has no non-interactive default / "Pass a worktree name: + wt go "). Erroring deterministically is preferred over silently picking a + default — a no-arg "pick a worktree" has no obviously-correct silent choice. +- **`wt go --non-interactive`** resolves directly — there is no menu to + suppress, so the flag is a no-op on that path. +- **Non-TTY (no flag)**: the no-arg menu degrades through the existing + `ShowMenu`/`MenuSession` non-TTY fallback (numbered-prompt path), the same + fallback every `wt` menu uses — see `/wt-cli/menu-navigation-contract.md`. + +### Shared `selectWorktree` helper — single source of truth (open / go / open --go) + +- The worktree-selection logic was extracted out of `selectAndOpen` into + `selectWorktree(ctx *wt.RepoContext, session *wt.MenuSession, prompt string) + (path, name string, cancelled, noWorktrees bool, err error)` in + `src/cmd/wt/open.go`. It is the single source of truth for worktree selection, + consumed by all three menu callers: + - `selectAndOpen` — `wt open` no-arg in the main repo (prompt + `"Select worktree to open:"`), re-expressed on top of the helper + (behavior-preserving — `TestOpen_MenuOrdersNewestFirst` and the other `open` + tests still pass). + - `wt go` no-arg (prompt `"Select worktree to go to:"`). + - `openGo` — `wt open --go` no-arg (prompt `"Select worktree to open:"`). +- The helper owns the menu UX: filter out the main repo (`ctx.RepoRoot`), + newest-first `wt.SortByRecency` ordering, per-entry `"name (branch)"` rows via + `getBranchForPath`, `defaultIdx = 1`, and rendering via the **caller-supplied** + `MenuSession`. No new business rule moves into `internal/worktree` — the helper + composes existing exported helpers (Constitution V). +- The `prompt` is a parameter (so the wording can differ per verb); everything + else (filter, ordering, branch display, default) is identical across callers. +- **Caller-supplied session** is load-bearing for the launch-chaining flows: + `selectAndOpen` and `openGo` pass the SAME `MenuSession` to `selectWorktree` + and then to `handleAppMenuWithSession`, so the "Open in:" menu runs on the same + stdin reader. Chaining two menus on separate readers steals the second menu's + first keystroke — see `/wt-cli/menu-navigation-contract.md` and `wt.MenuSession`. + `wt go` owns its own one-shot session (no second menu to chain). + +### Cancel vs. no-worktrees split — the `noWorktrees` return flag + +- `selectWorktree` returns `cancelled=true` both when the user picks Cancel + (choice `0`) and when there are no non-main worktrees to select. The two cases + are disambiguated by the `noWorktrees` return: + - `cancelled=true, noWorktrees=true` — `selectWorktree` has already printed + `No worktrees found.` itself (shared by all callers). The caller prints + **nothing more**. + - `cancelled=true, noWorktrees=false` — explicit user Cancel. The caller prints + its own `Cancelled.` line and exits `0`. +- This split lives in every caller identically (`wt go`, `selectAndOpen`, + `openGo`): the "No worktrees found." message is the helper's to print once; the + "Cancelled." line is the caller's. A nil error with `cancelled=false` + guarantees `path` and `name` are populated. + +### `wt open --go` composes selection then launch + +- A boolean `--go` flag on `wt open` (`openCmd()` in `src/cmd/wt/open.go`). When + set, `RunE` delegates to `openGo(target, appFlag)` **before** any of the + non-`--go` resolution branches — the non-`--go` code paths are left untouched. +- `openGo` requires a git repo (else `ExitGitError` (3), same precondition as + `wt go`), then obtains a worktree path by **selection**: + - `wt open --go ` — resolve `` via `resolveWorktreeByName` + (not-found → `ExitGeneralError`, list-fail → `ExitGitError`, same mapping as + `wt go`). + - `wt open --go` (no name) — `selectWorktree` on a shared session (cancel → + `Cancelled.` + exit `0`; no-worktrees → `No worktrees found.`). +- It then **launches** the selected worktree via the existing launcher path: + `--app ` opens directly through `openInNamedApp`; otherwise + `handleAppMenuWithSession` renders the "Open in:" menu on the **same** session + as the selection menu. `--go` + `--app` compose (select, then open directly in + the named app). +- `wt open`'s existing surface is **unchanged**: no-arg in a worktree opens the + current folder; no-arg in the main repo shows menu+launch (`selectAndOpen`); + `wt open ` / `` / `--app` resolve-AND-launch. The `hop` + launcher-contract surface (`wt open ` / `` / `--app` / exit codes / + `WT_CD_FILE`) is not altered by the `--go` addition. + +## Design Decisions + +### `wt go` writes `WT_CD_FILE` + prints stdout via a dedicated `navigateTo`, not `OpenInApp` + +**Decision**: a small `navigateTo` helper in `cmd/wt/go.go` does both the +`WT_CD_FILE` write (when set) and the always-print-bare-path stdout emission. +**Why**: `wt go` is a navigation verb with no app concept, and it must do BOTH +sides of the cd contract (write the file when set AND always print the bare path +for `cd "$(command wt go)"`). Constitution VII + `launcher-contract.md` §3 fix +the mechanism; no new env var, no `internal/` business rule. +**Rejected**: reusing `OpenInApp("open_here", ...)` — its output contract is the +wrong shape (it writes `WT_CD_FILE` OR prints a `cd --` line, mutually exclusive, +and never emits a bare path), and it couples `go` to the launcher app catalog. +*Introduced by*: `260620-3pp5-open-worktree-from-worktree`. + +### Shared `selectWorktree(ctx, session, prompt)` returning `(path, name, cancelled, noWorktrees, err)` + +**Decision**: extract the menu logic from `selectAndOpen` into one helper that +takes a `*MenuSession` and a `prompt`, and returns the chosen `path`+`name`, a +`cancelled` flag, and a `noWorktrees` flag. +**Why**: the `MenuSession` parameter lets `wt open --go` chain the "Open in:" +menu on one stdin reader (the documented byte-theft fix in `MenuSession`); the +`name` return covers tab-naming for the launch flows; the `cancelled` + +`noWorktrees` pair lets the helper own the single `No worktrees found.` message +while each caller owns its own `Cancelled.` line. One helper means +`recency-ordering-contract` and `menu-navigation-contract` hold across all three +callers. +**Rejected**: returning only a path (loses the cancel signal, the name, and the +no-worktrees-vs-cancel distinction); baking the prompt into the helper (the two +verbs want different prompt wording). +*Introduced by*: `260620-3pp5-open-worktree-from-worktree`. + +### `wt go` no-arg under non-interactive errors rather than auto-picking newest + +**Decision**: `wt go --non-interactive` with no name exits `ExitGeneralError` (1) +with a "pass a name" message, instead of silently selecting the newest worktree. +**Why**: a no-arg "pick a worktree" menu has no obviously-correct silent default; +erroring surfaces the misuse deterministically and is scriptable (Constitution +VI). Reversible if a "newest default" is later wanted. +**Rejected**: auto-picking the newest worktree (guesses intent); a silent no-op +(swallows the misuse). +*Introduced by*: `260620-3pp5-open-worktree-from-worktree`. + +### The current worktree is included in the `wt go` menu (behavior-preserving, not a special case) + +**Decision**: `wt go`'s menu lists every non-main worktree, including the one the +user is currently inside; only the main repo is filtered. +**Why**: the menu is rendered by the SHARED `selectWorktree` helper, which has +always (as `selectAndOpen`) listed all non-main worktrees. Keeping `wt go` on the +identical filter means `wt open`'s menu and `wt go`'s menu never diverge — a +single source of truth. Navigating to the worktree you are already in is a +harmless no-op `cd`, so suppressing the current row would be a `wt go`-only +special case with no real benefit and a divergence cost. +**Rejected**: filtering out the cwd's own worktree in `wt go` only (forks the +shared menu into two behaviors; the helper would need a "current path" param it +otherwise does not want). +*Introduced by*: `260620-3pp5-open-worktree-from-worktree`. + +## Cross-references + +- Sibling memory: `/wt-cli/recency-ordering-contract.md` — the shared + `RecencyOf`/`RecencyLess`/`SortByRecency` newest-first ordering that + `selectWorktree` (and thus `wt go`'s no-arg menu) consumes, alongside + `wt list`/`wt open`/`wt delete`. +- Sibling memory: `/wt-cli/menu-navigation-contract.md` — the shared + `ShowMenu`/`MenuSession` arrow-key navigation, TTY gating, and the non-TTY + numbered-prompt fallback that `wt go`'s no-arg menu degrades through; also the + single-stdin-reader (`MenuSession`) requirement behind the shared session in + `selectAndOpen`/`openGo`. +- Spec doc: `docs/specs/cli-surface.md` — the `## wt go [name]` section (behavior + matrix, exit codes, `--non-interactive`, `WT_CD_FILE`/stdout navigation) and + the `## wt open [name|path]` `--go` flag / launcher-vs-selector framing. +- Spec doc: `docs/specs/launcher-contract.md` — §3 (`WT_CD_FILE`, "Reused by + `wt go`" note), §4 (`WT_WRAPPER` hint), §5 (exit-code contract `wt go` mirrors), + §6 (stability guarantees, unchanged — no new env var). +- Source: `src/cmd/wt/go.go` — `goCmd`, `navigateTo`. +- Source: `src/cmd/wt/open.go` — `openCmd` (`--go` flag), `openGo`, + `selectWorktree` (shared helper), `selectAndOpen` (re-expressed on the helper), + `resolveWorktreeByName` / `errWorktreeNotFound` (shared resolver/sentinel), + `handleAppMenuWithSession`, `openInNamedApp`, `getBranchForPath`. +- Source: `src/cmd/wt/main.go` — `goCmd()` registered in `root.AddCommand(...)`. +- Tests: `src/cmd/wt/go_test.go` (unit: name happy path → `WT_CD_FILE`+stdout, + unknown name → exit 1, non-git → exit 3, no-arg `--non-interactive` → exit 1 + without prompting), `src/cmd/wt/integration_test.go` (end-to-end `wt go ` + from a sibling worktree, no-arg menu newest-first ordering, + `wt open --go --app open_here`). +- Constitution: Principle II (Cobra command surface — `SilenceUsage`/ + `SilenceErrors`, `RunE`), III (Typed exit codes — `ExitGitError` / + `ExitGeneralError`, no new code), V (selection orchestration lives in `cmd/`; + no new `internal/` business rule), VI (`--non-interactive` deterministic; + scriptable stdout path), VII (shell-cd via `WT_CD_FILE`, never a direct + parent-shell `cd`). diff --git a/docs/memory/wt-cli/index.md b/docs/memory/wt-cli/index.md index 04e5fdf..39e9ee8 100644 --- a/docs/memory/wt-cli/index.md +++ b/docs/memory/wt-cli/index.md @@ -8,6 +8,7 @@ description: "Behavior contracts for the `wt` CLI binary — commands, exit code | File | Description | Last Updated | |------|-------------|-------------| | [create-output-phases](create-output-phases.md) | Phase-separator output contract for `wt create` / `wt init` — Git/Init/Open separators on stderr, stdout reserved for the machine result. | 2026-06-20 | +| [go-command-contract](go-command-contract.md) | `wt go` worktree-selection contract — selection-only navigation via `WT_CD_FILE`/stdout (no launch), exit codes, the current-worktree-included menu, and the `wt open --go` composition. | — | | [help-dump-contract](help-dump-contract.md) | Contract for the hidden `wt help-dump` command — the JSON envelope shll.ai's scheduled puller consumes. | 2026-06-20 | | [idle-staleness-contract](idle-staleness-contract.md) | The shared idle predicate, the `wt delete --stale` selector, and the safety invariant that idleness never gates a deletion on its own. | 2026-06-20 | | [init-failure-contract](init-failure-contract.md) | Init-failure behavior of `wt create` / `wt init` — kept-worktree contract, `ExitInitFailed`, SIGINT handling, and terminal-foreground reclaim. | 2026-06-20 | diff --git a/docs/memory/wt-cli/menu-navigation-contract.md b/docs/memory/wt-cli/menu-navigation-contract.md index e84ce26..e9762d8 100644 --- a/docs/memory/wt-cli/menu-navigation-contract.md +++ b/docs/memory/wt-cli/menu-navigation-contract.md @@ -79,6 +79,12 @@ This file documents the contract that `ShowMenu` honors after the arrow-key navi - Both validation error messages: `Invalid choice. Please enter a number.` and `Invalid choice. Please enter a number between 0 and N.` - Existing test harnesses driving `ShowMenu` via piped stdin continue to pass unmodified. Integration tests under `cmd/integration_test.go` invoke the binary with pipes and naturally land on the fallback path. +### `wt go` is a new `ShowMenu`/`MenuSession` caller (`260620-3pp5`) + +- The `260620-3pp5-open-worktree-from-worktree` change added `wt go` (and `wt open --go`), whose worktree-selection menu renders through the **shared** `selectWorktree(ctx, session, prompt)` helper (`src/cmd/wt/open.go`) — which calls `session.Show(...)` → `ShowMenu`. No new menu primitive was introduced; `wt go`'s picker inherits this contract wholesale (arrow keys, digit-submit, Cancel→`0`, wrap-around, the `(default)` marker, and the in-place redraw) with no `menu.go` edits. +- **Non-TTY fallback**: `wt go`'s no-arg menu degrades through the same byte-identical numbered-prompt fallback as every other caller — `isInteractiveTTY()` gates it, and piped/CI invocations land on the historical numbered prompt automatically. `wt go` adds a separate, earlier guard for the **`--non-interactive` no-arg** case: it refuses with `ExitGeneralError` *before* reaching `selectWorktree` at all (a no-arg selection has no non-interactive default — see `/wt-cli/go-command-contract.md`), so that path never renders even the fallback prompt. `wt go ` and `wt go` interactive both reach the menu only when a menu is actually wanted. +- **Shared `MenuSession` for the launch-chaining callers**: `selectAndOpen` and `wt open --go`'s `openGo` pass ONE `MenuSession` to both `selectWorktree` (the "Select worktree…" menu) and `handleAppMenuWithSession` (the "Open in:" menu), so the two consecutive menus share a single stdin reader — the documented byte-theft fix. `wt go` chains no second menu, so it uses a one-shot session. The single-reader requirement and why it matters are the `MenuSession` contract this file's navigation behavior sits on. + ### Pure `nextMenuState` and `parseKey` are testable without a PTY - `nextMenuState(prev menuState, key keyEvent) menuStateTransition` is a **pure function** with no I/O, no globals, no clock. It encodes every key-mapping, wrap-around, digit boundary, and Cancel transition. @@ -153,4 +159,5 @@ Originally `paintMenu` and `redrawMenu` had two parallel rendering paths — the - Tests: `src/internal/worktree/menu_test.go` — `nextMenuState` table-driven coverage (every keybinding, wrap-around, digit boundaries, seeding), `parseKey` table-driven coverage (arrow sequences, bare-Esc-vs-arrow timeout via fake clock, unknown sequences), fallback-path byte-equality tests, `TestRunInteractiveMenuCore_PanicRestore` (defer-restore guarantee), `TestPaintAndRedrawShareCore` (first-paint / redraw byte-equality). - Constitution: Principle I (Single-Binary CLI — motivated rejecting third-party TUI deps), Principle IV (Test What the User Sees — motivated the pure state-machine seam), Principle VI (Interactive by Default, Scriptable on Demand — motivated the byte-identical non-TTY fallback). - Sibling memory: `wt-cli/init-failure-contract.md` (different `wt` subcommand, same post-change invariant-capture pattern), `wt-cli/list-status-contract.md` (different subcommand, same pattern). -- Call sites (informational, not edited by this change): `src/cmd/wt/open.go` (2 calls), `src/cmd/wt/delete.go` (7 calls), `src/cmd/wt/create.go` (2 calls). +- Sibling memory: `wt-cli/go-command-contract.md` — the `wt go` selector / `wt open --go` composition whose worktree-selection menu is a new caller of this contract (via the shared `selectWorktree` → `ShowMenu`/`MenuSession`). +- Call sites (informational): `src/cmd/wt/open.go` (the shared `selectWorktree` → `session.Show`, plus the "Open in:" menu, plus `wt open --go`'s `openGo`), `src/cmd/wt/go.go` (`wt go`'s no-arg selection menu, via `selectWorktree`; added by `260620-3pp5`), `src/cmd/wt/delete.go` (7 calls), `src/cmd/wt/create.go` (2 calls). diff --git a/docs/memory/wt-cli/recency-ordering-contract.md b/docs/memory/wt-cli/recency-ordering-contract.md index f35879a..77eacc3 100644 --- a/docs/memory/wt-cli/recency-ordering-contract.md +++ b/docs/memory/wt-cli/recency-ordering-contract.md @@ -77,12 +77,12 @@ explicit spec amendment supersedes them. ### Open / delete menus list non-main worktrees newest-first -- `selectAndOpen` (`src/cmd/wt/open.go`) and `handleDeleteMenu` - (`src/cmd/wt/delete.go`) build a `wtOption` slice of non-main worktrees - (the main worktree / `ctx.RepoRoot` is skipped) and sort it with - `wt.SortByRecency` so the **newest worktree appears at the top** of the menu. - This replaces the previous behavior where items appeared in porcelain order and - only the newest was highlighted. +- `selectAndOpen` (`src/cmd/wt/open.go`, via the shared `selectWorktree` helper as + of `260620-3pp5` — see below) and `handleDeleteMenu` (`src/cmd/wt/delete.go`) + build a `wtOption` slice of non-main worktrees (the main worktree / + `ctx.RepoRoot` is skipped) and sort it with `wt.SortByRecency` so the **newest + worktree appears at the top** of the menu. This replaces the previous behavior + where items appeared in porcelain order and only the newest was highlighted. - The pre-selected menu default remains the **most-recent** worktree — this is behavior-preserving for the default selection. Only the item *ordering* changed. - `wt open`: `defaultIdx = 1` (newest is the first menu item; index 0 is the @@ -95,6 +95,23 @@ explicit spec amendment supersedes them. - The two menus produce identical non-main ordering (both driven by the same `SortByRecency` call), so `wt open` and `wt delete` never disagree on order. +### `wt go`'s no-arg menu is a new `SortByRecency` consumer (`260620-3pp5`) + +- The `260620-3pp5-open-worktree-from-worktree` change extracted the + `selectAndOpen` menu logic into the shared `selectWorktree(ctx, session, + prompt)` helper (`src/cmd/wt/open.go`), which calls `wt.SortByRecency` over its + local `wtOption` slice (filtering out the main repo, `defaultIdx = 1`). That + single helper now backs **three** menu callers — `wt open` (main-repo no-arg, + prompt "Select worktree to open:"), `wt go` (no-arg, prompt "Select worktree to + go to:"), and `wt open --go` (no-arg). So `wt go`'s selection menu is a new + consumer of the same newest-first ordering, joining `wt list` / `wt open` / + `wt delete` — there is still exactly one `SortByRecency` call site for the + open/go selection menu, not a per-verb copy. +- The ordering, branch display, and newest-default are byte-identical across all + three callers because they share the one helper. See + `/wt-cli/go-command-contract.md` for the `wt go` / `wt open --go` behavior + contract and the `selectWorktree` extraction details. + ### `wt delete` menu: stale-aware annotation + "All idle" (`260530-5fyu`) - `handleDeleteMenu` (`src/cmd/wt/delete.go`) now annotates each idle non-main @@ -188,6 +205,9 @@ produced. (built on the `RecencyOf` signal documented here), `DefaultIdleThreshold`, and the `wt delete --stale` selector; the authoritative cross-command idle contract behind the `defaultIdx` 2/3 shift and `, idle` annotation noted above. +- Sibling memory: `wt-cli/go-command-contract.md` — the `wt go` selector and + `wt open --go` composition; the new `selectWorktree` menu consumers of the + `SortByRecency` ordering documented here. - Spec doc: `docs/specs/cli-surface.md` — `wt list` (`--sort` flag), `wt open` (selection menu, "most recently modified worktree" default), `wt delete` (selection menu). @@ -196,7 +216,8 @@ produced. `.worktrees//` root. - Source: `src/internal/worktree/recency.go` — `RecencyOf`, `RecencyLess`, `SortByRecency`. -- Source: `src/cmd/wt/open.go` (`selectAndOpen`), `src/cmd/wt/delete.go` +- Source: `src/cmd/wt/open.go` (`selectWorktree` shared helper + `selectAndOpen`; + `selectWorktree` is also called by `wt go` and `wt open --go`), `src/cmd/wt/delete.go` (`handleDeleteMenu` — now stale-aware: `firstWorktreeIdx`/`defaultIdx` 2/3, `, idle` annotation, "All idle (N)"; plus `handleDeleteStale`), `src/cmd/wt/list.go` (`sortEntries`, `resolveSort`). diff --git a/docs/specs/cli-surface.md b/docs/specs/cli-surface.md index 2c5d167..8658351 100644 --- a/docs/specs/cli-surface.md +++ b/docs/specs/cli-surface.md @@ -75,13 +75,16 @@ Exit codes: `ExitInvalidArgs` if `--path` is combined with `--json` or ## `wt open [name|path]` Open a directory in a detected application (editor, terminal, file manager). -`wt open` is the canonical directory launcher — external callers (notably +`wt open` is the canonical directory **launcher** — external callers (notably `hop`) MAY delegate to it via subprocess invocation. The full env-var contract -is documented in [`launcher-contract.md`](launcher-contract.md). +is documented in [`launcher-contract.md`](launcher-contract.md). Worktree +**selection** (picking which worktree) is the job of [`wt go`](#wt-go-name); +`wt open --go` composes the two (select, then launch). | Flag | Default | Description | |------|---------|-------------| | `--app ` | (none) | Open directly in the named app, skipping the menu. `default` selects the auto-detected default. | +| `--go` | `false` | Select a worktree first (via `wt go`'s menu when no name is given, or resolve-by-name when one is), then launch it. Requires a git repository; composes with `--app`. From a non-git cwd, exits `ExitGitError`. | Positional arg `[name|path]`: @@ -104,6 +107,43 @@ menu; `ExitGitError` only when a git operation fails during name resolution `ExitGeneralError` for unknown apps, unresolved targets, or name args supplied from a non-git cwd. +## `wt go [name]` + +Select a worktree of the current repository and **navigate** there. `wt go` is +the worktree **selector** (the counterpart to `wt open`, the launcher): it +changes the shell's working directory to the chosen worktree and launches +nothing. Navigation reuses the same `WT_CD_FILE` shell-cd plumbing as the +launcher's "Open here" option — see [`launcher-contract.md`](launcher-contract.md) §3. + +| Flag | Default | Description | +|------|---------|-------------| +| `--non-interactive` | `false` | No prompts. With no name, refuses deterministically (a no-arg selection menu has no non-interactive default) instead of prompting. | + +Positional arg `[name]`: + +- Omitted: shows a worktree-selection menu for the current repo (newest-first, + branch shown per entry, newest pre-selected as default). Reachable from + anywhere in the repository — the main repo **or** inside another worktree. + On selection, navigates to the chosen worktree. +- Provided: resolved as a worktree name (case-insensitive); navigates there + directly with no menu. + +`wt go` always **requires a git repository** — worktree resolution walks the +repo's worktree list. It is scoped to the current repo's worktrees only; +cross-repo navigation is `hop`'s job. + +Navigation mechanism: the resolved absolute path is written to `WT_CD_FILE` +(when set; mode `0600`, truncate-on-write) so the `wt shell-init` wrapper cd's +the parent shell there, **and** is printed to stdout as the last line so the +no-wrapper scripting form works: `cd "$(command wt go some-name)"`. When +`WT_CD_FILE` is unset and `WT_WRAPPER` is not `1`, the same "shell wrapper not +loaded" hint the launcher prints applies. `wt go` never cd's the parent shell +directly. + +Exit codes: `ExitGitError` (3) when the cwd is not in a git repository or +`git worktree list` fails; `ExitGeneralError` (1) for an unknown worktree name, +or for a no-arg invocation under `--non-interactive`. + ## `wt delete [worktree-names...]` Delete one or more worktrees with optional branch cleanup. diff --git a/docs/specs/launcher-contract.md b/docs/specs/launcher-contract.md index 4e37468..e50d3f2 100644 --- a/docs/specs/launcher-contract.md +++ b/docs/specs/launcher-contract.md @@ -59,6 +59,17 @@ existing directory and the cwd is in a git repo. When `WT_CD_FILE` is unset, "Open here" falls back to printing `cd -- ''` to stdout (single-quoted with `'\''` escaping for shell safety). +**Reused by `wt go`.** The `wt go` selector (see +[`cli-surface.md`](cli-surface.md#wt-go-name)) navigates to a worktree using +this **same** `WT_CD_FILE` mechanism — no new environment variable is +introduced. It writes the resolved absolute path to `WT_CD_FILE` with the +identical semantics above (mode `0600`, truncate-on-write, contents = resolved +directory path), and additionally always prints the path to stdout as the last +line (so `cd "$(command wt go )"` works without the wrapper). Because +`wt go` adds no new env-var name and does not alter any semantics in this +section or §5, the stability guarantees in §6 are unchanged — no constitution +amendment is required. + ## 4. `WT_WRAPPER` `WT_WRAPPER=1` signals that the caller is handling the `cd` itself (via diff --git a/fab/changes/260620-3pp5-open-worktree-from-worktree/.history.jsonl b/fab/changes/260620-3pp5-open-worktree-from-worktree/.history.jsonl new file mode 100644 index 0000000..1c54b95 --- /dev/null +++ b/fab/changes/260620-3pp5-open-worktree-from-worktree/.history.jsonl @@ -0,0 +1,10 @@ +{"action":"enter","driver":"fab-new","event":"stage-transition","stage":"intake","ts":"2026-06-20T05:44:24Z"} +{"args":"There's no easy way to open a worktree from another worktree. wt open seems like the command that should do this, but right now it opens the current folder. Thinking holistically, I am also fine transferring the responsibility of 'opening folders' to the hop command, and make wt open open a worktree and delegate to hop open","cmd":"fab-new","event":"command","ts":"2026-06-20T05:44:24Z"} +{"delta":"+4.6","event":"confidence","score":4.6,"trigger":"calc-score","ts":"2026-06-20T06:46:53Z"} +{"delta":"+0.0","event":"confidence","score":4.6,"trigger":"calc-score","ts":"2026-06-20T06:46:58Z"} +{"cmd":"fab-fff","event":"command","ts":"2026-06-20T06:47:41Z"} +{"action":"enter","driver":"fab-fff","event":"stage-transition","stage":"apply","ts":"2026-06-20T06:47:47Z"} +{"action":"enter","driver":"fab-fff","event":"stage-transition","stage":"review","ts":"2026-06-20T06:56:54Z"} +{"action":"enter","driver":"fab-fff","event":"stage-transition","stage":"hydrate","ts":"2026-06-20T07:03:18Z"} +{"event":"review","result":"passed","ts":"2026-06-20T07:03:18Z"} +{"action":"enter","driver":"fab-fff","event":"stage-transition","stage":"ship","ts":"2026-06-20T07:08:00Z"} diff --git a/fab/changes/260620-3pp5-open-worktree-from-worktree/.status.yaml b/fab/changes/260620-3pp5-open-worktree-from-worktree/.status.yaml new file mode 100644 index 0000000..276f72d --- /dev/null +++ b/fab/changes/260620-3pp5-open-worktree-from-worktree/.status.yaml @@ -0,0 +1,51 @@ +id: 3pp5 +name: 260620-3pp5-open-worktree-from-worktree +created: 2026-06-20T05:44:24Z +created_by: sahil-noon +change_type: feat +issues: [] +progress: + intake: done + apply: done + review: done + hydrate: done + ship: active + review-pr: pending +plan: + generated: true + task_count: 9 + acceptance_count: 20 + acceptance_completed: 20 +confidence: + certain: 4 + confident: 4 + tentative: 0 + unresolved: 0 + score: 4.6 + fuzzy: true + dimensions: + signal: 78.1 + reversibility: 77.5 + competence: 86.9 + disambiguation: 79.4 +stage_metrics: + intake: {started_at: "2026-06-20T05:44:24Z", driver: fab-new, iterations: 1, completed_at: "2026-06-20T06:47:47Z"} + apply: {started_at: "2026-06-20T06:47:47Z", driver: fab-fff, iterations: 1, completed_at: "2026-06-20T06:56:54Z"} + review: {started_at: "2026-06-20T06:56:54Z", driver: fab-fff, iterations: 1, completed_at: "2026-06-20T07:03:18Z"} + hydrate: {started_at: "2026-06-20T07:03:18Z", driver: fab-fff, iterations: 1, completed_at: "2026-06-20T07:08:00Z"} + ship: {started_at: "2026-06-20T07:08:00Z", driver: fab-fff, iterations: 1} +prs: [] +change_type_source: explicit +true_impact: + added: 0 + deleted: 0 + net: 0 + excluding: + added: 0 + deleted: 0 + net: 0 + computed_at: "2026-06-20T07:08:00Z" + computed_at_stage: hydrate +summary: Add wt go (current-repo worktree selection, WT_CD_FILE+stdout navigation, no launch) and wt open --go composition; extract shared selectWorktree helper +# true_impact: lazily created on first apply-finish (no placeholder here). +last_updated: 2026-06-20T07:08:00Z diff --git a/fab/changes/260620-3pp5-open-worktree-from-worktree/intake.md b/fab/changes/260620-3pp5-open-worktree-from-worktree/intake.md new file mode 100644 index 0000000..aeeab5d --- /dev/null +++ b/fab/changes/260620-3pp5-open-worktree-from-worktree/intake.md @@ -0,0 +1,237 @@ +# Intake: Worktree navigation via `wt go`, launcher via `wt open` + +**Change**: 260620-3pp5-open-worktree-from-worktree +**Created**: 2026-06-20 + +## Origin + +Initiated conversationally via `/fab-new`. Raw user input: + +> There's no easy way to open a worktree from another worktree. `wt open` seems like the command that should do this, but right now it opens the current folder. Thinking holistically, I am also fine transferring the responsibility of 'opening folders' to the hop command, and make `wt open` open a worktree and delegate to `hop open`. + +This was a **conversational, multi-turn** intake. The raw prompt fused two acts that the +final design deliberately separates, and several framings were explored and rejected before +landing on the design below. Key decision trail (all decided *with the user*, this session): + +1. **Code trace corrected the premise.** `wt open ` *already* opens a different worktree + by name from inside any worktree (`src/cmd/wt/open.go:76-95`). The genuine gap is that the + worktree-**selection menu** is reachable only from the main repo (`open.go:104` short-circuits + no-arg-in-a-worktree to "open the current worktree"). So the problem is **discovery from inside + a worktree**, not name-resolution. + +2. **"Delegate to `hop`" was rejected** — and the user explicitly confirmed keeping `wt` + canonical. `docs/specs/launcher-contract.md` establishes `wt open` as the canonical launcher + and `hop` (external repo, `github.com/sahil87/hop`) as the *consumer* that delegates **to** `wt`. + Inverting that (`wt` shelling out to `hop`) would (a) create a circular/inverted dependency + against the launcher contract and (b) violate Constitution Principle I (single self-contained + binary, no runtime deps beyond `git`). Decision: **keep `wt` canonical; `hop` keeps consuming + `wt open`.** + +3. **`wt go` vs `wt switch` naming** — settled on **`go`**. `switch` collides with `git switch` + (in-place *branch* change), which is the wrong mental model: this act *navigates to a different + directory*, it never changes the current worktree's branch. `go` reads as navigation. + +4. **`hop` already owns cross-repo/worktree navigation** (`hop /`, `hop ls --trees` + which fans out `wt list --json`, `hop open` which "delegates to wt's menu"). So `wt go` + is deliberately scoped to **the current repo's** worktrees only — `wt` has no cross-repo + registry and must not grow one (Constitution I). This is the one piece `hop` cannot do without + re-asking the user to type the repo name. + +5. **Final shape (user's own proposal, this session):** split the overloaded `open` into two + orthogonal verbs plus a composition flag — + - `wt go [name]` — Act 2: current-repo worktree **selection** (navigation). + - `wt open [path]` — Act 1: the directory **launcher** (opens the current folder by default). + - `wt open --go` — compose: select via `go`, then launch via `open`. + +6. **cd mechanism already exists** — the user pointed to `wt shell-init`. The `wt()` shell + wrapper (`src/cmd/wt/shell_init.go`) already allocates a fresh `WT_CD_FILE` on *every* `wt` + invocation and `cd`'s the parent shell to whatever path the binary writes there. `wt go` reuses + this verbatim — no new env var, no new mechanism, fully consistent with Constitution VII. + +A second, related pain point the user raised — `hop`'s worktree picker requires too much typing +(`hop /`; there is no one-keystroke fuzzy picker, and `hop ls --json` does not even +expose worktrees) — is **out of scope for this `wt` change** (it needs `hop.yaml`'s cross-repo +registry, which lives in the `hop` repo). Recorded as a follow-up in Open Questions. + +## Why + +**Problem (the pain point).** From inside worktree A of a repo, there is no low-friction way to +*discover and jump to* a sibling worktree. The user must already know the target worktree's name +(`wt open `), because the only worktree-selection menu `wt` offers is gated to the main repo +(`open.go:113`). No-arg `wt open` from within a worktree opens the current folder — useful, but it +gives no picker. The act of "pick which worktree" and the act of "launch a directory in a tool" +are fused into a single overloaded verb (`open`), so neither can be invoked on its own. + +**Consequence if unfixed.** Day-to-day worktree-hopping within a repo stays a +type-the-exact-name operation. Users fall back to `wt list` + copy/paste, or to `hop` (which +requires re-typing the repo name even when you're already inside that repo). The overload also +blocks the clean division of labor the toolchain is otherwise built around: `wt open` *wants* to +be the pure launcher that `hop` delegates to, but it can't be while it also owns worktree +selection. + +**Why this approach over alternatives.** +- *Rejected: delegate `wt open` → `hop open`.* Inverts the launcher-contract dependency direction + and breaks Constitution I (single binary, git-only). The user confirmed keeping `wt` canonical. +- *Rejected: add `wt open --pick` only (keep one overloaded verb).* Considered and liked + mid-discussion, but the user's final preference was the cleaner two-verb split: it de-overloads + `open` (→ pure Act-1 launcher, the stable primitive `hop` consumes) and gives worktree + navigation its own home (`wt go`, Act 2). `--go` becomes the ergonomic bridge. +- *Rejected: `wt go` as cross-repo navigator.* That is `hop`'s job and `hop` already does it; + duplicating it in `wt` would require a cross-repo registry `wt` must not own. +- *Chosen: two orthogonal verbs (`go` = select, `open` = launch) + `--go` composition*, reusing + the existing `WT_CD_FILE` shell-cd plumbing. Smallest mechanism, non-breaking for `wt open`, + preserves every contract. + +## What Changes + +### 1. New verb: `wt go [name]` — current-repo worktree selection (Act 2) + +A new cobra subcommand in `src/cmd/wt/go.go`, registered in `main.go`'s `root.AddCommand(...)`. +Its single responsibility is to **resolve a worktree of the current repo and navigate there** — +it does **not** launch any application. It is worktree-aware and **requires a git repository** +(it walks the current repo's worktree list). + +**Behavior matrix:** + +| Invocation | Behavior | +|------------|----------| +| `wt go` (no arg, in a git repo) | Show the **worktree-selection menu** for the current repo (the same "Select worktree to open:" menu `selectAndOpen` builds today — newest-first recency ordering, branch shown per entry). On selection, navigate to the chosen worktree (see cd mechanism below). Reachable from **anywhere in the repo** — main repo *or* inside a worktree. This is the capability the main-repo-only menu lacks today. | +| `wt go ` (in a git repo) | Resolve `` as a worktree (case-insensitive, via the existing `resolveWorktreeByName` logic) and navigate there directly — no menu. | +| `wt go` / `wt go ` (not in a git repo) | Exit `ExitGitError` (3) with the standard what/why/fix message — worktree resolution requires a git repo. Mirrors how name-resolution-requires-a-repo is handled in `open.go:96-103`. | +| `wt go ` | Exit `ExitGeneralError` (1): "Worktree '' not found" + "Use 'wt list' to see available worktrees" (same message structure as `open.go:80-84`). | + +**Navigation mechanism (no new plumbing).** `wt go` reuses the existing shell-cd contract: + +- It **writes the resolved worktree's absolute path to `WT_CD_FILE`** (when set). The `wt()` shell + wrapper from `wt shell-init` (`src/cmd/wt/shell_init.go`) then `cd`'s the parent shell into that + path — exactly the mechanism "Open here" already uses. No new env var; reuses + `launcher-contract.md` §3 semantics (mode 0600, truncate-on-write, contents = resolved dir path). +- It **also prints the resolved absolute path to stdout** as the last line, so the no-wrapper / + scripting path works: `cd "$(command wt go some-name)"`. Consistent with `wt list --path` and + `wt create`'s stdout-path contract. +- When `WT_CD_FILE` is unset and no wrapper is loaded, behavior degrades to "print the path" + (the stdout line is the usable output). The existing `WT_WRAPPER`-gated "shell wrapper not + loaded" hint convention applies as it does for `open`. + +> **Constitution VII compliance:** `wt go` never `cd`'s the parent shell directly — it prints / +> writes a path and the shell wrapper evaluates it. Identical discipline to the existing launcher. + +**`--non-interactive`** (Constitution VI): `wt go` with no arg and `--non-interactive` MUST NOT +prompt. It exits `ExitGeneralError` (or selects nothing) deterministically — a no-arg menu has no +sensible non-interactive default. `wt go --non-interactive` resolves directly (no menu, so +nothing to suppress). When stdout is not a TTY, the no-arg menu degrades per the existing menu +fallback in `src/internal/worktree/menu.go` (it already has a non-TTY fallback path). + +### 2. `wt open` — narrowed toward a pure launcher (Act 1) + +`wt open`'s **existing behavior is preserved** for all current invocations (non-breaking): + +- `wt open` (no arg, inside a worktree) → still opens the **current** worktree/folder in a tool + (`open.go:104` path unchanged). +- `wt open` (no arg, main repo) → still shows the worktree-selection menu then launches + (`selectAndOpen`, unchanged — see Open Questions for whether this eventually delegates to `wt go`). +- `wt open ` / `wt open ` / `wt open --app ` → unchanged. `wt open ` + continues to resolve-and-**launch** a worktree (this is the launcher-contract behavior `hop` + depends on; it is NOT removed — see Assumptions). + +The conceptual reframe is that `open` is now understood as the **launcher** and `go` as the +**selector**; the no-arg-in-main-repo menu is the one spot where `open` still performs selection, +and whether that should internally delegate to `wt go` is left as an Open Question rather than +changed now (keeps this change non-breaking and tightly scoped). + +### 3. New flag: `wt open --go` — select-then-launch composition + +A boolean `--go` flag on `wt open`. When set, `wt open` first performs **`wt go`'s selection** +(menu when no positional arg; resolve when `` given) to obtain a worktree path, then runs +its **own launcher** (app menu / `--app`) against that path. Mechanically this is an *internal +composition* of the two commands' shared functions (no subprocess) — the selection helper and the +launch helper both already live in the `cmd/wt` package (`selectAndOpen`, `resolveWorktreeByName`, +`handleAppMenu`, `OpenInApp`). + +``` +wt open --go # menu of current repo's worktrees -> launch the pick in a tool +wt open --go # resolve -> launch it in a tool +wt open --go --app code # menu -> open the pick directly in `code` (skips the app menu) +``` + +`--go` + `--app` compose (select a worktree, then open it directly in the named app). +`--go` is incompatible only where the underlying selection is impossible (e.g. not in a git +repo → `ExitGitError`, same as `wt go`). + +> **Implementation note for apply/plan:** factor the worktree-selection logic currently inside +> `selectAndOpen` (`open.go:254-315`) into a reusable helper that both `wt go` and `wt open --go` +> call, so the menu UX (recency ordering, branch display, shared `MenuSession`) has a single +> source of truth. This keeps `recency-ordering-contract` and `menu-navigation-contract` intact +> across both verbs. + +### 4. Docs & specs + +- `docs/specs/cli-surface.md` — add a `## wt go [name]` section; update `## wt open` to document + the `--go` flag and the open=launcher / go=selector framing. +- `docs/specs/launcher-contract.md` — note that `wt go` reuses `WT_CD_FILE` (§3) for navigation; + confirm the env-var contract is unchanged (no new vars), so no stability-guarantee amendment is + needed. +- `wt go --help` / `wt open --help` long-form text. + +## Affected Memory + +- `wt-cli/recency-ordering-contract`: (modify) `wt go`'s no-arg menu is a new consumer of the + shared `SortByRecency` newest-first ordering — add it alongside `wt list` / `wt open` / `wt delete`. +- `wt-cli/menu-navigation-contract`: (modify) `wt go`'s worktree-selection menu uses the shared + `ShowMenu` / `MenuSession` — note the new caller and its non-TTY fallback behavior. +- `wt-cli/go-command-contract`: (new) Behavior contract for `wt go` — selection-only semantics, + `WT_CD_FILE`-based navigation (no launch), stdout path emission, exit codes, and the + `wt open --go` composition. Sibling to the launcher contract: `go` selects, `open` launches. + +## Impact + +**Code areas:** +- `src/cmd/wt/go.go` (new) + `src/cmd/wt/go_test.go` (new) — the `wt go` subcommand, unit tests. +- `src/cmd/wt/main.go` — register `goCmd()` in `root.AddCommand(...)`. +- `src/cmd/wt/open.go` — add the `--go` flag and its compose path; extract the selection helper + out of `selectAndOpen` so `go` and `open --go` share it. +- `src/cmd/wt/integration_test.go` — end-to-end coverage for `wt go` (menu + name + WT_CD_FILE + + non-git error) per Constitution IV (test what the user sees). +- `src/internal/worktree/` — likely no new logic; `wt go` composes existing exported helpers + (`SortByRecency`, `ShowMenu`/`MenuSession`, worktree listing). Keep `cmd/` thin per + Constitution V (selection orchestration in `cmd/`, no new business rules added to `internal/`). + +**Exit codes:** `wt go` maps to the existing typed codes (Constitution III) — `ExitGitError` (3) +for non-git cwd / git list failure, `ExitGeneralError` (1) for unknown worktree name. No new exit +code constant is required. + +**External consumers:** `hop`'s delegation to `wt open` is **unaffected** — `wt open`'s +launcher-contract surface (`WT_CD_FILE`, `WT_WRAPPER`, exit codes, path/name precedence) is +unchanged. `wt go` adds a new surface but does not alter the contract `hop` relies on. + +**No constitution amendment needed:** no new env vars, no new runtime dependency, no module-path +change, no change to the stable launcher-contract surface. + +## Open Questions + +- Should the no-arg `wt open` from the **main repo** (which today shows a selection menu via + `selectAndOpen`) eventually delegate to `wt go` internally, so selection lives in exactly one + place? Deferred — keeping it as-is now makes this change non-breaking; revisit once `wt go` + ships and the shared selection helper exists. +- Should `wt open` (no-arg, inside a worktree) ever switch its default from "open current" to + "selection menu"? The user explicitly wants no-arg `open` to keep opening the current folder, so + this stays out of scope — recorded only to mark it as a consciously-rejected option. +- **Follow-up in the `hop` repo (not this change):** `hop`'s worktree picker requires too much + typing (`hop /`; `hop ls --json` does not expose worktrees, and the shim is + tab-completion only — no one-keystroke fuzzy picker). A low-typing cross-repo worktree picker + belongs in `hop` (it needs `hop.yaml`). Track as a separate `/fab-new` in the `hop` repo. + +## Assumptions + +| # | Grade | Decision | Rationale | Scores | +|---|-------|----------|-----------|--------| +| 1 | Certain | Keep `wt` canonical; do NOT delegate `wt open` to `hop` | User confirmed explicitly; launcher-contract.md + Constitution I both make `wt` the self-contained launcher and `hop` the consumer — inverting breaks single-binary/git-only | S:95 R:80 A:100 D:95 | +| 2 | Certain | Name the new verb `wt go` (not `wt switch`) | User chose `go`; `switch` collides with `git switch` (in-place branch change), the wrong mental model for directory navigation | S:90 R:85 A:90 D:90 | +| 3 | Certain | `wt go` reuses the existing `WT_CD_FILE` shell-cd mechanism (no new plumbing) | User pointed to `wt shell-init`; the `wt()` wrapper already cd's to any path written to `WT_CD_FILE` on every invocation — Constitution VII satisfied, no new env var | S:90 R:85 A:95 D:90 | +| 4 | Confident | `wt go` is scoped to the **current repo's** worktrees only (not cross-repo) | `hop` already owns cross-repo nav (`hop /`, `hop ls --trees`); `wt` has no cross-repo registry and must not grow one (Constitution I). User agreed cross-repo is hop's job | S:80 R:75 A:90 D:80 | +| 5 | Confident | `wt open ` keeps its current resolve-AND-launch behavior (not narrowed to selection-only) | This is the launcher-contract surface `hop` depends on; removing launch from `wt open ` would be a breaking change to an external consumer. Keep `open`=launch, add `go`=select | S:75 R:65 A:90 D:80 | +| 6 | Confident | `wt go` also prints the resolved path to stdout (in addition to `WT_CD_FILE`) | Enables the no-wrapper / scripting path `cd "$(command wt go)"`; consistent with `wt list --path` / `wt create` stdout-path conventions; Constitution VI scriptability | S:70 R:80 A:85 D:75 | +| 7 | Confident | `--go` composes selection+launch internally (shared helpers), not via subprocess | Both the selection (`selectAndOpen`) and launch (`OpenInApp`/`handleAppMenu`) helpers already live in `cmd/wt`; an internal call is simpler and avoids re-exec overhead | S:70 R:80 A:85 D:80 | +| 8 | Tentative | `wt go` no-arg under `--non-interactive` / non-TTY exits deterministically rather than picking a default | A no-arg "pick a worktree" menu has no obviously-correct silent default; erroring is safer than guessing. But the exact code (ExitGeneralError vs a no-op) and whether to allow a "newest" default could be revisited in apply | S:55 R:70 A:60 D:45 | + +8 assumptions (3 certain, 4 confident, 1 tentative, 0 unresolved). diff --git a/fab/changes/260620-3pp5-open-worktree-from-worktree/plan.md b/fab/changes/260620-3pp5-open-worktree-from-worktree/plan.md new file mode 100644 index 0000000..bfcb416 --- /dev/null +++ b/fab/changes/260620-3pp5-open-worktree-from-worktree/plan.md @@ -0,0 +1,247 @@ +# Plan: Worktree navigation via `wt go`, launcher via `wt open` + +**Change**: 260620-3pp5-open-worktree-from-worktree +**Intake**: `intake.md` + +## Requirements + +### wt-cli: `wt go` worktree selection (Act 2 — navigation, no launch) + +#### R1: `wt go` is a registered, contract-conformant subcommand +A new `wt go [name]` subcommand SHALL exist as a `cobra.Command` defined in +`src/cmd/wt/go.go` and registered on the root command in `src/cmd/wt/main.go`'s +`root.AddCommand(...)`. It SHALL set `SilenceUsage: true` and `SilenceErrors: true` +and return domain errors via `RunE` / `wt.ExitWithError`, per Constitution II. + +- **GIVEN** the `wt` binary is built +- **WHEN** the user runs `wt go --help` +- **THEN** a `go` subcommand is listed with long-form help describing current-repo + worktree selection and the `WT_CD_FILE` / stdout navigation contract +- **AND** the command never prints cobra usage on a domain error (errors render via the root handler) + +#### R2: `wt go ` resolves a worktree by name and navigates to it +`wt go ` SHALL resolve `` as a worktree of the current repo +(case-insensitive, via the existing `resolveWorktreeByName` logic) and navigate +there — it SHALL NOT launch any application. + +- **GIVEN** the cwd is inside a git repo with a worktree named `alpha` +- **WHEN** the user runs `wt go alpha` +- **THEN** the resolved absolute path of `alpha` is written to `WT_CD_FILE` (when set) +- **AND** the same absolute path is printed to stdout as the last line +- **AND** no application is launched + +#### R3: `wt go` (no arg) shows the current-repo selection menu from anywhere in the repo +`wt go` with no positional arg SHALL show the worktree-selection menu for the +current repo — newest-first recency ordering, branch shown per entry — reachable +from the main repo **or** from inside any worktree. On selection it SHALL navigate +to the chosen worktree (write `WT_CD_FILE` + print stdout path). The menu SHALL be +rendered by the shared selection helper (single source of truth — R8). + +- **GIVEN** the cwd is inside worktree `alpha` of a repo that also has `bravo` and `charlie` +- **WHEN** the user runs `wt go` (interactive) +- **THEN** a "Select worktree to go to:" menu lists the repo's worktrees (including the current one — behavior-preserving with the shared `wt open` menu per R8) newest-first +- **AND** selecting an entry writes its path to `WT_CD_FILE` and prints it to stdout +- **AND** cancelling (choice 0) prints `Cancelled.` and exits 0 without navigating + +#### R4: `wt go` requires a git repository +`wt go` (with or without a name) SHALL require a git repository. From a non-git +cwd it SHALL exit `ExitGitError` (3) with a what/why/fix message — worktree +resolution walks the repo's worktree list, which is unreachable outside a repo. + +- **GIVEN** the cwd is not inside a git repository +- **WHEN** the user runs `wt go` or `wt go some-name` +- **THEN** the command exits `ExitGitError` (3) +- **AND** stderr carries a structured what/why/fix message + +#### R5: `wt go ` exits `ExitGeneralError` +When `` does not match any worktree (the worktree list succeeded but no +entry matched), `wt go ` SHALL exit `ExitGeneralError` (1) with the message +structure "Worktree '' not found" + "Use 'wt list' to see available worktrees". +A genuine git-list failure (distinct sentinel) SHALL exit `ExitGitError` (3). + +- **GIVEN** the cwd is inside a git repo with no worktree named `ghost` +- **WHEN** the user runs `wt go ghost` +- **THEN** the command exits `ExitGeneralError` (1) +- **AND** stderr contains `not found` + +#### R6: `wt go` navigates via the existing `WT_CD_FILE` shell-cd contract + stdout +`wt go` SHALL reuse the existing shell-cd plumbing with no new env var: write the +resolved absolute path to `WT_CD_FILE` (mode `0600`, truncate-on-write) when set, +AND print the resolved absolute path to stdout as the last line. When `WT_CD_FILE` +is unset and `WT_WRAPPER` is not `1`, it SHALL emit the same shell-wrapper hint +convention `wt open`'s "Open here" uses. It SHALL NEVER `cd` the parent shell +directly (Constitution VII). + +- **GIVEN** `WT_CD_FILE` points at a writable file and `WT_WRAPPER=1` +- **WHEN** `wt go alpha` resolves successfully +- **THEN** `WT_CD_FILE` contains exactly the resolved absolute path, mode `0600` +- **AND** stdout's last line is that same path +- **AND** no shell-wrapper hint is printed to stderr + +#### R7: `wt go` no-arg under `--non-interactive` / non-TTY is deterministic and non-prompting +`wt go` SHALL accept a `--non-interactive` flag (Constitution VI). With no arg and +`--non-interactive`, it SHALL NOT prompt; it SHALL exit `ExitGeneralError` (1) with +a what/why/fix message stating that a no-arg selection has no non-interactive +default (pass a name). `wt go --non-interactive` resolves directly (nothing +to suppress). When stdout/stdin is not a TTY, the no-arg menu degrades through the +existing `MenuSession` fallback path. + +- **GIVEN** the cwd is inside a git repo +- **WHEN** the user runs `wt go --non-interactive` (no name) +- **THEN** the command exits `ExitGeneralError` (1) without prompting +- **AND** stderr advises passing a worktree name + +### wt-cli: `wt open --go` composition (select-then-launch) + +#### R8: shared worktree-selection helper (single source of truth) +The worktree-selection logic currently inside `selectAndOpen` (`src/cmd/wt/open.go`) +SHALL be extracted into a reusable helper in `src/cmd/wt/` that both `wt go` and +`wt open --go` call. The helper SHALL own the menu UX: filter out the main repo, +newest-first `SortByRecency` ordering, per-entry branch display, and rendering via +a caller-supplied `MenuSession` (so `open --go` can chain the "Open in:" menu on the +same stdin reader). `selectAndOpen`'s existing behavior SHALL be preserved by +re-expressing it on top of the helper. No new business rules move into +`internal/worktree/` — the helper composes existing exported helpers (Constitution V). + +- **GIVEN** the refactor is complete +- **WHEN** `wt open` (no-arg, main repo), `wt go` (no-arg), and `wt open --go` (no-arg) each show a selection menu +- **THEN** all three render the identical menu (same recency ordering, same branch display, same default highlight) +- **AND** the menu construction lives in exactly one helper function + +#### R9: `wt open --go` composes selection then launch +A boolean `--go` flag SHALL be added to `wt open`. When set, `wt open` SHALL first +perform `wt go`'s selection (menu when no positional arg; resolve-by-name when +`` given) to obtain a worktree path, then run its own launcher (app menu, or +`--app` directly) against that path — an internal composition via shared functions, +no subprocess. `--go` + `--app` SHALL compose (select, then open directly in the +named app). `wt open`'s existing surface (no-arg in worktree opens current; no-arg +in main repo shows menu+launch; `wt open ` resolves-AND-launches) SHALL be +preserved unchanged. + +- **GIVEN** the cwd is inside a git repo with worktrees `alpha` and `bravo` +- **WHEN** the user runs `wt open --go bravo --app open_here` +- **THEN** `bravo` is resolved and opened in `open_here` (path written to `WT_CD_FILE`) +- **AND** `wt open --go` (no arg) shows the selection menu, then the app menu on the same session +- **AND** plain `wt open ` continues to resolve AND launch (launcher-contract surface unchanged) + +#### R10: `wt open --go` requires a git repository +Because `--go` performs worktree selection, `wt open --go` from a non-git cwd SHALL +exit `ExitGitError` (3) — the same precondition as `wt go`. + +- **GIVEN** the cwd is not inside a git repository +- **WHEN** the user runs `wt open --go` or `wt open --go some-name` +- **THEN** the command exits `ExitGitError` (3) + +### docs: spec + help conformance + +#### R11: docs reflect the new surface +`docs/specs/cli-surface.md` SHALL gain a `## wt go [name]` section and document +the `wt open --go` flag and the open=launcher / go=selector framing. +`docs/specs/launcher-contract.md` SHALL note that `wt go` reuses `WT_CD_FILE` (§3) +for navigation with no new env var — confirming the env-var contract is unchanged +(no stability-guarantee amendment needed). `--help` long text for `wt go` and the +`--go` flag SHALL be accurate. + +- **GIVEN** the change is implemented +- **WHEN** a reader consults `docs/specs/cli-surface.md` and `docs/specs/launcher-contract.md` +- **THEN** `wt go` and `wt open --go` are documented with exit codes and the navigation contract +- **AND** the launcher-contract notes `wt go` reuses `WT_CD_FILE` without adding any env var + +### Non-Goals + +- Cross-repo navigation — `wt go` is scoped to the current repo's worktrees only; cross-repo is `hop`'s job. +- Changing `wt open`'s no-arg behavior (in-worktree "open current", main-repo menu+launch) — preserved as-is. +- Narrowing `wt open ` to selection-only — it keeps resolve-AND-launch (the surface `hop` depends on). +- Delegating the main-repo no-arg `wt open` menu to `wt go` internally — deferred (intake Open Questions). +- `docs/memory/` files — hydration is a later stage. + +### Design Decisions + +1. **`wt go` writes `WT_CD_FILE` AND prints stdout directly, rather than routing through `OpenInApp("open_here", …)`**: `wt go` is a navigation verb with no app concept — *Why*: `OpenInApp`'s "open_here" path writes `WT_CD_FILE` OR prints `cd -- ''` (mutually exclusive) and lives in the launcher subsystem; `wt go` must do BOTH (write file when set AND always print the bare path for `cd "$(command wt go)"`). A small dedicated navigation helper in `cmd/wt` keeps `go` decoupled from the app catalog — *Rejected*: reusing `OpenInApp("open_here")` (wrong output contract: it prints a `cd --` line, not a bare path, and only when `WT_CD_FILE` is unset). +2. **Shared selection helper returns `(path, name, cancelled, err)` and takes a `*MenuSession`**: *Why*: lets `open --go` chain the "Open in:" menu on the same stdin reader (the documented byte-theft fix in `MenuSession`), and lets `wt go` own its own one-shot session — *Rejected*: returning only a path (loses the cancel signal and the name needed for tab naming). +3. **`--go` no-arg under non-interactive inherits `wt go`'s deterministic error** rather than auto-picking newest: *Why*: a no-arg "pick a worktree" has no obviously-correct silent default; erroring is safer (matches intake Assumption 8). + +## Tasks + +### Phase 1: Refactor — shared selection helper + +- [x] T001 Extract the worktree-selection logic from `selectAndOpen` in `src/cmd/wt/open.go` into a new reusable helper `selectWorktree(ctx *wt.RepoContext, session *wt.MenuSession) (path, name string, cancelled bool, err error)` — filter out main repo, `SortByRecency` newest-first, build "name (branch)" menu rows, render via the passed session with prompt "Select worktree to go to:", default highlight index 1. Re-express `selectAndOpen` to call it (preserving its existing launch behavior on the same session). + +### Phase 2: Core Implementation — `wt go` + +- [x] T002 Add `src/cmd/wt/go.go`: `goCmd() *cobra.Command` with `Use: "go [name]"`, `SilenceUsage`/`SilenceErrors` true, `Args: cobra.MaximumNArgs(1)`, a `--non-interactive` bool flag, and long help describing selection + `WT_CD_FILE`/stdout navigation. Implement `RunE`: require git repo (else `ExitGitError`); with a name → `resolveWorktreeByName` (not-found → `ExitGeneralError`, list-fail → `ExitGitError`) then navigate; no arg + `--non-interactive` → `ExitGeneralError` deterministic message; no arg interactive → shared `selectWorktree` via a fresh `MenuSession`, cancel prints `Cancelled.` exit 0, else navigate. +- [x] T003 Add a navigation helper `navigateTo(path string)` in `src/cmd/wt/go.go`: write `path` to `WT_CD_FILE` (mode `0600`) when set; print the shell-wrapper hint to stderr when `WT_CD_FILE` is unset and `WT_WRAPPER != "1"` (mirroring `OpenInApp`'s "open_here" hint wording); always print the resolved absolute path to stdout as the last line. +- [x] T004 Register `goCmd()` in `src/cmd/wt/main.go`'s `root.AddCommand(...)`. + +### Phase 3: Core Implementation — `wt open --go` + +- [x] T005 Add a `--go` bool flag to `openCmd()` in `src/cmd/wt/open.go`. When set: require a git repo (else `ExitGitError`); obtain a worktree path via selection (resolve `` when a positional arg is given; otherwise `selectWorktree` on a shared session — cancel exits 0), then launch it via the existing launcher path (`--app` direct, or `handleAppMenuWithSession` on the same session). Compose with `--app`. Leave the non-`--go` code paths untouched. + +### Phase 4: Tests + +- [x] T006 [P] Add `src/cmd/wt/go_test.go`: unit-level binary tests — `wt go ` happy path writes `WT_CD_FILE` + stdout path; `wt go ghost` (unknown) exits 1 with "not found"; `wt go` from non-git cwd exits 3; `wt go --non-interactive` (no arg) exits 1 without prompting. +- [x] T007 [P] Add integration coverage in `src/cmd/wt/integration_test.go`: end-to-end `wt go ` from inside a sibling worktree writes the correct sibling path to `WT_CD_FILE` and stdout; no-arg `wt go` menu lists siblings newest-first (mirror `TestOpen_MenuOrdersNewestFirst`); `wt open --go --app open_here` resolves and writes `WT_CD_FILE`. + +### Phase 5: Docs + +- [x] T008 [P] Update `docs/specs/cli-surface.md`: add `## wt go [name]` section (behavior matrix, exit codes, `--non-interactive`, `WT_CD_FILE`/stdout navigation); update `## wt open` to document `--go` and the launcher/selector framing. +- [x] T009 [P] Update `docs/specs/launcher-contract.md`: note `wt go` reuses `WT_CD_FILE` (§3) for navigation, no new env var, no stability amendment. + +## Execution Order + +- T001 blocks T002, T005 (both call `selectWorktree`) +- T002, T003, T004 are the `wt go` command (T003's `navigateTo` is used by T002) +- T006, T007, T008, T009 follow implementation; T006–T009 are mutually `[P]` + +## Acceptance + +### Functional Completeness + +- [x] A-001 R1: `wt go` is registered in `main.go`, defined in `go.go`, sets `SilenceUsage`/`SilenceErrors`, returns errors via `RunE`/`ExitWithError`, and `wt go --help` shows accurate long text. +- [x] A-002 R2: `wt go ` resolves a worktree case-insensitively and navigates (no app launched). +- [x] A-003 R3: `wt go` (no arg) shows the selection menu from inside a worktree as well as the main repo, newest-first with branch display. +- [x] A-004 R4: `wt go` and `wt go ` from a non-git cwd exit `ExitGitError` (3) with a what/why/fix message. +- [x] A-005 R5: `wt go ` exits `ExitGeneralError` (1) with "not found"; a git-list failure exits `ExitGitError` (3). +- [x] A-006 R6: `wt go` writes the resolved abs path to `WT_CD_FILE` (mode 0600) when set AND prints it to stdout as the last line; never cd's the parent shell directly. +- [x] A-007 R7: `wt go --non-interactive` (no arg) exits `ExitGeneralError` (1) without prompting; `wt go --non-interactive` resolves directly. +- [x] A-008 R8: a single shared `selectWorktree` helper renders the menu for `wt open` (main-repo no-arg), `wt go`, and `wt open --go` — identical ordering/branch display/default. +- [x] A-009 R9: `wt open --go [name]` composes selection + launch internally; `--go`+`--app` compose; `wt open ` still resolves AND launches (launcher contract unchanged). +- [x] A-010 R10: `wt open --go` from a non-git cwd exits `ExitGitError` (3). +- [x] A-011 R11: `docs/specs/cli-surface.md` documents `wt go [name]` and `wt open --go`; `docs/specs/launcher-contract.md` notes `wt go` reuses `WT_CD_FILE` with no new env var. + +### Behavioral Correctness + +- [x] A-012 R8: `selectAndOpen`'s pre-change behavior (main-repo no-arg menu → launch) is preserved after the refactor — `TestOpen_MenuOrdersNewestFirst` and the other `open` tests still pass. +- [x] A-013 R9: `hop`'s launcher-contract surface (`wt open ` / `` / `--app` / exit codes / `WT_CD_FILE`) is unchanged by the `--go` addition. + +### Scenario Coverage + +- [x] A-014 R2 R6: an integration test exercises `wt go ` from inside a sibling worktree end-to-end (path → `WT_CD_FILE` + stdout). +- [x] A-015 R3: an integration test asserts the no-arg `wt go` menu lists siblings newest-first. +- [x] A-016 R9: an integration test exercises `wt open --go --app open_here`. + +### Edge Cases & Error Handling + +- [x] A-017 R5 R7: unit tests cover unknown-name (exit 1), non-git (exit 3), and no-arg non-interactive (exit 1) paths for `wt go`. + +### Code Quality + +- [x] A-018 Pattern consistency: new code follows the `wt.ExitWithError` what/why/fix style, cobra `RunE` conventions, and test conventions (`runWt`, `createTestRepo`, `createWorktreeViaWt`) of surrounding code. +- [x] A-019 No unnecessary duplication: `wt go` and `wt open --go` reuse `selectWorktree`, `resolveWorktreeByName`, `SortByRecency`, and `MenuSession` rather than reimplementing selection/recency/menu logic. +- [x] A-020 gofmt/vet clean: `gofmt -l` (from `src/`) emits no output and `go vet ./...` passes. + +## Notes + +- Check items as you review: `- [x]` +- All acceptance items must pass before `/fab-continue` (hydrate) + +## Assumptions + +| # | Grade | Decision | Rationale | Scores | +|---|-------|----------|-----------|--------| +| 1 | Confident | Menu prompt for `wt go` is "Select worktree to go to:" (vs. open's "Select worktree to open:") | Intake calls the menu "the same … menu `selectAndOpen` builds"; `go` is navigation not launch, so a navigation-flavored prompt reads correctly. Cosmetic, trivially reversible | S:60 R:90 A:70 D:65 | +| 2 | Confident | `wt go` no-arg non-interactive exits `ExitGeneralError` (1), not a silent no-op | Intake Assumption 8 (Tentative there) leaned this way; erroring surfaces the misuse deterministically and is scriptable. Reversible if a "newest default" is later wanted | S:55 R:80 A:70 D:55 | +| 3 | Certain | `wt go` navigation writes `WT_CD_FILE` + prints stdout via a dedicated `cmd/wt` helper, not `OpenInApp` | Constitution VII + launcher-contract §3 fix the mechanism; `OpenInApp`'s output contract differs (cd-line vs bare path). No new env var, no `internal/` business rule added | S:85 R:80 A:95 D:90 | +| 4 | Confident | Shared selection helper signature `selectWorktree(ctx, *MenuSession) (path, name string, cancelled bool, err error)` | The `MenuSession` param is required for `open --go` to chain the app menu on one reader (documented byte-theft fix); returning name+cancel covers all callers' needs | S:65 R:75 A:85 D:70 | + +4 assumptions (1 certain, 3 confident, 0 tentative). diff --git a/src/cmd/wt/go.go b/src/cmd/wt/go.go new file mode 100644 index 0000000..c0dcabb --- /dev/null +++ b/src/cmd/wt/go.go @@ -0,0 +1,137 @@ +package main + +import ( + "errors" + "fmt" + "os" + + wt "github.com/sahil87/wt/internal/worktree" + "github.com/spf13/cobra" +) + +func goCmd() *cobra.Command { + var nonInteractive bool + + cmd := &cobra.Command{ + Use: "go [name]", + Short: "Select a worktree of the current repo and navigate there", + Long: `Select a worktree of the current repository and navigate there. + +Unlike "wt open", "wt go" does not launch any application — it only changes the +shell's working directory to the selected worktree (open=launcher, go=selector). + +When called without arguments, shows a worktree-selection menu for the current +repo (newest-first, branch shown per entry). The menu is reachable from anywhere +in the repository — the main repo or inside another worktree. + +When called with a name, resolves it as a worktree (case-insensitive) and +navigates there directly, with no menu. + +Navigation reuses the same shell-cd plumbing as the "Open here" launcher option: +the resolved absolute path is written to WT_CD_FILE (when set) and also printed +to stdout as the last line, so both the shell wrapper (eval "$(wt shell-init)") +and the scripting form (cd "$(command wt go some-name)") work. + +Requires a git repository — worktree resolution walks the repo's worktree list.`, + Args: cobra.MaximumNArgs(1), + SilenceUsage: true, + SilenceErrors: true, + RunE: func(cmd *cobra.Command, args []string) error { + // Worktree resolution always requires a git repo (it walks the + // repo's worktree list). Mirrors open.go's git-context gating. + if wt.ValidateGitRepo() != nil { + wt.ExitWithError(wt.ExitGitError, + "Not a git repository", + "wt go resolves worktrees of the current repo and needs a git repository", + "Run wt go from inside a git repository") + } + + ctx, err := wt.GetRepoContext() + if err != nil { + wt.ExitWithError(wt.ExitGeneralError, "Cannot get repo context", err.Error(), "") + } + + var target string + if len(args) > 0 { + target = args[0] + } + + if target != "" { + path, err := resolveWorktreeByName(target, ctx) + if err != nil { + if errors.Is(err, errWorktreeNotFound) { + wt.ExitWithError(wt.ExitGeneralError, + fmt.Sprintf("Worktree '%s' not found", target), + "No worktree with that name in this repository", + "Use 'wt list' to see available worktrees") + } + // listWorktreeEntries failed — a real git operation error. + wt.ExitWithError(wt.ExitGitError, + "git worktree list failed", + err.Error(), + "Check 'git worktree list' from this repo") + } + return navigateTo(path) + } + + // No name. A no-arg selection menu has no sensible non-interactive + // default, so refuse deterministically rather than prompt or guess. + if nonInteractive { + wt.ExitWithError(wt.ExitGeneralError, + "No worktree specified", + "wt go with no name shows a selection menu, which has no non-interactive default", + "Pass a worktree name: wt go ") + } + + session := wt.NewMenuSession() + defer session.Close() + + path, _, cancelled, noWorktrees, err := selectWorktree(ctx, session, "Select worktree to go to:") + if err != nil { + return err + } + if cancelled { + // "No worktrees found." is printed by selectWorktree; only the + // explicit Cancel path needs the "Cancelled." line. + if !noWorktrees { + fmt.Println("Cancelled.") + } + return nil + } + + return navigateTo(path) + }, + } + + cmd.Flags().BoolVar(&nonInteractive, "non-interactive", false, "No prompts; require a worktree name") + + return cmd +} + +// navigateTo records the navigation target for the shell-cd contract. It writes +// the resolved absolute path to WT_CD_FILE (when set) so the wt() shell wrapper +// cd's the parent shell there, and always prints the path to stdout as the last +// line so the no-wrapper scripting form (cd "$(command wt go ...)") works. +// +// Per Constitution VII, wt never cd's the parent shell directly — it cooperates +// via WT_CD_FILE / stdout and the shell wrapper evaluates the result. The +// WT_CD_FILE semantics (mode 0600, truncate-on-write, contents = resolved dir +// path) are the same ones documented in launcher-contract.md §3 for "Open here". +func navigateTo(path string) error { + if cdFile := os.Getenv("WT_CD_FILE"); cdFile != "" { + if err := os.WriteFile(cdFile, []byte(path), 0600); err != nil { + wt.ExitWithError(wt.ExitGeneralError, + "Cannot write navigation target", + err.Error(), + "Check that WT_CD_FILE points to a writable path") + } + } else if os.Getenv("WT_WRAPPER") != "1" { + fmt.Fprintln(os.Stderr, `hint: wt go requires the shell wrapper to cd. Run: eval "$(wt shell-init)"`) + fmt.Fprintln(os.Stderr, ` Add it to your ~/.zshrc or ~/.bashrc to make it permanent.`) + } + + // Always emit the resolved path as the last stdout line for the scripting + // path: cd "$(command wt go some-name)". + fmt.Println(path) + return nil +} diff --git a/src/cmd/wt/go_test.go b/src/cmd/wt/go_test.go new file mode 100644 index 0000000..ea6e304 --- /dev/null +++ b/src/cmd/wt/go_test.go @@ -0,0 +1,121 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// TestGo_NameArg_NavigatesToWorktree verifies the happy path: `wt go ` +// resolves a worktree and writes its absolute path to WT_CD_FILE while also +// printing it to stdout as the last line. No application is launched. +func TestGo_NameArg_NavigatesToWorktree(t *testing.T) { + repo := createTestRepo(t) + wtPath := createWorktreeViaWt(t, repo, "swift-fox") + + cdFile := filepath.Join(repo, "wt-cd") + env := []string{ + "WT_CD_FILE=" + cdFile, + "WT_WRAPPER=1", + } + + r := runWtSuccess(t, repo, env, "go", "swift-fox") + + // WT_CD_FILE holds the resolved worktree path. + data, err := os.ReadFile(cdFile) + if err != nil { + t.Fatalf("reading cd file: %v", err) + } + if string(data) != wtPath { + t.Errorf("expected cd file to contain %q, got %q", wtPath, string(data)) + } + // launcher-contract.md §3: mode 0600. + info, err := os.Stat(cdFile) + if err != nil { + t.Fatalf("stat cd file: %v", err) + } + if mode := info.Mode().Perm(); mode != 0o600 { + t.Errorf("expected cd file mode 0600, got %o", mode) + } + + // stdout's last non-empty line is the resolved path (scripting form). + lines := strings.Split(strings.TrimRight(r.Stdout, "\n"), "\n") + last := lines[len(lines)-1] + if last != wtPath { + t.Errorf("expected stdout last line %q, got %q (full stdout: %q)", wtPath, last, r.Stdout) + } + + // No app launch leaked through (the test seam marker would appear). + if strings.Contains(r.Stderr, "[wt-test-no-launch]") { + t.Errorf("wt go must not launch an app, got stderr: %q", r.Stderr) + } +} + +// TestGo_NameArg_CaseInsensitive verifies name resolution is case-insensitive, +// matching resolveWorktreeByName's contract shared with `wt open`. +func TestGo_NameArg_CaseInsensitive(t *testing.T) { + repo := createTestRepo(t) + wtPath := createWorktreeViaWt(t, repo, "alpha") + + cdFile := filepath.Join(repo, "wt-cd") + env := []string{"WT_CD_FILE=" + cdFile, "WT_WRAPPER=1"} + + runWtSuccess(t, repo, env, "go", "ALPHA") + + data, err := os.ReadFile(cdFile) + if err != nil { + t.Fatalf("reading cd file: %v", err) + } + if string(data) != wtPath { + t.Errorf("expected cd file to contain %q, got %q", wtPath, string(data)) + } +} + +// TestGo_UnknownName_ExitsGeneralError verifies an unresolved name exits +// ExitGeneralError (1) with a "not found" message — the worktree list +// succeeded, the name simply didn't match. +func TestGo_UnknownName_ExitsGeneralError(t *testing.T) { + repo := createTestRepo(t) + + r := runWt(t, repo, nil, "go", "no-such-worktree") + if r.ExitCode != 1 { + t.Fatalf("expected exit 1 (ExitGeneralError), got %d\nstdout: %s\nstderr: %s", + r.ExitCode, r.Stdout, r.Stderr) + } + assertContains(t, r.Stderr, "not found") + assertContains(t, r.Stderr, "wt list") +} + +// TestGo_NonGit_ExitsGitError verifies that running `wt go` (and `wt go +// `) from a non-git cwd exits ExitGitError (3). +func TestGo_NonGit_ExitsGitError(t *testing.T) { + dir := t.TempDir() + + r := runWt(t, dir, nil, "go") + if r.ExitCode != 3 { + t.Fatalf("expected exit 3 (ExitGitError) for no-arg, got %d\nstderr: %s", r.ExitCode, r.Stderr) + } + + r = runWt(t, dir, nil, "go", "some-name") + if r.ExitCode != 3 { + t.Fatalf("expected exit 3 (ExitGitError) for name-arg, got %d\nstderr: %s", r.ExitCode, r.Stderr) + } +} + +// TestGo_NoArg_NonInteractive_ExitsGeneralError verifies that `wt go +// --non-interactive` with no name refuses deterministically (exit 1) rather +// than prompting — a no-arg selection menu has no non-interactive default. +func TestGo_NoArg_NonInteractive_ExitsGeneralError(t *testing.T) { + repo := createTestRepo(t) + createWorktreeViaWt(t, repo, "alpha") + + r := runWt(t, repo, nil, "go", "--non-interactive") + if r.ExitCode != 1 { + t.Fatalf("expected exit 1 (ExitGeneralError), got %d\nstdout: %s\nstderr: %s", + r.ExitCode, r.Stdout, r.Stderr) + } + assertContains(t, r.Stderr, "No worktree specified") + // Must not have prompted (no menu rendered). + assertNotContains(t, r.Stdout, "Select worktree") +} diff --git a/src/cmd/wt/help_dump_test.go b/src/cmd/wt/help_dump_test.go index a1d398e..5f5132b 100644 --- a/src/cmd/wt/help_dump_test.go +++ b/src/cmd/wt/help_dump_test.go @@ -76,14 +76,14 @@ func TestHelpDump_EmitsValidEnvelope(t *testing.T) { t.Errorf("tree must not contain %q", banned) } } - // wt currently exposes exactly these 7 visible subcommands. - for _, want := range []string{"create", "delete", "init", "list", "open", "shell-init", "update"} { + // wt currently exposes exactly these 8 visible subcommands. + for _, want := range []string{"create", "delete", "go", "init", "list", "open", "shell-init", "update"} { if !names[want] { t.Errorf("tree missing expected subcommand %q, got: %v", want, names) } } - if got := len(doc.Root.Commands); got != 7 { - t.Errorf("expected 7 visible subcommands, got %d: %v", got, names) + if got := len(doc.Root.Commands); got != 8 { + t.Errorf("expected 8 visible subcommands, got %d: %v", got, names) } } diff --git a/src/cmd/wt/integration_test.go b/src/cmd/wt/integration_test.go index a48638d..700f07a 100644 --- a/src/cmd/wt/integration_test.go +++ b/src/cmd/wt/integration_test.go @@ -6,6 +6,7 @@ import ( "path/filepath" "strings" "testing" + "time" ) func TestIntegration_CreateListDeleteLifecycle(t *testing.T) { @@ -264,6 +265,102 @@ func TestIntegration_CreateInitFailure_KeepsWorktreeAndExits7(t *testing.T) { assertContains(t, r.Stderr, "INIT_FAIL_MARKER") } +// TestIntegration_Go_FromSiblingWorktree exercises the core gap this change +// closes: navigating to a sibling worktree from inside another worktree. +// `wt go ` run from inside worktree A must resolve sibling B and write +// B's absolute path to WT_CD_FILE (and stdout), launching nothing. +func TestIntegration_Go_FromSiblingWorktree(t *testing.T) { + repo := createTestRepo(t) + pathA := createWorktreeViaWt(t, repo, "alpha") + pathB := createWorktreeViaWt(t, repo, "bravo") + + cdFile := filepath.Join(repo, "wt-cd") + env := []string{"WT_CD_FILE=" + cdFile, "WT_WRAPPER=1"} + + // cwd is INSIDE worktree alpha — the menu/resolution must still see bravo. + r := runWtSuccess(t, pathA, env, "go", "bravo") + + data, err := os.ReadFile(cdFile) + if err != nil { + t.Fatalf("reading cd file: %v", err) + } + if string(data) != pathB { + t.Errorf("expected cd file to contain sibling %q, got %q", pathB, string(data)) + } + if last := strings.TrimSpace(r.Stdout); last != pathB { + t.Errorf("expected stdout to be sibling path %q, got %q", pathB, r.Stdout) + } + if strings.Contains(r.Stderr, "[wt-test-no-launch]") { + t.Errorf("wt go must not launch an app, got stderr: %q", r.Stderr) + } +} + +// TestIntegration_Go_MenuOrdersNewestFirst verifies the no-arg `wt go` menu +// lists the repo's worktrees newest-first with the newest as the default — +// the shared selection helper used by `wt open`. Mirrors +// TestOpen_MenuOrdersNewestFirst. +func TestIntegration_Go_MenuOrdersNewestFirst(t *testing.T) { + repo := createTestRepo(t) + createWorktreeViaWt(t, repo, "alpha") + createWorktreeViaWt(t, repo, "bravo") + createWorktreeViaWt(t, repo, "charlie") + + base := time.Date(2022, 1, 1, 0, 0, 0, 0, time.UTC) + chtimesWorktree(t, repo, "alpha", base) + chtimesWorktree(t, repo, "bravo", base.Add(time.Hour)) + chtimesWorktree(t, repo, "charlie", base.Add(2*time.Hour)) + + r := runWt(t, repo, nil, "go") + got := menuOrder(r.Stdout, []string{"alpha", "bravo", "charlie"}) + want := []string{"charlie", "bravo", "alpha"} + if len(got) != len(want) { + t.Fatalf("expected %v in menu, got %v\nstdout:\n%s", want, got, r.Stdout) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("go menu order = %v, want %v", got, want) + break + } + } + assertContains(t, r.Stdout, "charlie (charlie) (default)") + assertContains(t, r.Stdout, "Select worktree to go to:") +} + +// TestIntegration_OpenGo_NameArg_ResolvesAndLaunches exercises `wt open --go +// --app open_here`: it composes selection (resolve-by-name) with the +// launcher, writing the resolved path to WT_CD_FILE via the open_here app. +func TestIntegration_OpenGo_NameArg_ResolvesAndLaunches(t *testing.T) { + repo := createTestRepo(t) + createWorktreeViaWt(t, repo, "alpha") + pathB := createWorktreeViaWt(t, repo, "bravo") + + cdFile := filepath.Join(repo, "wt-cd") + env := []string{"WT_CD_FILE=" + cdFile, "WT_WRAPPER=1"} + + // Run from inside alpha to prove cross-worktree selection works here too. + r := runWtSuccess(t, worktreePath(repo, "alpha"), env, "open", "--go", "bravo", "--app", "open_here") + + data, err := os.ReadFile(cdFile) + if err != nil { + t.Fatalf("reading cd file: %v", err) + } + if string(data) != pathB { + t.Errorf("expected cd file to contain %q, got %q", pathB, string(data)) + } + _ = r +} + +// TestIntegration_OpenGo_NonGit_ExitsGitError verifies `wt open --go` from a +// non-git cwd exits ExitGitError (3), the same precondition as `wt go`. +func TestIntegration_OpenGo_NonGit_ExitsGitError(t *testing.T) { + dir := t.TempDir() + + r := runWt(t, dir, nil, "open", "--go") + if r.ExitCode != 3 { + t.Fatalf("expected exit 3 (ExitGitError), got %d\nstderr: %s", r.ExitCode, r.Stderr) + } +} + func TestIntegration_WorktreeCommitIndependent(t *testing.T) { repo := createTestRepo(t) diff --git a/src/cmd/wt/main.go b/src/cmd/wt/main.go index dd8ffbe..6bc8fb0 100644 --- a/src/cmd/wt/main.go +++ b/src/cmd/wt/main.go @@ -31,6 +31,7 @@ Shell wrapper (recommended): createCmd(), listCmd(), openCmd(), + goCmd(), deleteCmd(), initCmd(), shellInitCmd(), diff --git a/src/cmd/wt/open.go b/src/cmd/wt/open.go index 49a9a4d..e8eb8dc 100644 --- a/src/cmd/wt/open.go +++ b/src/cmd/wt/open.go @@ -14,6 +14,7 @@ import ( func openCmd() *cobra.Command { var appFlag string + var goFlag bool cmd := &cobra.Command{ Use: "open [name|path]", @@ -25,7 +26,12 @@ When called without arguments from the main repo, shows a worktree-selection men When called without arguments from a non-git directory, opens the current working directory. Path arguments are accepted regardless of git context. Worktree-name resolution -requires a git repository.`, +requires a git repository. + +With --go, "wt open" first performs "wt go"'s worktree selection (a menu when no +name is given, or resolve-by-name when a name is given) and then launches the +selected worktree — composing the selector and the launcher. --go requires a git +repository and composes with --app.`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { var target string @@ -33,6 +39,12 @@ requires a git repository.`, target = args[0] } + // --go: compose "wt go"'s selection with "wt open"'s launcher. + // Self-contained so the non--go paths below stay untouched. + if goFlag { + return openGo(target, appFlag) + } + // Soft git-context detection: git context enriches resolution but is // no longer a precondition. ValidateGitRepo only gates branches that // genuinely require a repo (worktree-name resolution, in-worktree @@ -132,52 +144,128 @@ requires a git repository.`, // Open with specified app or show menu if appFlag != "" { - apps := wt.BuildAvailableApps() - var resolved *wt.AppInfo - var err error - if appFlag == "default" { - resolved, err = wt.ResolveDefaultApp(apps) - if err != nil { - wt.ExitWithError(wt.ExitGeneralError, - "No default app detected", - "Could not determine a default application for the current environment", - "Use 'wt open' without --app to see the menu") - } - } else { - resolved, err = wt.ResolveApp(appFlag, apps) - if err != nil { - wt.ExitWithError(wt.ExitGeneralError, - fmt.Sprintf("Unknown app: %s", appFlag), - fmt.Sprintf("App '%s' is not available on this system", appFlag), - "Available apps can be seen with: wt open (then check the menu)") - } - } - wt.SaveLastApp(resolved.Cmd) - if openErr := wt.OpenInApp(resolved.Cmd, wtPath, repoName, wtName); openErr != nil { - exitCode := wt.ExitGeneralError - if strings.Contains(resolved.Cmd, "byobu") { - exitCode = wt.ExitByobuTabError - } else if strings.Contains(resolved.Cmd, "tmux") { - exitCode = wt.ExitTmuxWindowError - } - wt.ExitWithError(exitCode, - fmt.Sprintf("Failed to open in %s", resolved.Name), - openErr.Error(), - "Verify the application is running and retry") - } - } else { - return handleAppMenu(wtPath, repoName, wtName) + return openInNamedApp(appFlag, wtPath, repoName, wtName) } - - return nil + return handleAppMenu(wtPath, repoName, wtName) }, } cmd.Flags().StringVar(&appFlag, "app", "", "Open in specified app, skipping the menu") + cmd.Flags().BoolVar(&goFlag, "go", false, "Select a worktree (menu or by name) first, then launch it") return cmd } +// openGo implements `wt open --go`: it composes `wt go`'s worktree selection +// with `wt open`'s launcher. It resolves a worktree path (by name when target +// is non-empty, otherwise via the shared selection menu) and launches it via +// the existing launcher path (--app direct, or the "Open in:" app menu). Like +// `wt go`, --go requires a git repository. +// +// Selection and the subsequent app menu share ONE MenuSession (single stdin +// reader) — see wt.MenuSession for why chaining menus on separate readers +// steals keystrokes. +func openGo(target, appFlag string) error { + if wt.ValidateGitRepo() != nil { + wt.ExitWithError(wt.ExitGitError, + "Not a git repository", + "wt open --go selects a worktree of the current repo and needs a git repository", + "Run wt open --go from inside a git repository") + } + + ctx, err := wt.GetRepoContext() + if err != nil { + wt.ExitWithError(wt.ExitGeneralError, "Cannot get repo context", err.Error(), "") + } + + var wtPath, wtName string + + if target != "" { + path, resErr := resolveWorktreeByName(target, ctx) + if resErr != nil { + if errors.Is(resErr, errWorktreeNotFound) { + wt.ExitWithError(wt.ExitGeneralError, + fmt.Sprintf("Worktree '%s' not found", target), + "No worktree with that name in this repository", + "Use 'wt list' to see available worktrees") + } + wt.ExitWithError(wt.ExitGitError, + "git worktree list failed", + resErr.Error(), + "Check 'git worktree list' from this repo") + } + wtPath = path + wtName = target + } + + // One session spans the selection menu and the "Open in:" menu. + session := wt.NewMenuSession() + defer session.Close() + + if target == "" { + path, name, cancelled, noWorktrees, selErr := selectWorktree(ctx, session, "Select worktree to open:") + if selErr != nil { + return selErr + } + if cancelled { + if !noWorktrees { + fmt.Println("Cancelled.") + } + return nil + } + wtPath = path + wtName = name + } + + // Launch the selected worktree. --app opens directly; otherwise the + // "Open in:" menu runs on the same session as the selection menu. + if appFlag != "" { + return openInNamedApp(appFlag, wtPath, ctx.RepoName, wtName) + } + return handleAppMenuWithSession(session, wtPath, ctx.RepoName, wtName) +} + +// openInNamedApp resolves appFlag (or the "default" keyword) against the +// available apps and launches wtPath in it. Extracted from openCmd's --app +// branch so `wt open --go --app ` reuses the identical resolution and +// error-mapping logic (the launcher-contract exit-code surface). +func openInNamedApp(appFlag, wtPath, repoName, wtName string) error { + apps := wt.BuildAvailableApps() + var resolved *wt.AppInfo + var err error + if appFlag == "default" { + resolved, err = wt.ResolveDefaultApp(apps) + if err != nil { + wt.ExitWithError(wt.ExitGeneralError, + "No default app detected", + "Could not determine a default application for the current environment", + "Use 'wt open' without --app to see the menu") + } + } else { + resolved, err = wt.ResolveApp(appFlag, apps) + if err != nil { + wt.ExitWithError(wt.ExitGeneralError, + fmt.Sprintf("Unknown app: %s", appFlag), + fmt.Sprintf("App '%s' is not available on this system", appFlag), + "Available apps can be seen with: wt open (then check the menu)") + } + } + wt.SaveLastApp(resolved.Cmd) + if openErr := wt.OpenInApp(resolved.Cmd, wtPath, repoName, wtName); openErr != nil { + exitCode := wt.ExitGeneralError + if strings.Contains(resolved.Cmd, "byobu") { + exitCode = wt.ExitByobuTabError + } else if strings.Contains(resolved.Cmd, "tmux") { + exitCode = wt.ExitTmuxWindowError + } + wt.ExitWithError(exitCode, + fmt.Sprintf("Failed to open in %s", resolved.Name), + openErr.Error(), + "Verify the application is running and retry") + } + return nil +} + // errWorktreeNotFound is returned by resolveWorktreeByName when the worktree // list was retrieved successfully but no entry matched the requested name. // Distinct from a git-operation failure (which propagates up unchanged) so the @@ -251,10 +339,28 @@ func handleAppMenuWithSession(session *wt.MenuSession, wtPath, repoName, wtName return nil } -func selectAndOpen(ctx *wt.RepoContext) error { +// selectWorktree renders the current repo's worktree-selection menu against the +// provided session and returns the chosen worktree's (path, name). It is the +// single source of truth for worktree selection shared by `wt open` (main-repo +// no-arg menu), `wt go`, and `wt open --go`: it filters out the main repo, +// orders entries newest-first via the shared recency comparator, displays the +// branch per entry, and pre-selects the newest worktree as the default. +// +// The caller supplies the MenuSession so that select-then-launch flows +// (`wt open` / `wt open --go`) can chain the subsequent "Open in:" menu on the +// SAME stdin reader — see wt.MenuSession for why a single reader across menus +// is required (otherwise the first menu's orphaned read-ahead pump steals the +// next menu's first keystroke). +// +// Returns cancelled=true when the user picks Cancel (choice 0) or there are no +// other worktrees to select. The "No worktrees found." message is emitted here +// (shared by all callers); the per-caller "Cancelled." message is the caller's +// to print, distinguished via the noWorktrees flag. A nil error with +// cancelled=false guarantees path and name are populated. +func selectWorktree(ctx *wt.RepoContext, session *wt.MenuSession, prompt string) (path, name string, cancelled, noWorktrees bool, err error) { entries, err := listWorktreeEntries() if err != nil { - return err + return "", "", false, false, err } type wtOption struct { @@ -268,13 +374,12 @@ func selectAndOpen(ctx *wt.RepoContext) error { if e.path == ctx.RepoRoot { continue } - name := filepath.Base(e.path) - options = append(options, wtOption{path: e.path, name: name}) + options = append(options, wtOption{path: e.path, name: filepath.Base(e.path)}) } if len(options) == 0 { fmt.Println("No worktrees found.") - return nil + return "", "", true, true, nil } // Order newest-first via the shared recency comparator. The newest worktree @@ -286,14 +391,25 @@ func selectAndOpen(ctx *wt.RepoContext) error { ) defaultIdx := 1 - // Build menu + // Build menu rows: "name (branch)". menuNames := make([]string, len(options)) for i, o := range options { - // Get branch for display - branch := getBranchForPath(o.path) - menuNames[i] = fmt.Sprintf("%s (%s)", o.name, branch) + menuNames[i] = fmt.Sprintf("%s (%s)", o.name, getBranchForPath(o.path)) + } + + choice, err := session.Show(prompt, menuNames, defaultIdx) + if err != nil { + return "", "", false, false, err + } + if choice == 0 { + return "", "", true, false, nil } + selected := options[choice-1] + return selected.path, selected.name, false, false, nil +} + +func selectAndOpen(ctx *wt.RepoContext) error { // One terminal session spans both menus ("Select worktree to open:" then // "Open in:") so they share a single stdin reader. Without this, the first // menu's read-ahead pump is left orphaned on stdin and steals the second @@ -301,17 +417,20 @@ func selectAndOpen(ctx *wt.RepoContext) error { session := wt.NewMenuSession() defer session.Close() - choice, err := session.Show("Select worktree to open:", menuNames, defaultIdx) + path, name, cancelled, noWorktrees, err := selectWorktree(ctx, session, "Select worktree to open:") if err != nil { return err } - if choice == 0 { - fmt.Println("Cancelled.") + if cancelled { + // "No worktrees found." is printed by selectWorktree; only the + // explicit Cancel path needs the "Cancelled." line. + if !noWorktrees { + fmt.Println("Cancelled.") + } return nil } - selected := options[choice-1] - return handleAppMenuWithSession(session, selected.path, ctx.RepoName, selected.name) + return handleAppMenuWithSession(session, path, ctx.RepoName, name) } func getBranchForPath(wtPath string) string { From 721d85c88eefff5df8cb3ab26f0e47321fb2219c Mon Sep 17 00:00:00 2001 From: Sahil Ahuja Date: Sat, 20 Jun 2026 12:39:40 +0530 Subject: [PATCH 2/4] Update ship status and record PR URL --- .../.history.jsonl | 1 + .../.status.yaml | 12 +++++++----- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/fab/changes/260620-3pp5-open-worktree-from-worktree/.history.jsonl b/fab/changes/260620-3pp5-open-worktree-from-worktree/.history.jsonl index 1c54b95..284f545 100644 --- a/fab/changes/260620-3pp5-open-worktree-from-worktree/.history.jsonl +++ b/fab/changes/260620-3pp5-open-worktree-from-worktree/.history.jsonl @@ -8,3 +8,4 @@ {"action":"enter","driver":"fab-fff","event":"stage-transition","stage":"hydrate","ts":"2026-06-20T07:03:18Z"} {"event":"review","result":"passed","ts":"2026-06-20T07:03:18Z"} {"action":"enter","driver":"fab-fff","event":"stage-transition","stage":"ship","ts":"2026-06-20T07:08:00Z"} +{"action":"enter","driver":"git-pr","event":"stage-transition","stage":"review-pr","ts":"2026-06-20T07:09:37Z"} diff --git a/fab/changes/260620-3pp5-open-worktree-from-worktree/.status.yaml b/fab/changes/260620-3pp5-open-worktree-from-worktree/.status.yaml index 276f72d..6f7bc84 100644 --- a/fab/changes/260620-3pp5-open-worktree-from-worktree/.status.yaml +++ b/fab/changes/260620-3pp5-open-worktree-from-worktree/.status.yaml @@ -9,8 +9,8 @@ progress: apply: done review: done hydrate: done - ship: active - review-pr: pending + ship: done + review-pr: active plan: generated: true task_count: 9 @@ -33,8 +33,10 @@ stage_metrics: apply: {started_at: "2026-06-20T06:47:47Z", driver: fab-fff, iterations: 1, completed_at: "2026-06-20T06:56:54Z"} review: {started_at: "2026-06-20T06:56:54Z", driver: fab-fff, iterations: 1, completed_at: "2026-06-20T07:03:18Z"} hydrate: {started_at: "2026-06-20T07:03:18Z", driver: fab-fff, iterations: 1, completed_at: "2026-06-20T07:08:00Z"} - ship: {started_at: "2026-06-20T07:08:00Z", driver: fab-fff, iterations: 1} -prs: [] + ship: {started_at: "2026-06-20T07:08:00Z", driver: fab-fff, iterations: 1, completed_at: "2026-06-20T07:09:37Z"} + review-pr: {started_at: "2026-06-20T07:09:37Z", driver: git-pr, iterations: 1} +prs: + - https://github.com/sahil87/wt/pull/23 change_type_source: explicit true_impact: added: 0 @@ -48,4 +50,4 @@ true_impact: computed_at_stage: hydrate summary: Add wt go (current-repo worktree selection, WT_CD_FILE+stdout navigation, no launch) and wt open --go composition; extract shared selectWorktree helper # true_impact: lazily created on first apply-finish (no placeholder here). -last_updated: 2026-06-20T07:08:00Z +last_updated: 2026-06-20T07:09:37Z From 08e0a1f6d75683288224cc5d7eb4f18eeb74b1b1 Mon Sep 17 00:00:00 2001 From: Sahil Ahuja Date: Sat, 20 Jun 2026 12:45:13 +0530 Subject: [PATCH 3/4] fix: address review feedback from @Copilot --- docs/memory/wt-cli/index.md | 2 +- docs/specs/cli-surface.md | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/memory/wt-cli/index.md b/docs/memory/wt-cli/index.md index 39e9ee8..87caa08 100644 --- a/docs/memory/wt-cli/index.md +++ b/docs/memory/wt-cli/index.md @@ -8,7 +8,7 @@ description: "Behavior contracts for the `wt` CLI binary — commands, exit code | File | Description | Last Updated | |------|-------------|-------------| | [create-output-phases](create-output-phases.md) | Phase-separator output contract for `wt create` / `wt init` — Git/Init/Open separators on stderr, stdout reserved for the machine result. | 2026-06-20 | -| [go-command-contract](go-command-contract.md) | `wt go` worktree-selection contract — selection-only navigation via `WT_CD_FILE`/stdout (no launch), exit codes, the current-worktree-included menu, and the `wt open --go` composition. | — | +| [go-command-contract](go-command-contract.md) | `wt go` worktree-selection contract — selection-only navigation via `WT_CD_FILE`/stdout (no launch), exit codes, the current-worktree-included menu, and the `wt open --go` composition. | 2026-06-20 | | [help-dump-contract](help-dump-contract.md) | Contract for the hidden `wt help-dump` command — the JSON envelope shll.ai's scheduled puller consumes. | 2026-06-20 | | [idle-staleness-contract](idle-staleness-contract.md) | The shared idle predicate, the `wt delete --stale` selector, and the safety invariant that idleness never gates a deletion on its own. | 2026-06-20 | | [init-failure-contract](init-failure-contract.md) | Init-failure behavior of `wt create` / `wt init` — kept-worktree contract, `ExitInitFailed`, SIGINT handling, and terminal-foreground reclaim. | 2026-06-20 | diff --git a/docs/specs/cli-surface.md b/docs/specs/cli-surface.md index 8658351..3dbcbe2 100644 --- a/docs/specs/cli-surface.md +++ b/docs/specs/cli-surface.md @@ -101,8 +101,9 @@ Positional arg `[name|path]`: reachable when the cwd is inside a git repo. Exit codes: `ExitInvalidArgs` when `--app` is used with the main-repo selection -menu; `ExitGitError` only when a git operation fails during name resolution -(not for path-only or no-args invocations from outside a repo); +menu; `ExitGitError` when a git operation fails during name resolution, or when +`--go` is invoked from a non-git cwd (the `--go` git-repo precondition) — but +not for path-only or no-args invocations from outside a repo; `ExitByobuTabError` / `ExitTmuxWindowError` for terminal-app failures; `ExitGeneralError` for unknown apps, unresolved targets, or name args supplied from a non-git cwd. From a4776e828012f306f438f46deb11394b845ae2d4 Mon Sep 17 00:00:00 2001 From: Sahil Ahuja Date: Sat, 20 Jun 2026 12:46:00 +0530 Subject: [PATCH 4/4] Update review-pr status --- .../260620-3pp5-open-worktree-from-worktree/.history.jsonl | 1 + .../260620-3pp5-open-worktree-from-worktree/.status.yaml | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/fab/changes/260620-3pp5-open-worktree-from-worktree/.history.jsonl b/fab/changes/260620-3pp5-open-worktree-from-worktree/.history.jsonl index 284f545..380eec6 100644 --- a/fab/changes/260620-3pp5-open-worktree-from-worktree/.history.jsonl +++ b/fab/changes/260620-3pp5-open-worktree-from-worktree/.history.jsonl @@ -9,3 +9,4 @@ {"event":"review","result":"passed","ts":"2026-06-20T07:03:18Z"} {"action":"enter","driver":"fab-fff","event":"stage-transition","stage":"ship","ts":"2026-06-20T07:08:00Z"} {"action":"enter","driver":"git-pr","event":"stage-transition","stage":"review-pr","ts":"2026-06-20T07:09:37Z"} +{"event":"review","result":"passed","ts":"2026-06-20T07:15:49Z"} diff --git a/fab/changes/260620-3pp5-open-worktree-from-worktree/.status.yaml b/fab/changes/260620-3pp5-open-worktree-from-worktree/.status.yaml index 6f7bc84..0d08abe 100644 --- a/fab/changes/260620-3pp5-open-worktree-from-worktree/.status.yaml +++ b/fab/changes/260620-3pp5-open-worktree-from-worktree/.status.yaml @@ -10,7 +10,7 @@ progress: review: done hydrate: done ship: done - review-pr: active + review-pr: done plan: generated: true task_count: 9 @@ -34,7 +34,7 @@ stage_metrics: review: {started_at: "2026-06-20T06:56:54Z", driver: fab-fff, iterations: 1, completed_at: "2026-06-20T07:03:18Z"} hydrate: {started_at: "2026-06-20T07:03:18Z", driver: fab-fff, iterations: 1, completed_at: "2026-06-20T07:08:00Z"} ship: {started_at: "2026-06-20T07:08:00Z", driver: fab-fff, iterations: 1, completed_at: "2026-06-20T07:09:37Z"} - review-pr: {started_at: "2026-06-20T07:09:37Z", driver: git-pr, iterations: 1} + review-pr: {started_at: "2026-06-20T07:09:37Z", driver: git-pr, iterations: 1, completed_at: "2026-06-20T07:15:49Z"} prs: - https://github.com/sahil87/wt/pull/23 change_type_source: explicit @@ -50,4 +50,4 @@ true_impact: computed_at_stage: hydrate summary: Add wt go (current-repo worktree selection, WT_CD_FILE+stdout navigation, no launch) and wt open --go composition; extract shared selectWorktree helper # true_impact: lazily created on first apply-finish (no placeholder here). -last_updated: 2026-06-20T07:09:37Z +last_updated: 2026-06-20T07:15:49Z