mirror of
https://github.com/max-sixty/worktrunk.git
synced 2026-09-14 20:00:38 +08:00
codex/remove-codex-cloud-specific-tests
126 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
7aba380f0c |
fix(remove): gate removal on the registration, not the repository (#3808)
`wt remove --force` deleted a live worktree of this same repository, uncommitted work included, whenever that worktree had been moved onto another worktree's registered path. The guard added in #3785 asks which *repository* the occupant answers to: a linked worktree's git dir sits under `<common>/worktrees/`, the main worktree's *is* the common dir, anything else is someone else's. A sibling worktree moved onto the path satisfies that — its git dir sits under `<common>/worktrees/` like any worktree of this repo — so it passed, and the fast path renamed the directory into trash and handed the `rm -rf` to a detached process. It is not prunable either: its gitdir file points at a location that exists, so the `is_prunable` arm from the same PR doesn't catch it. Git's own validation is one level finer. `validate_worktree` requires the directory to point back at *this registration*, and refuses this removal with `--force`: ```console $ git -C repo worktree remove --force ../repo.feature fatal: validation failed, cannot remove working tree: '.../repo.feature' does not point back to '.git/worktrees/repo.feature' ``` <details> <summary>Reproducer, verified against a build of main</summary> The occupant has to be *moved* onto the path rather than created there — `git worktree add` refuses a registered path, which is what leaves a plain `mv` as the way this state arises. ```console $ git -C repo worktree add ../repo.feature -b feature $ git -C repo worktree add ../repo.bar -b bar $ rm -rf ../repo.feature && mv ../repo.bar ../repo.feature $ echo PRECIOUS > ../repo.feature/precious.txt $ wt remove --force --yes feature ◎ Removing feature worktree (--force) & branch in background (same commit as main, _) $ ls ../repo.feature ls: ../repo.feature: No such file or directory ``` </details> ## The fix The gate is now git's comparison at git's granularity: the directory's `.git` must name *this* registration, and that registration's `gitdir` file must name the directory back. Repository-level ownership stays as the weaker half of the conjunction — it is what rejects a `.git` file pointing at another repository — and the main worktree is the same test where there is no registration to point back at. `ensure_belongs_to_repo` becomes `ensure_holds_this_worktree`, since it no longer merely asks about repository membership. Resolution moves to `Repository::git_dir_at`, the fs-only resolver the `wt list` prewarm already used (`derive_worktree_git_dir`), generalized to answer for a directory rather than for a known worktree of this repo: its main-worktree branch returned `git_common_dir()` on trust, and now canonicalizes the `.git` it actually found. It also never walks up to a parent, which is what git reads too — `git rev-parse --git-dir` in an emptied worktree can resolve the *enclosing* repository. That settles a second thing the old docstring got wrong. It claimed the plan→rename window was "narrower than `ensure_clean`'s"; in fact `ensure_clean` re-runs `git status` while this gate answered from `GIT_DIRS`, memoized process-wide, so the second call was vacuous and the window — which contains the approval prompt and the `pre-remove` hook — was unguarded. `git_dir_at` reads the filesystem on every call, so the check at the rename now re-decides. The refusal was `Directory @ … is not this repository's worktree`, which is false in the sibling case: it *is* one of this repository's worktrees, just not the one registered there. Its hint didn't fit either — "move the directory aside, then run `git worktree prune`" is a repo-wide prune, and with a sibling moved aside *both* registrations are prunable, so following it clears both and leaves a live checkout that has stopped being a worktree: ```console $ mv ../repo.feature ../repo.aside && git worktree prune -v Removing worktrees/repo.feature: gitdir file points to non-existent location Removing worktrees/repo.other: gitdir file points to non-existent location $ git -C ../repo.aside status fatal: not a git repository: (null) ``` So the error carries where the occupant's own registration records it, and each case gets the remedy that fits. Moving it back to that path leaves prune with only the stale entry to clear: ```console $ wt remove --force --yes feature ✗ Directory @ ../repo.feature does not hold the worktree registered there ↳ Removing it could destroy the worktree registered @ ../repo.other; move the directory back there, then run git worktree prune ``` That path is read through `canonicalize_with_parents`, because a relative `gitdir` entry resolves against `<common>/worktrees/<id>` and would otherwise reach the hint with the `..` chain still in it — and plain canonicalization can't normalize a directory that no longer exists, which is the case the arm is reached for. Normalizing there also makes `crate::path::paths_match`, the crate's canonicalizing comparison over that same helper, the right test for the gate, so there is no second comparison beside it. The gate's fail-closed behavior now rests on that helper resolving `..` through the filesystem rather than collapsing it lexically — across a symlink the two readings name different directories — so `src/path.rs` records the constraint where a lexical rewrite would otherwise read as a tidy-up. The FAQ's "What can Worktrunk delete?" paragraph carried the same "a *different* repository" framing and is corrected. ## Scope Pre-existing, and 0.73.0 already narrowed it — 0.72.0 had no ownership check at all and deleted foreign clones too. The guard has two call sites (`prepare_worktree_removal` at planning, `stage_worktree_removal` at the rename), so this reaches `wt merge --remove`, `wt step prune`, and picker removal, not only `wt remove`. One incidental tightening: `wt remove <bare-repo-path>` previously passed the guard (a bare root's git dir *is* the common dir) and was stopped only by the dirty check, which `--force` skips. It now refuses at the guard. <details> <summary>One residual, left alone</summary> `git worktree repair <path>` after the `mv` produces a *double registration*: both `worktrees/repo.bar/gitdir` and `worktrees/repo.feature/gitdir` come to record the same path, and `git worktree list` reports two worktrees there. In that state the new gate accepts the removal — the occupant does point at the `feature` registration, and that registration does point back — while git refuses, because its path→worktree lookup happens to match the `bar` entry first. Closing it means knowing the registration id at the gate, or scanning every `worktrees/*/gitdir` for duplicate claims. Unchanged by this PR, and reachable only via `mv` followed by `repair`. </details> ## Testing Five new tests, each confirmed to fail with the line it covers reverted and to leave the others passing. Three drive the binary: - **the sibling case** — follows #3785's data-safety model: asserts the filesystem afterwards, not just the exit code, since removal stages by rename and deletes in a detached process. Snapshots the refusal, so the hint and the path it names are pinned. Fails with the pointer-back conjunct removed, while the foreign-repo test still passes without it — the two cover different halves. - **the re-check at the rename** — a `pre-remove` hook repoints the worktree's `.git` at a sibling's registration after planning has already cleared it, which is what makes the second gate's freshness observable. Fails when resolution routes back through the `GIT_DIRS`-cached `git_dir()`. - **a relative `gitdir` entry** — removal succeeds, and git reads the rewritten entry back, which is what makes it the form git itself writes. Rewriting the entry rather than setting `worktree.useRelativePaths` keeps the test independent of the git version that introduced the option. Two sit at the gate, where the CLI can't reach: - **both worktree shapes are accepted** — including the main worktree, whose git dir *is* the common dir. `wt remove` rejects the main worktree well upstream of this gate and a bare repository's worktrees are all linked, so nothing through the CLI would notice that arm inverting. - **the refusal names a normalized path** — asserted against `Diagnostic::render`, since the path is in the hint and `Display` carries only the title. Local gate green: 4607 tests, lints, doctests, rustdoc under `-Dwarnings`. > _This was written by Claude Code on behalf of max-sixty_ --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
0cb3cc1ef7 |
test(remove): cover the reap guard's spared branch deterministically (#3805)
The controlling-terminal guard's sparing branch had no CI coverage.
`test_remove_reap_kills_process` branches on the suite's own terminal —
none in CI, so CI only ever exercised the kill side, and the branch that
protects an interactive process from `--reap` ran only on dev boxes.
`test_remove_reap_spares_terminal_process` gives the child its own PTY
instead of reading the suite's, so the spared branch runs everywhere.
`portable_pty` makes the child a session leader with that PTY as its
controlling terminal, which is exactly the property the guard reads. A
spared process and an undiscovered one produce identical output ("No
processes to reap"), so the test proves discovery sees the child first,
then pins the guard's verdict in-process, then asserts the child
survives removal.
The reap phase prints before `handle_remove_output` runs the removal, so
both tests now wait for the worktree to actually disappear — without it,
a `wt` that gave up after the reap phase would still satisfy every
output assertion. Both presence polls also move onto `wait_for`, the
existing presence-poll helper, instead of hand-rolled deadline loops.
`tests/CLAUDE.md` picks up two entries this work surfaced. The first is
the slow-timeout triage signature: a kill at the 180s bound is a
duration symptom with two causes, and the durations around it tell CPU
starvation (a sibling worktree's build) from a blocked call inside the
test. It records `threads-required` as starvation's only lever and why
it stays unset. The second distinguishes a bounded poll on one
identified `ErrorKind` from a retry, so `pin_test_binary` and
`forward_with_etxtbsy_retry` don't read as violations of the No Retries
doctrine — the opening sentence is narrowed to the re-run it actually
bans.
> _This was written by Claude Code on behalf of max-sixty_
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
715af4cd72 |
fix(tests): pin the spawned wt binary against concurrent cargo uplifts (#3784)
Two test-suite flakes fixed at the root, both dependencies on machine
load and concurrent builds.
**Concurrent-cargo spawn `NotFound`.** Cargo uplifts `target/debug/wt`
by removing the path and recreating it, so a second `cargo` against the
same target directory leaves the binary every test spawns absent for a
fraction of a millisecond per rebuild — the one-off `NotFound` spawn
failures that pass on re-run. `wt_bin()` now returns a hardlink pinned
under `target/debug/wt-test-bin/<mtime>-<len>/`: the uplift unlinks only
the uplifted name, so the pin keeps serving the observed binary through
any number of concurrent rebuilds, at no disk cost beyond the `deps/`
artifact whose inode it shares. `test_wt_spawns_are_pinned` keeps every
spawn routed through it. Reproduced by re-creating the uplift every 200
ms alongside a full `cargo nextest run`: 302 of 4583 tests failed before
this change (147 as direct `NotFound` spawn panics, five of them
byte-identical to the original shell-wrapper report), 4585 of 4585 after
— with an unrelated external cargo also rebuilding `wt` mid-validation,
absorbed the same way.
**`--reap` probe races.** `test_remove_reap_kills_process` predicted the
reap guard's verdict with its own `lsof`/`ps` snapshot, and under load
either probe's spawn can stall past the 5 s bound, whose fail-safe empty
result flips the outcome — a prediction `wt` then contradicts, or `wt`
reporting "No processes to reap" for a live child. The prediction now
reads the session's controlling terminal directly (`/dev/tty` opens iff
the session has one — the property the child inherits at spawn), the
probe timeout is env-pinnable (`WORKTRUNK_TEST_PROBE_TIMEOUT_MS`, set to
60 s in the static test baseline; production keeps its 5 s bound), and
the discovery poll uses the suite's 60 s presence-poll convention.
Looped 15/15 green at load average ~60, where the previous shape failed
2/10.
Not covered here, noted as follow-ups: benches still spawn
`env!("CARGO_BIN_EXE_wt")` directly (same hazard, separate runner,
outside the guard's scan), and the reap "spared" branch has no
deterministic end-to-end test (needs a PTY-held child).
> _This was written by Claude Code on behalf of max-sixty_
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
cf822f75cf |
fix(worktree): guard a registered path that no longer holds its worktree (#3785)
A registered worktree's path was trusted to still hold that worktree.
Three defects followed, one of them destroying data, and the check that
would have caught them — where it existed at all — was `Path::exists()`.
## `wt remove --force` deleted an unrelated repository
A clone that came to sit at a stale registration's path was removed
whole, including uncommitted work and, for a repo never pushed, the only
copy of its objects. wt's own hint routed the user there: the dirty gate
reads `git status` in that directory, reports the occupant's changes as
this worktree's, and offers `--force` as the cure.
```console
$ wt remove feature
✗ Cannot remove worktree: feature has uncommitted changes
?? precious.txt # ← the other repo's file
↳ ... to lose uncommitted changes, run wt remove --force feature
```
git refuses that same removal, `--force` included (`validation failed …
is not a .git file`). Worktrunk's fast path renames the directory into
trash rather than asking git to, so git's validation never ran.
`ensure_belongs_to_repo` makes it, comparing the directory's git dir
against this repository's: a linked worktree's sits under
`<common>/worktrees/`, the main worktree's *is* the common dir, anything
else answers to someone else. One comparison covers both worktree kinds
and also rejects a `.git` file pointing at another repo, which git's
shape test accepts.
It runs at planning, ahead of the dirty gate, and again at the rename
for callers that stage without planning. Now:
```console
$ wt remove --force feature
✗ Directory @ ../repo.feature is not this repository's worktree
↳ Removing it could destroy unrelated data; move the directory aside, then run git worktree prune
```
## A recreated worktree directory leaked git's exit 128
`wt switch`, `wt merge`, and `wt step push` walked into `git rev-parse
--git-dir failed (exit 128)`. Two of them probed `Path::exists()` first,
which a deleted-and-recreated directory passes; the third asked nothing.
`worktree_is_unusable` is the union of both tests, because neither
implies the other. `exists()` catches the absent directory; git's
`prunable` catches the recreated one. `prunable` alone is *not* the
wider test it looks like — git withholds the attribute from a **locked**
worktree even when its directory is gone, since prunability is its
pruning policy and a lock means "don't prune this":
```console
$ git worktree list --porcelain # wt2 locked, all three directories removed
worktree /tmp/ptest/wt1
prunable gitdir file points to non-existent location
worktree /tmp/ptest/wt2
locked removable media # ← no prunable line
worktree /tmp/ptest/wt3
prunable gitdir file points to non-existent location
```
A locked worktree on an unmounted volume is exactly what
`prepare_worktree_removal`'s lock guard exists for, so a `prunable`-only
test would read it as healthy. All three commands now give the message
the merely-deleted case already gave.
`wt remove` keeps `exists()`, deliberately: there it is the precondition
for the cleanup path rather than a health test, since
`prune_worktree_entry` unregisters via `git worktree remove`, which
skips validation only while the directory is absent. No scoped git
command clears the recreated case, so it reports and names the repo-wide
`git worktree prune` that does.
## `wt switch docs/` missed a branch sitting right there
Git's ref format forbids a trailing `/`, so the branch lookup never had
a candidate — and shell completion produces exactly that spelling
whenever a `docs` directory sits beside the branch. Selectors are
normalized before resolution.
## Resolving selectors through one ladder
The three fixes landed in three of the four places that assemble "expand
shortcuts, try the branch, try the path, classify the failure" by hand.
Each gated its path attempt on "did something rewrite this token?",
answered by comparing an expansion's output against its input:
| where | the comparison |
|---|---|
| `resolve_worktree` | `branch == name` |
| `plan_switch` | `target.branch == branch` |
| `target_worktree_at_path` | `target.filter(\|t\| *t == resolved)` |
| `resolve_base_ref` | `resolved == base` |
That is a fact the rewriting step knows, re-derived downstream from its
output, and it is wrong in both directions. A shortcut can expand to the
token it was given — `-` pointing at the branch you are already on — and
string equality reads that as a literal, turning the path arm back on
for a token nobody typed. Normalization breaks it the other way, which
is why the trailing-separator fix needed threading through three call
sites.
`Selector` carries the fact instead: `expand_shortcut` reports whether
it fired, `wt switch` reports its `pr:`/`mr:` dispatch and remote-prefix
strip, and `names_a_path()` replaces all four comparisons.
`resolve_selector` is the ladder, and `plan_switch` expands into it
rather than re-implementing its phases.
`names_a_path()` gates both path steps together — the worktree-by-path
lookup and the directory verdict — which is what `wt switch --create`
needs: the argument names a branch to create, so `branch_only()` takes
the arm off at the producer rather than each consumer re-testing
`create`.
It also reaches the directory verdict, so `ResolvedWorktree` gains
`NoWorktreeAtPath` and the four sites that called `path_selector_error`
themselves stop re-deriving it. The docstring defending that laziness
didn't survive checking — the function returns on `is_valid_branch_name`
before touching the filesystem, so every ordinary branch name already
short-circuited.
| | before | after |
|---|---|---|
| `normalize_selector` call sites | 3 | 1 |
| `path_selector_*` call sites | 4 | 2 |
| "was it rewritten?" comparisons | 4 | 0 |
## Navigating the diff
- `src/git/repository/mod.rs` — `Selector`, `normalize_selector`, the
new `ResolvedWorktree` variant.
- `src/git/repository/worktrees.rs` — `expand_shortcut`,
`expand_selector`, `resolve_selector`, `usable_worktree_for_branch`.
- `src/git/repository/working_tree.rs` — `ensure_belongs_to_repo`, the
ownership check.
- `src/git/remove.rs`, `src/commands/repository_ext.rs` — where it gates
removal, and why before the dirty gate.
- Call sites: `commands/worktree/switch.rs`,
`commands/worktree/push.rs`, `commands/merge.rs`, `commands/remove.rs`,
`git/repository/config.rs`.
## Size
Comments and docstrings are the largest share: the ownership check and
the four conditions behind the directory verdict all look like things to
simplify away, so the reason each exists is recorded where it's
enforced.
| | + | − |
|---|---:|---:|
| Production code | 277 | 142 |
| Comments & docstrings | 320 | 75 |
| Tests | 325 | 8 |
| Snapshots | 186 | 0 |
| Docs | 6 | 0 |
| **Total** | **1114** | **225** |
## Testing
Seven new tests. The data-safety one drives the real binary and asserts
the filesystem afterwards, not just the exit code — removal stages by
rename and deletes in a detached process, so a passing exit would not
have caught a staged-then-deleted tree. The others cover the recreated
directory (switch and remove), the trailing separator, `--create`
against a worktree registered at that path, and, at the unit boundary,
the four states of `worktree_is_unusable` — healthy, absent,
locked-and-absent, recreated — and the selector's path-ness, including
the degenerate case string equality got wrong. Each new test was
confirmed to fail with its fix reverted.
One more covers an omitted merge target in a repo whose default branch
can't be determined. `^` had a test for that error; the omitted-target
route to the same message had none. The gap predates this branch — the
closure is byte-identical to the one it replaces and codecov records
those lines as missed at the base commit too — but relocating them into
`resolve_target_selector` re-counted them as patch lines, which is what
surfaced it.
Local gate green: 4593 tests, lints, doctests, rustdoc under
`-Dwarnings`.
<details>
<summary>Behavioral matrix, verified against a build</summary>
```console
docs/ (trailing sep) ▲ Worktree for docs @ ../repo.docs
detached by path ▲ Worktree for detached worktree @ ../repo.det
leftover dir ✗ No worktree @ ../repo.leftover
recreated dir ✗ Worktree directory missing for rec
shortcut ^ ▲ Worktree for main @ ../repo
remove leftover ✗ No worktree @ ../repo.leftover
--base docs/ ✓ Created branch nf from docs
foreign-repo remove ✗ Directory @ ../repo.frn is not this repository's worktree
precious.txt survives
```
</details>
<details>
<summary>Also swept, and one thing left alone</summary>
Three more instances of the same shape, fixed here:
- `resolve_base_ref` was the fourth copy of the comparison, so `--base
docs/` now resolves too.
- `hint_for_repo` suggested `wt switch ^` after an existence probe a
recreated directory passes, pointing at a worktree the switch then
refuses.
- The pre-switch hook's `target` var used the bare shortcut expander, so
a hook saw `docs/` where the switch resolved `docs`.
The identical unborn/stale default-branch block in
`require_target_branch` and `require_target_ref` is extracted. The rest
of that pair differs in its existence predicate, extra arms, and final
error; sharing it would cost more in parameters than the duplication
does.
Left alone: `live_sibling_checkout` decides whether another worktree
still holds a branch during removal, and also uses `exists()`. Switching
it to `prunable` would make branch deletion *more* likely in a corner
case where the detached path already answers the other way. That is a
data-safety surface and a separate decision.
</details>
> _This was written by Claude Code on behalf of max-sixty_
|
||
|
|
f3ea2fd598 |
fix(errors): report a directory holding no worktree as a directory (#3773)
Passing a path that holds no worktree used to be reported as a missing branch: ``` $ wt remove /repo/.claude/worktrees/ghost ✗ No branch named /repo/.claude/worktrees/ghost ↳ To list branches, run wt list --branches --remotes ``` The argument is plainly a path; the error calls it a branch and sends the user to a listing it could never appear in. Now: ``` $ wt remove /repo/.claude/worktrees/ghost ✗ No worktree @ /repo/.claude/worktrees/ghost ↳ The directory exists but is not a worktree; to list worktrees, run wt list ``` #3767 fixed the hook that produced the leftover directory in #3753; this fixes the message a person gets when they type such a path themselves, which judewang named there as why it was hard to diagnose. Ref #3753. Two neighbouring commands had the same cause and are also fixed. `wt switch <path>` and `wt merge <path>` used to offer `wt switch --create <path>`, which fails with `fatal: … is not a valid branch name`; and `wt config state marker set --branch <path> foo` **succeeded silently**, storing state keyed by a path string. ## How the decision is made `Repository::path_selector_error` reports a directory only when all four conditions hold. Each is here because dropping it made `wt` assert something false, reproduced against a build: | Condition | Without it | |---|---| | Git could never accept the selector as a branch name | `wt step rebase HEAD~9` reports revision syntax as a path | | A directory is there | `wt remove docs` stops meaning the branch when `docs/` sits beside it | | No worktree of this repo is registered at it | a *detached* worktree — reachable by path alone — is reported as absent, contradicting the `wt list` in the hint | | It holds no git data | a sibling repo's live checkout, or a bare repository, is called a leftover only the user can delete | That last one is the data-safety case: the new variant's hint says only the user can delete the directory, so a false positive reads as an invitation to `rm -rf` a checkout with uncommitted work. Absence of git data is *established* rather than assumed — `symlink_metadata` with only `NotFound` counting, so an unreadable clone or a dangling `.git` symlink withholds the claim instead of earning it. The first condition is git's own ref-format rules, implemented in-process and pinned against `git check-ref-format` by a test. ## Navigating the diff - `src/git/repository/worktrees.rs` — `Repository::path_selector_error` and `holds_git_data`, with the rationale for each condition recorded where it's enforced. - `src/git/repository/branch.rs` — `is_valid_branch_name`, the ref-format predicate. - `src/git/error.rs` — the new `GitError::WorktreeNotFoundAtPath` variant. - `src/git/repository/config.rs` — `target_branch_at_path` → `target_worktree_at_path`, returning the worktree whole rather than collapsing "no worktree here" with "a detached worktree here". - Call sites: `commands/remove.rs`, `commands/worktree/switch.rs`, `commands/config/state.rs`. Reporting a detached *target* made `DetachedHead`'s fixed hint wrong: `git switch` acts on the tree it runs in, so `wt merge ../B` was telling the user to switch the tree they were standing in. The variant now carries the detached worktree when that isn't the current one, and the hint becomes `git -C ../B switch <branch>` — the same reason `OperationInProgress` carries `branch`. Set at the three sites where the detached worktree isn't the current one (`require_target_branch`, `require_selected_branch`, and `wt step promote`, which raises this about the main worktree); every other site passes `None` and renders as before. 140 of the 257 added production lines are comments and docstrings — three of the four conditions look like obvious things to simplify away, so the reason each exists is written down next to it. ## Testing Unit tests at the resolver boundary cover each condition and its complement, both `directory_exists` arms, the shortcut-expansion route (`wt merge -`), detached targets, revision syntax, unresolvable `.git` entries, and bare repositories. Two integration tests drive the real binary end-to-end against `../repo.leftover` — `wt remove` and `wt switch` — covering the relative-path spelling and each command's wiring, and the `switch` snapshot pins the thing that would regress silently: that no `--create` hint is offered for a name git would reject. The ref-format predicate is verified beyond its committed table: 2,791 generated names were run against real `git check-ref-format` with zero disagreements, and two review passes did the same over their own inputs. That sweep isn't committed — it needs Python and a live `git` — so the committed table is what guards against drift. Not exercised locally: the Windows-specific behaviours are reasoned, not run — `Path::new("foo.").is_dir()` succeeding against `foo`, and drive-relative `C:foo`. CI exercises the platform but not those inputs. <details> <summary>Known gaps this would ship with</summary> - `wt switch docs/` (trailing slash, branch `docs` has a worktree, `./docs` is a source directory) answers `No worktree @ docs/`. True, unhelpful. The real fix is stripping trailing separators before resolution — a resolution change, not a message change. On Windows it reads `No worktree @ …/docs`, because `path_slash`'s `to_slash_lossy` rebuilds the string from `Path::components()` there and drops the trailing separator — still true of the path, minus the character that explains it. The doc comment on `path_selector_error` now says so rather than promising a spelling guarantee that holds on one platform. - A worktree whose registration was pruned out from under it gets a safe-but-vague message. The useful answer is a distinct `git worktree repair`-or-delete diagnosis; that's new detection, and nobody has reported hitting it. - Under `-C`, a `./`-prefixed selector renders a visible `/./` (`wt -C repo remove ./ghost` → `repo/./ghost`). Cosmetic; tidying it would discard the trailing separator the `docs/` case needs. - `require_target_branch` and `require_target_ref` remain near-copies of one ladder, and this adds a line to each. Pre-existing; extracting it is a separate refactor. - `CommandEnv::require_branch` passes `worktree: None` unconditionally, and `CommandEnv::for_selector` builds an env whose `worktree_path` is the worktree the *argument* named — so a future caller combining the two would get the unqualified hint. Nothing reaches that combination today: `merge` and `squash` are the only `require_branch` callers and both build with `for_action`, and the one `for_selector` caller (`wt step commit --branch`) never calls it. Two pre-existing bugs surfaced while reviewing this, unrelated to the diff and reproducible by branch name: `wt remove` will trash a clone that now sits at a registered worktree's path, and a recreated worktree directory leaks a raw `git rev-parse --git-dir failed (exit 128)`. </details> > _This was written by Claude Code on behalf of max-sixty_ --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
860fa02ecf |
fix(output): don't panic when a --format=json consumer closes the pipe (#3746)
`print_json` — the pretty-JSON printer behind `--format=json` — lived in
`src/commands/list/mod.rs`, under the one command that happened to need
it first, while `wt list statusline` and `wt config approvals list`
reached across for it. It now lives in `src/output/json.rs`, re-exported
as `crate::output::print_json`.
Moving it surfaced thirty other call sites that had each open-coded the
same two lines, and that those two lines were not equivalent everywhere.
Four printed through anstream's `println!` (via `worktrunk::styling`),
the other twenty-six through std's. anstream drops a `BrokenPipe` write
error; std panics. So on main today:
```console
$ wt config state get --format=json | head -3
{
"ci_status": [
{
thread 'main' panicked at library/std/src/io/stdio.rs:1165:9:
failed printing to stdout: Broken pipe (os error 32)
```
while `wt config show --format=json | head -3` exits cleanly — same
command family, different behavior, decided by which macro happened to
be imported in that file. `EPIPE` depends on whether a reader is still
attached, not on how much is written, so this is a race rather than a
size threshold: an output that fits the pipe buffer usually lands before
the consumer goes away, which is why it stayed hidden rather than why it
is safe. A three-byte write panics just as readily once the reader has
closed.
All thirty-six call sites now go through `print_json`, and `print_json`
prints through anstream, so no `--format=json` surface panics on a
closed pipe. The command above now exits 0 with an empty stderr; also
checked by hand on `wt list`, `wt step prune --dry-run`, `wt config
show`, `wt step eval`, `wt config approvals list`, and `wt list
statusline`.
`wt switch --format=json` is the one surface that isn't a `print_json`
caller: it emits its single result as one compact line, which is what a
shell loop reads, so converting it would change a machine-readable
format for no gain. It gets the same anstream printer instead, which is
the part that matters here.
`wt list statusline --format=json` had a third instance hiding in its
schema-1 empty path, a bare `println!("[]")` on std's macro — on the
surface that runs on every prompt redraw. It now serializes an empty
array through `print_json`, which emits the same two bytes;
`test_statusline_json_outside_worktree` already asserted them. A
module-level `println` import would have been wrong there, since the
text statusline's `println!` deliberately bypasses anstream so its
pre-rendered ANSI survives a non-tty stdout.
Pruned eight now-dead entries from `STDOUT_ALLOWED_PATHS` in
`tests/integration_tests/output_system_guard.rs`. Centralizing these
writes left seven allowlisted files with no direct `println!` at all, so
their exemptions covered no code and the guard quietly stopped
protecting exactly the files this branch touched; a stray `println!` in
`merge.rs` or `remove.rs` fails the test again. (`alias.rs`'s entry was
already dead.)
Serialization is otherwise byte-identical: serde escapes control
characters, so raw ANSI never reaches this output and anstream's
stripping is a no-op on it.
`src/commands/CLAUDE.md` gains one line under "Adding a CLI Command"
stating the rule — stdout through anstream, `print_json` as the printer,
switch's compact line as the one shape difference — so the next JSON
surface doesn't open-code it again.
Two `print_json` calls the diff rewrote were uncovered on the base
commit, which pulled their misses into `codecov/patch` without any
behavior changing. Rather than hand over a waiver, both now have tests:
`wt remove --format=json` with no branch argument (the single-worktree
path, distinct from the named removals already tested) and `wt step
for-each --format=json` interrupted mid-loop, which still owes its
consumer the results collected before the signal. Each was
mutation-checked — deleting the line under test fails it — because a
one-item array is what both the abort and completion paths produce, so a
weaker assertion would pass either way.
> _This was written by Claude Code on behalf of max-sixty_
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
5c42c5b7d5 |
feat: machine-readable approval state and branch-removal outcomes (#3710)
Two of the five machine-readable-output requests NathanaelRea opened (#3696–#3700), reviewed as a set and implemented where the gap was real. ## `wt config approvals list --format=json` (#3698) The command already computed the four distinctions an orchestrator needs — no commands, approved, approval-required, and stale — read-only, without prompting or writing. It had no `--format` flag, so the only way to learn that a non-interactive run would stop for approval was to run the operation and catch `NotInteractive`, or to pass `--yes` and approve whatever was there. ```json { "state": "approval_required", "commands": [ {"phase": "post-start", "name": "dev", "template": "npm run dev", "approved": false}, {"phase": "pre-merge", "template": "cargo test", "approved": true} ], "stale": ["some removed command"] } ``` `state` is what a caller branches on. `stale` stays a separate list rather than a fourth `state`, because it co-occurs with all three — and those are the approvals `--yes` would silently re-approve after their command template changed, which is exactly what an orchestrator preserving the approval model needs to see. A flag on the existing read command rather than a new `status` verb, matching `wt config show`, `wt config state get`, `wt config state logs`, and `wt list`. Closes #3698. ## `branch_outcome` on removal (#3700, partly) `wt remove --format=json` reported the branch as one boolean, collapsing five internal outcomes into two values: | Internal outcome | `branch_deleted` was | |---|---| | `Deleted` | `true` | | `Deferred` — handed to a detached process, result never observed | `true` | | `NotAttempted` — no branch, or `--no-delete-branch` | `false` | | `Retained` — a sibling worktree has it checked out | `false` | | `Retained` — **the CAS refused; the ref moved under us** | `false` | The last row is the exact race #3700 asks for protection against. Worktrunk already deletes with `git update-ref -d <ref> <oid>` and already fails closed when the ref has moved — then reported it as the same `false` that means "you asked me not to". And `Deferred` reported `true` on intent. `branch_outcome` names it instead: `deleted`, `deferred`, `not_attempted`, `retained_unmerged`, `retained_checked_out`, `retained_raced`, `retained_failed`. A caller that sees `retained_raced` knows to re-read the ref and retry, which is what the guard detects it for. **This does not close #3700.** That issue asks for an *input* — a caller-supplied expected OID that makes `wt` fail closed against the orchestrator's own observation. This is an *output*. They land in the same place on the default path, because the integration check already refuses to delete unintegrated content, so the caller was never going to lose commits — they just couldn't classify the refusal. Where the gap is real is `--force-delete` / `-D`, which takes the early return in `delete_branch_if_safe` and runs `git branch -D` with no integration check and no CAS. If an `--expected-oid` flag lands, it has to gate that path. ## Notes - **Output-format break.** `branch_deleted` is replaced, not supplemented, on `wt remove --format=json` and on `wt step prune --format=json`'s live path. Per CLAUDE.md, output formatting is on the flexible side of the interface line; flagging it here so the release changelog picks it up. - **`wt step prune --dry-run` keeps `branch_deleted`.** A dry run predicts; it runs nothing to have an outcome. Different thing, different name, documented as such. - **`retained_raced` and `retained_checked_out` have no deterministic CLI trigger.** Both come from windows between `wt`'s own fresh read and the ref mutation, which no hook can be scheduled inside. They're covered at the unit level (`branch_fate_from_result_mapping`, `branch_fate_json_outcome_is_distinct_per_fate`, and `cas_rejects_delete_when_branch_advances` in `src/git/remove.rs`, which drives the race with a stale snapshot). The integration tests cover the two reachable contrasts: `retained_unmerged` via a `pre-remove` hook that commits, and `not_attempted` via `--no-delete-branch`. - **`print_json` lives under `src/commands/list/`** and now has a third caller from outside that module. Worth a more central home; not moved here. ## The other three Reviewed but not implemented: - **#3696** — already possible. `wt --config-set 'list.json-schema = 2' list --format=json` pins the schema per invocation above every config layer, as does `WORKTRUNK_LIST__JSON_SCHEMA`. Answered on the issue; what's left is a docs gap and making an out-of-range value fail rather than degrade in JSON mode. - **#3697** — the machine-readable error channel. A real gap and the one policy call in the set; not started. - **#3699** — aimed at `wt config state logs --format=json`, which is a directory listing reconstructed from paths, under a model that overwrites. The append-only run record it wants is `commands.jsonl`. ## Testing `cargo run -- hook pre-merge --yes` green: 4533 tests, clippy, fmt, doctests, rustdoc, docs sync. > _This was written by Claude Code on behalf of max-sixty_ --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
bbd24fb8f3 |
refactor(remove): make one function own the pre-removal gate (#3694)
Follow-up to #3690, which recorded the decision that removal's final dirty-worktree gate is answered by the fsmonitor daemon, exactly as `git worktree remove`'s own gate is. The decision went into the `src/git/remove.rs` module spec — but the default removal path never reads that module. Both paths ran the same three ordered steps before the worktree directory stopped existing: dirty-worktree gate, fsmonitor stop, rename into trash. They were two copies with near-identical comments. `execute_instant_removal_or_fallback` (`src/output/handlers.rs`, the default background path for `wt remove` and `wt merge --remove`) assembled them inline and never called `remove_worktree_with_cleanup`, so a reader working on the gate that issue #3646 was actually about would not find the reasoning written for it. `stage_worktree_removal` now owns all three steps and both paths call it. The gate becomes one decision instead of a sequence each caller re-assembles, and the ordering rationale — why the gate precedes the daemon stop, why the stop precedes the rename — lives on the function that implements it. The rename-and-prune step it previously named becomes the private `rename_into_trash`. No behavior change. ## Stale spec claims this surfaced - `stop_fsmonitor_daemon` documented three callers ("the library `remove_worktree_with_cleanup`, the foreground handler, and the background `spawn_background_removal`"). The foreground handler goes through `remove_worktree_with_cleanup`, so it was two; after this change it is one. - The module opened by calling itself "the canonical removal flow used by `wt remove`, `wt merge --remove`, and the TUI picker". `wt merge --remove` takes the background path, and so does `wt remove` unless `--foreground` is passed. ## Test Nothing pinned the final gate on the **default** path — the existing test covers `--foreground` only. Planning validates cleanliness before `pre-remove` runs, so a hook that dirties the worktree can only be caught by the gate immediately before the mutation. The test is now parameterized over both execution modes. Verified by mutation rather than by assuming the assertion binds: with the gate disabled, the background case reports `Removed feature-hook-dirties worktree & branch` and destroys the hook's file, which is the data-loss scenario #3646 described. Ref #3646 > _This was written by Claude Code on behalf of Maximilian_ --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
1eded27943 |
Simplify forge, shell integration, and removal internals (#3662)
This consolidates cross-cutting models that had accumulated parallel representations, while preserving the CLI and config interfaces. ## What changed - Forge identity now flows through one `ForgeKind`, with boundary-safe network-host classification shared by CI, remote references, and structured repository metadata. Branded SSH aliases such as `github-personal` remain outside provider dispatch and receive an actionable `forge.platform` diagnostic when forge data is requested. - Worktree removal now uses one owned target type and rechecks uncached worktree topology immediately before compare-and-swap branch deletion, retaining branches that gained a live or locked checkout. - Shell integration no longer implements the retired single-file directive writer. Stale wrappers receive repair guidance, execution fails closed, and child processes cannot inherit the retired sourceable-file capability. - Zsh and Git-version probes are shared, while dead mocks, redundant dependency edges, serializer detours, pass-through types, and duplicate tests are removed. The removal guard deliberately distinguishes live, stale-prunable, and locked registrations. The detached cleanup path performs the same record-aware check before deleting a branch ref. ## Testing `cargo run -- hook pre-merge --yes` passed after merging current `origin/main`: 4,489 tests, Clippy, formatting, lockfile checks, docs, doctests, and snapshot review. > _This was written by Claude Code on behalf of max_. |
||
|
|
6d09125b7b |
test: converge suite on semantic boundaries (#3663)
This follows the first test-simplification tranche by converging the remaining suite around distinct semantic and pragmatic contracts rather than raw case count. The branch removes false-confidence tests, invalid setup variants, repetitive snapshots, and expensive PTY overlap while strengthening the retained route, precondition, and interaction proofs. ## What changed - Replace obsolete CI-status integration mocks and blank snapshots with direct provider semantics, mixed-priority cases, and strict GitHub/GitLab route assertions. - Remove free-riding merge, push, remove, list, security, config, and switch cases whose setup never reached the named behavior; consolidate repetitive direct cases into labeled tables. - Reduce the switch picker from 42 PTYs to 19 distinct terminal contracts, using causal release gates for asynchronous loading and repaint behavior. - Add a cached main-only picker fixture, eliminating 138 unnecessary Git subprocesses across the retained PTYs, and integrate it with main's generated hermetic standard fixture. - Tighten test guidance around proving setup preconditions and mock invocation routes, and correct the comments-tab help text and generated mirrors. ## Reviewer map - `tests/integration_tests/ci_status.rs` and `src/commands/list/ci_status/`: provider semantics and route coverage. - `tests/integration_tests/switch_picker.rs`, `src/commands/picker/`, and `src/testing/`: retained PTY contracts, causal mocks, and fixture design. - `tests/integration_tests/config_show.rs`, `src/config/deprecation.rs`, `src/config/expansion.rs`, and worktree type/resolve tests: direct-boundary consolidation. - `tests/CLAUDE.md`: the testing rules extracted from the false-confidence cases found during the survey. The measured loop removed 98 tests, 65 snapshots, and 23 picker PTYs. Controlled warm Nextest execution improved from a 79.593-second mean to 72.574 seconds (8.8%), while comparable production-line coverage moved from 97.32% to 97.23%. The tracked PR diff is a net deletion of more than 5,700 lines. ## Validation - `cargo run -- hook pre-merge --yes` after syncing current `main`: 4,468 passed, one configured skip; docs, doctests, clippy, formatting, policy checks, and snapshots green. - `task coverage` on the completed change before the base sync: 4,465 passed, one configured skip; 97.23% comparable production-line coverage. - Three independent final audits found no remaining lost beliefs, fixture hazards, or safe PTY consolidations. > _This was written by Claude Code on behalf of max_. |
||
|
|
4554f50ce6 |
perf(tests): stop leaking a temp dir per test, and measure where the suite's CPU goes (#3604)
Measured where the test suite's CPU actually goes, fixed what was doing
real extra work, and added `task profile-tests` so the measurement is
repeatable.
## Baseline
`cargo nextest run --features shell-integration-tests` on an 18-core
M-series machine: 4,570 tests, ~95s wall, **988 CPU-seconds (332 user +
655 sys)**.
Two thirds is kernel time, so the cost is process creation and
filesystem churn rather than computation. The integration binary is 86%
of summed self-time: 2,184 tests at ~0.6s each, each spawning `wt` (a 65
MB debug binary, ~11ms CPU per spawn against 2.6ms for a trivial
process) and `git` against a fresh fixture copy. It sits in a broad
middle, not a few outliers: 73% of self-time is in tests taking 0.25 to
2.0s.
## One leaked temp directory per test
`isolated_test_cwd()` held a `TempDir` in a `LazyLock`. Statics don't
run destructors at process exit and nextest runs one process per test,
so every test leaked an empty directory into the system temp root.
Measured with `TMPDIR` pointed at a fresh directory: **704 per
integration-suite run**. This machine had accumulated **454,907 entries,
353,268 of them empty strays older than a day**.
Stale entries are cheap to ignore but expensive to enumerate, and
`git::recover::recover_from_path` reads every ancestor directory of a
deleted CWD:
| temp root | `test_recover_from_path_returns_none_for_unrelated_path` |
|---|---|
| 454k entries | 14.2s (34.4s on a quieter run) |
| empty | 0.27s |
One fixed directory replaces it. Leaks per run: 704 to 0, verified
across full suite runs, and the directory is still empty after ~9,000
test executions.
I also checked whether a crowded temp root slows ordinary temp
operations. It does not: create, populate and delete of a fixture-sized
tree ran at 21ms/iter in a 454k-entry parent against 40 to 60ms in an
empty one. The leak's cost is concentrated entirely in code that
enumerates.
## Fixture temp dirs no longer sit in the shared temp root
The fixtures created their temp directories directly in the system temp
dir, among however many entries the machine had put there.
`test_temp_root()` (`$TMPDIR/wt`) roots them one level in, and
`test_tempdir()` replaces `TempDir::new()` across every `TestRepo`
constructor, the mock-command helper, the `temp_home` fixture and the
recovery tests. That test again: **14.2s to 0.06s**.
Two constraints worth knowing, both found by trying the more aggressive
version first:
- **A cache-dir root fails.** `~/Library/Caches` is under `/Users/`,
which a conditional `includeIf "gitdir:/Users/"` matches, so 16 picker
tests fail their commits under `commit.gpgsign` — they drive git through
`Repository::run_command`, the production API with no isolated config.
`step_promote` above was not a one-off: the suite was hermetic against
host git config only because macOS puts `$TMPDIR` outside `/Users/`.
That is the hole the next section closes — at the layer that covers
in-process git, not by choosing where temp files live.
- **The root's name is load-bearing.** A unix socket path cannot exceed
`sun_path`, 104 bytes on macOS. The canonicalized per-user `$TMPDIR` is
56, and `test_copy_ignored_skips_non_regular_files` binds a listener 89
bytes in. `worktrunk-tests` (16 bytes with its slash) overflowed by two;
`wt` costs 3 of the 14 spare.
The ~240 tests that call `tempfile` directly still use the system temp
dir. They are transient and a clean run leaks nothing, so converting
them is tidiness rather than a fix.
## A test that passed by accident of TMPDIR location
`step_promote::test_promote_bare_repo_with_worktrees` drove git through
bare `Cmd::new("git")` instead of `configure_git_env`, so the host's
config applied. A conditional `includeIf "gitdir:/Users/"` enabling
`commit.gpgsign` fails its commit, but only when `TMPDIR` sits inside
the matched tree. macOS puts `TMPDIR` under `/var/folders`, so it
passed; pointing `TMPDIR` anywhere under the home directory failed it.
## The suite was not actually isolated from the developer's git config
Chasing the temp-root change turned up a real hole. `TestRepo` exposes
the production `Repository` type, and `Repository::run_command` builds a
plain `Cmd::new("git")` with no `GIT_CONFIG_GLOBAL`, so it inherits the
test process's own environment. Separately, a bare `wt_command()` had no
`GIT_CONFIG_GLOBAL` at all and fell through to `~/.gitconfig`. 280
`Repository::at/current/discover` constructions across 31 test files and
156 direct `run_command*` calls in test code sat on that path.
Signing was only the symptom that surfaced. Confirmed leaking from a
real developer config: `commit.gpgsign`, `core.fsmonitor` (spawns a
daemon per fixture repo), `worktree.guessremote` (changes `git worktree
add`, directly under test), `help.autocorrect = prompt` (a mistyped git
command blocks). Structurally: `core.hooksPath`, `credential.helper`,
`filter.*` clean/smudge and `diff.external` all execute arbitrary
programs; `url.*.insteadOf` rewrites remotes; `merge.conflictstyle`,
`diff.context`, `rebase.autostash`, `fetch.prune`, `push.default` all
change what the code under test observes. That set is unbounded, which
is why hardening each fixture's local config was rejected — a denylist
can't cover it, and local config cannot unset an inherited `[include]`
or `credential.helper` at all.
The floor is one constant, `shell_exec::HERMETIC_TEST_GIT_ENV` — the
deny pair pointing `GIT_CONFIG_GLOBAL` and `GIT_CONFIG_SYSTEM` at a path
that does not exist, plus the settings the suite needs through
`GIT_CONFIG_COUNT` / `GIT_CONFIG_KEY_n` / `GIT_CONFIG_VALUE_n` — applied
to every child at its spawn site. There is no git-config file anywhere
in the repo. For the spawn sites the harness owns (`git_test_env`,
`configure_git_cmd`, `isolate_subprocess_env` for `wt` children,
`pty_env_vars` for `env_clear`ed PTY children) that's ordinary per-child
env. For the git that *production* code spawns while a test drives it
in-process, the test never holds the command — so the harness flips an
atomic latch (`shell_exec::enable_hermetic_test_env`, called from the
fixture constructors), and `Cmd`, the choke point every production spawn
passes through, applies the floor to each child while the latch is set.
Setting env on a child is safe; it was setting the test process's *own*
env that wasn't (`set_var` races the other test threads), and the latch
dissolves the need for it — no `unsafe`, no pre-`main` constructor, no
cargo `[env]`. Because the latch lives in the binary, every runner
agrees by construction: `cargo test`, nextest, `cargo llvm-cov`, `cargo
bench`, an IDE, a debugger, a directly executed
`target/debug/deps/integration-*`. `.config/nextest.toml` was tested and
rejected, and is now ruled out standing: nextest 0.9.132 has no `[env]`
key, and a `$NEXTEST_ENV` setup script would miss the three non-nextest
runners CI uses (`cargo llvm-cov`, the Nix `cargo test` derivation,
`cargo bench`). A runner-specific knob doesn't fail loudly when another
runner misses it — it yields a different result, usually in the coverage
job whose numbers gate a merge. `tests/CLAUDE.md` → One Result Per Test,
Whatever Runs It records the rule; `.config/nextest.toml` points at it
from the place someone would be tempted.
Acceptance test, since the suite passed before only by accident of
`$TMPDIR` sitting outside `/Users/`: with `test_temp_root()` temporarily
pointed under `$HOME` so a conditional `includeIf "gitdir:/Users/"`
fires, the picker tests go from **20 of 38 failing to 38 of 38
passing**.
**The cost:** the latch is a test-serving switch compiled into
`shell_exec` — one static, one relaxed load per spawn, marked
`TODO(hermetic-env)` with the structural alternative (threading an
explicit env value through `Repository`). `cargo run -- <cmd>` is
untouched: nothing in production latches it, so a developer's own
invocations keep their aliases, credential helper, and identity. The one
production git spawn that bypasses `Cmd` (the fsmonitor daemon launch)
re-applies the floor by hand. (Two earlier shapes were tried and
replaced: cargo's `[env]`, which taxed every `cargo run` and vanished
whenever a test binary ran outside cargo, and a pre-`main` constructor
crate, which every test target had to link and which put the floor on
developers' `cargo bench`-adjacent runs too.)
## In-process tests were reading the developer's worktrunk config too
`config_path()`'s third priority is the real
`~/.config/worktrunk/config.toml`, and a lib-crate test cannot set the
second for itself — `set_var` is `unsafe` and this crate forbids
`unsafe`. So a test reaching priority 3 got the developer's own config.
A `panic!` build of the guard named the live callers immediately:
`git::repository::tests::prewarm_*` read it on every run, and
`set_skip_shell_integration_prompt` /
`set_skip_commit_generation_prompt` reach the same resolver to
**write**.
Priority 3 is now absent under `#[cfg(test)]`, which is compiled out of
the real binary — so unlike the git floor above, this one costs `cargo
run` nothing. It returns `None` rather than panicking like
`approvals_path()`: that guards a mutation target where silent absence
would let a test believe it saved something, whereas this is a lookup
whose absent state is already handled — `require_config_path()` turns it
into an error, so a write still fails loudly while a best-effort read
preloads nothing.
The guard covers lib-crate tests only; `src/commands/` and `src/output/`
link the lib in non-test mode. Nothing there exercises the fall-through
today (968 bin-crate tests create no config under a scratch `$HOME`), so
that is a requirement on new tests, recorded in `tests/CLAUDE.md`.
`system_config_path()` stays unguarded on purpose — machine-wide file,
and `config::deprecation`'s `PendingDefault` rules need the lookup.
## Merging main's parallel fix
#3620 attacked the same in-process hole from the other side, writing
`LOCAL_TEST_CONFIG` into each fixture's own `.git/config`. The two
compose rather than compete and both are kept: the floor denies the
host's config to every git, and the local config supplies what a
hermetic in-process git still needs — an identity, which the floor
deliberately withholds because it has per-command homes already
(`git_test_env`, `LOCAL_TEST_CONFIG`) and `useConfigOnly` fails loudly
if a path misses both. main's structure (`TestConfigPaths`,
`TestRepo::bare`) is kept as-is.
Two docstrings were true on each side and false together:
`test_gitconfig_path` restated the gitconfig inline, and
`LOCAL_TEST_CONFIG` said in-process git reads the developer's
`~/.gitconfig`, which is what the floor prevents. `test_gitconfig_path`
also held its `TempDir` in a `LazyLock`, the leak this PR removes. Both
are moot now: the function is gone with the files.
## Why there is no gitconfig file
The isolation went through two file-based shapes before this one, and
neither earned its keep. Denial never needed a file, because
`GIT_CONFIG_GLOBAL` and `GIT_CONFIG_SYSTEM` *are* the denial; the file
existed only to *set* things, and `GIT_CONFIG_COUNT` is git's
environment spelling of `-c`. Deleting both files also deletes the
`[include]` that kept them from drifting, the per-process write that
broke `test (windows)` on a shared path, and the config-path argument
threaded through 22 call sites.
The floor that remains is the deny pair plus two settings.
`user.useConfigOnly` is a backstop: denial alone leaves git *guessing*
an identity from the OS username and hostname rather than failing, which
is the one way a hermetic suite could still author a commit as the
developer. Nothing exercises it, and that is the reason to keep it.
`rerere.enabled = false` is *set* rather than left unset, so the suite's
rerere state cannot depend on what a fixture happens to carry.
`commit.gpgsign`, `advice.mergeConflict` and `advice.resolveConflict`
are gone: denial leaves git on its own default for the first, and the
snapshot layer strips the gutter-prefixed `hint:` lines the other two
quieted (they vary across git versions), so nothing depends on
suppressing them at the source. The long-dead
`tests/fixtures/template-repo/` fixture went with them.
Once the latch made denial universal, the redundant copies went too:
`git_test_env` no longer restates the deny pair per command (the floor
is denial's only writer, so its value is uniform across every
transport), `LOCAL_TEST_CONFIG` dropped its `commit.gpgsign` (denial
guarantees the default), the platform-dependent `NULL_DEVICE` constant
is deleted, and the `.env.GIT_CONFIG_GLOBAL` snapshot redaction is gone
— the recorded value is one cross-platform constant, so there is nothing
volatile to redact.
I removed `rerere.enabled` first, on a local measurement that was wrong,
and CI failed on all three platforms. The standard fixture is built once
into `target/debug/wt-test-fixtures/` and copied per test, and that
cached copy held an `rr-cache` directory left by a rebase run while the
floor still enabled rerere. Git turns rerere on by itself whenever
`rr-cache` exists, so every local test kept the behavior the change had
just removed, while CI built the fixture fresh and lost it.
`tests/CLAUDE.md` now records the trap: clear the fixture cache before
trusting a local measurement of a git-config change.
**Why the floor can't live in a fixture:** every other test variable is
set on a *child* — `git_test_env` on a git command,
`configure_cli_command` on a `wt` subprocess. In-process git is not a
child the test configures: `Repository::run_command` is production code
building a plain `Cmd::new("git")`, and the test never holds that
command, so there is no place to set env on it — while setting the test
process's own environment is the one thing a test can't do safely
(`set_var` races the other test threads). The identity did move to the
fixtures and the per-command env this way; the denial reaches
production's children through the latch at `Cmd`, the choke point they
all pass through.
Two things fall out of `-c` semantics, both pinned:
- **It outranks a repository's own config**, where a global file would
yield to it. So `init.defaultBranch` cannot live in the floor:
`default_branch.rs` sets that key in a repo to prove `wt` reads it, and
an entry would silently win. The three harness `git init` calls that
relied on the floor now name their branch, as the other two already did.
- **A PTY child is `env_clear`ed**, so it inherits nothing and used to
get the floor through the file. It now gets the family from
`configure_pty_command`, the choke point every PTY spawn routes through,
and again from `pty_env_vars`, whose vector declares a PTY `wt` child's
complete environment. Each copy is pinned by its own test, because the
settings only quiet advice and refuse a guessed identity, so no PTY
assertion would catch their loss.
Four tests wrote their own gitconfig to get `init.defaultBranch` plus an
identity; the harness supplies both, so those writes are gone too. Net
45 lines lighter, and no snapshot changed.
## Measurement
`task profile-tests` builds first, then runs the suite under bash's
`time` keyword (task's own interpreter, mvdan/sh, parses `time` but
hardcodes `user`/`sys` to zero): CPU totals on the console, every
per-test duration in the default profile's `junit.xml`. It began three
sizes larger — a scratch-`TMPDIR` leak check that dragged a `sun_path`
byte budget into the Taskfile, a `/usr/bin/time` dependency GNU-less
Linux lacks, and a `perf` nextest profile whose console slow-listing
restated what junit already carries — and each piece fell to the same
question, whether the measuring goal needed it. Method and how to read
the numbers: `tests/CLAUDE.md` under Profiling the Suite.
## Found, measured, not changed
**The gate keeps a duplicate cargo artifact set.** `RUSTFLAGS='-D
warnings'` on the insta step is part of cargo's fingerprint, so it forks
all 343 crates into a second artifact set: 261 CPU-seconds to prime plus
a duplicate of `target/debug/deps` (`target/debug` here is 35 GB, in a
52 GB `target/`). I removed it and then put it back: the clippy step
that would cover it runs on ubuntu only, while the cross-platform matrix
runs `wt hook pre-merge --yes insta`, so this RUSTFLAGS is the only
thing denying warnings on macOS and Windows. `[lints.rust] warnings =
"deny"` would keep that coverage everywhere without forking the graph
(verified locally: fails on a planted unused variable, recompiles only
worktrunk and wt-perf, feature-check commands still pass), but it also
makes plain local builds fail on warnings and newly exposes `cargo msrv
verify` and the minimal-versions job. That is a workflow call. The
duplication is a one-time cost per artifact set rather than per-edit, so
it is second-order next to the ~600 CPU-seconds each suite run costs.
**`recover_from_path` enumerates every ancestor up to `/`.** At each
ancestor it reads the directory and stats `.git` in every child.
Bounding the child scan to the first *existing* ancestor would preserve
both documented layouts (sibling and nested) and both of that module's
regression tests, but it would break a custom `worktree-path` layout
where the repo is a child of a higher ancestor, so it needs a decision
about which layouts recovery must support.
**Nothing in the gate is the biggest cost; concurrency is.** The five
`[[pre-merge]]` keys are one table, so they run concurrently, and each
is a cargo command that wants the whole machine. They serialize on
cargo's build-directory lock (`Blocking waiting for file lock on build
directory` appears in every run) while test execution overlaps another
step's build. The `lockfile` comment says it "must be first", which
concurrent execution does not provide. Separately, agent worktrees run
whole gates at once: during this work a second worktree ran its own `wt
hook pre-merge` alongside mine, load average hit **154 on 18 cores**,
and the same suite took 147.7s instead of 92.9s.
## Tried and rejected
`[profile.dev] debug = "line-tables-only"` first measured 14% less CPU,
but per-spawn CPU was unchanged, which did not fit the proposed
mechanism. Re-measuring both configurations on a quiet machine gave
602.6s (baseline) against 606.5s (line-tables). The original delta was
contention from a sibling worktree running its own suite.
macOS Gatekeeper (`syspolicyd`) looked like a candidate at 230% CPU, but
300 spawns of the freshly built `wt` cost it 0s, and 0.2s after a
relink, against 0.4s during a 10s idle baseline.
## Verification
`cargo run -- hook pre-merge --yes` green, 4,611 tests passed, run
against a freshly built fixture cache after the switch to the latch. The
latch was verified to be the only source of the variables: the invoking
shell carries no `GIT_CONFIG_*`, and the meta-tests
(`in_process_git_reads_only_the_hermetic_config` asserting the *origin*
of every resolved setting, `pty_env_vars_carry_the_git_config_floor`,
and the `isolate_subprocess_env` scrub test asserting the floor is
re-set after the scrub) pin each transport. Across earlier runs one hit
a single intermittent PTY failure in `shell_wrapper` (exit 127) that
passes 3/3 in isolation and whose code path never touches `wt_command()`
or the shared cwd; a later run was green under heavier load than the one
that failed.
## Review round
A full review of the branch (three finder lenses, findings adversarially
verified) landed one more commit:
- **The wrapper-suite PTY children never got the floor.**
`configure_pty_command` env-clears, skips the `Cmd` latch, and sets the
real `HOME`, and the shell-wrapper call sites layer only fixture paths
and an identity on top. Every git those ~89 tests ran therefore read the
developer's real `~/.gitconfig` (and lost the gpgsign shield when
`LOCAL_TEST_CONFIG` dropped it). The floor now rides that choke point,
pinned by `configure_pty_command_carries_the_git_config_floor`; every
raw `CommandBuilder` site was checked to route through it.
- **`task profile-tests` could never report CPU.** mvdan/sh's `time`
hardcodes `user`/`sys` to `0m0.000s`, so the numbers the docs said to
track were unproducible; now `bash -c 'time "$@"' bash ...`. Measured
post-fix: user 5m47s / sys 12m8s, a 67.7% kernel share, confirming the
two-thirds claim above; the integration mean measured ~1s and the docs
were corrected from ~0.6s.
- Smaller: two raw `Command::new("git")` asserts in `remove.rs` tests
now go through `configure_git_cmd`; the hermetic meta-test keys on
`--show-scope` scopes rather than git's origin-path spelling; the dead
`template-repo` fixture is deleted; three test `git init`s name `-b
main`; doc corrections (sun_path arithmetic, recover-walk attribution,
dead `[TEST_GIT_CONFIG]` remnants, volatile counts).
Final gate on the finished tree: green, 4,612 tests. Deferred with
rationale: ~985 snapshots carry stale env-block metadata (insta never
compares it; it churns on future re-records), `$TMPDIR/wt` is not
per-user on Linux (a root run poisons it for later users), and
`spawn_detached_exec_*` / `step tether` do not hand-apply the floor (no
in-process test reaches them today).
> _This was written by Claude Code on behalf of Maximilian_
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
e9f69b2038 |
fix(remove): prune the named worktree's metadata, not the repository's (#3650)
Every stale-metadata cleanup in the removal chain ran a bare `git
worktree prune`. That command takes no path filter: it walks *every*
entry in `.git/worktrees/` and unregisters each one whose directory it
cannot find at that instant. A worktree that is merely absent right now
— an unmounted volume, a dropped network mount, a half-finished `mv` —
is indistinguishable from a deleted one, so removing worktree A also
unregistered bystander B.
B's committed work survives (refs and objects are shared) and its files
stay on disk, but its admin dir does not: the index, `ORIG_HEAD`, the
per-worktree reflog, `refs/worktree/*` and `refs/bisect/*`, and any
in-progress rebase or merge go with it.
Verified on git 2.55, in a scratch repo:
```console
$ git worktree prune -v # no --expire; no grace period
Removing worktrees/wtB: gitdir file points to non-existent location
$ git -C wtB status # after the "volume" comes back
fatal: not a git repository: (null)
$ git worktree repair wtB
error: unable to locate repository; .git file does not reference a repository
```
`git worktree repair` relinks an admin dir; it cannot recreate one.
## The change
`Repository::prune_worktrees()` is replaced by
`prune_worktree_entry(path)`, which runs `git worktree remove <path>`.
Git skips its clean check when the directory is already gone, so that
deletes exactly one `.git/worktrees/<id>`. All three call sites already
had the target in hand:
- `stage_worktree_removal` — the `wt remove` fast path, after the rename
into the trash
- `prepare_worktree_removal`'s missing-directory fallback
- `wt step prune`'s stale-detached candidate
No repo-wide prune remains anywhere in the codebase; a removal's blast
radius now equals its intent.
## Locked worktrees
A repo-wide prune silently skips a locked entry, whereas `git worktree
remove` fails on one. Only the missing-directory fallback can reach a
locked worktree — it resolves one before the lock guard below it — so
that site skips the call explicitly, preserving the previous outcome.
`pruned_from` stays unconditional there. It is what the sibling-checkout
guard in Phase 4 tests, not a record of whether the metadata went;
making it conditional would have quietly disabled a data-safety check.
## Tests
Two integration tests cover both `wt remove` routes: remove one worktree
while a sibling's directory is displaced, restore the sibling, assert it
is still registered. Both fail against the old behavior with the
bystander missing from `git worktree list` — confirmed by temporarily
reverting the primitive to `git worktree prune` and re-running. `wt step
prune`'s site is covered by the existing
`test_prune_stale_detached_worktree`.
`test_prune_skips_prunable_candidate_whose_preparation_fails` forced a
preparation failure by shimming `git` to fail `worktree prune`; its own
guard assertion (`"the shim never fired"`) caught that the shim had
stopped firing. The shim now targets `worktree remove`. The test's
subject — a failing preparation skips the candidate rather than failing
the run — is unchanged.
Local gate green: 4634 tests, lints, doctests.
`RUSTDOCFLAGS="-Dwarnings" cargo doc` also checked separately, since a
broken intra-doc link passes pre-commit but fails CI.
## Not done here
`wt step prune` keeps stale-detached candidates on the write side of the
scan lock. The stated rationale — that they race sibling prunes — no
longer holds now that each names its own entry, so a `TODO(prune)` marks
the move to the read-side fan-out as a separate concurrency change.
---
## Review response
The `tend` reviewer verified the change and then held the merge for a
human on deletion-surface policy rather than on any defect. It raised
one inline comment, which does not hold up:
> This inner `if wt.locked.is_none()` is unreachable-when-false: the
early `return Err(WorktreeLocked)` at line 215 means `wt.locked` is
always `None` by the time control reaches here.
Line 215 is a comment added by this PR, not a `return`. The lock check
is at line 232, inside the `else` arm, which runs only when the `if`
above it is false. A locked worktree whose directory is missing takes
the `if` branch and reaches the guard with `locked` still set, so the
guard is live.
The comment that misled it was genuinely ambiguous, and is reworded.
## Patch coverage
The first push measured 69.23% of diff hit. Seven of the eight missed
lines were avoidable: `prune_worktree_entry` had copied
`remove_worktree`'s seven-line `GitError::Other` block to produce the
`&str` that `run_command` takes, and that error closure never runs.
Every caller's path comes from `list_worktrees`, which already parses
git's porcelain as UTF-8, so it collapses to one line:
```rust
let path_str = path.to_str().context("worktree path is not valid UTF-8")?;
```
One miss remains: the `tracing::debug!` inside
`stage_worktree_removal`'s fail-soft arm, which fires only when the
metadata prune fails after a successful rename. That line predates this
change — only its message was reworded, which codecov counts as new.
Restoring the old wording would make the message inaccurate (it now
prunes one entry, not the repository's), so the wording stands and the
line stays uncovered.
## Reconciled with #3647
`main` landed [#3647](https://github.com/max-sixty/worktrunk/pull/3647)
while this PR sat in CI, fixing the same lock-safety issue from the
other side: a top-of-arm `if wt.locked.is_some() { return
Err(WorktreeLocked) }` placed *before* the missing-directory fallback,
where this branch had kept the guard in the `else` arm and skipped the
prune for a locked entry.
The two auto-merge with **no textual conflict**, which is the hazard.
The merged arm carried main's top guard *and* this branch's `if
wt.locked.is_none()` skip, while dropping the `else`-arm guard the skip
was paired with — leaving the inner check dead and its comment
describing a route the merge had foreclosed, under a green CI.
`main`'s placement is the better one and now owns lock handling alone,
so the fallback calls `prune_worktree_entry(&wt.path)` unconditionally.
What this PR contributes is unchanged and orthogonal to #3647: prune the
named worktree's metadata, not the repository's.
Caught by the reviewer's second pass, which read the merge tree rather
than the branch. Gate re-run green on the reconciled tree: 4641 tests.
> _This was written by Claude Code on behalf of Maximilian_
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
cc8aef3079 |
fix(remove): honor worktree lock when directory is missing (#3647)
## Problem In `prepare_worktree_removal` (`src/commands/repository_ext.rs`), the missing-directory fallback was tested before the lock guard. A worktree that was locked **and** whose directory was currently absent skipped `WorktreeLocked` entirely: its registration was pruned and it fell through to branch deletion — precisely the case `git worktree lock` exists for (removable media, a network mount, a dropped VPN). Reproduced against the current code: `wt remove <locked-worktree>` with the directory removed reported `✓ Pruned stale worktree & removed branch …`, silently bypassing the lock. ## Solution Hoist the lock check above the missing-directory check in the `Current`/`Path` arm, so the lock is honored regardless of whether the directory is present. `--force` still does not override the lock, matching `git worktree remove`. The duplicate lock check in the `else` branch is removed (it's now unreachable there). Blast radius is only the direct `wt remove` path: `wt step prune` already skips locked worktrees during candidate collection, and the `Branch` arm bails with "gained a worktree" before any deletion when a worktree entry (locked or not) exists. The FAQ's guarantee — *"Neither `git worktree remove` nor `wt remove` (even with `--force`) will delete [locked worktrees]"* — is left unchanged: it now describes the actual behavior. Fixing the code to match the documented guarantee is the stronger outcome than weakening the doc to describe a bypass. ## Testing Added `test_remove_locked_worktree_directory_missing` to `tests/integration_tests/remove.rs`: it locks a worktree, removes its directory externally, then asserts `wt remove` fails, the branch survives, and the stale registration is not pruned. The test fails on the pre-fix code (branch deleted, registration pruned) and passes after. The existing locked-worktree and pruned-missing-directory tests remain green, and `cargo clippy --bin wt` is clean. --- Closes #3645 — automated triage Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com> |
||
|
|
c90c4b1b77 |
refactor(remove): report a removal's branch fate, not its plan's intent (#3637)
Follow-up to #3633, which patched the most visible symptom (prune's summary counting candidate kinds). This lands the structural fix behind that class: the removal chain returned nothing from execution, so everything downstream reported the plan's intent as if it were the outcome. The type even said so — `RemoveResult` was produced by `prepare_worktree_removal` before anything ran, and the codebase couldn't agree on what it was (`RemovePlans` in remove.rs, `plan` in prune, `result` in the handler). ## The plan/outcome split `RemoveResult` → `RemovalPlan` (variant `RemovedWorktree` → `Worktree`), and `handle_remove_output` now returns a `BranchFate`: `Deleted` / `Retained` / `NotAttempted` observed synchronously, `Deferred` only for the detached legacy fallback whose outcome this process never sees. Consumers report the fate: - `wt remove --format=json` said `"branch_deleted": true` while stderr said `▲ Removed worktree but kept branch feature (not integrated)` — a pre-remove hook had advanced the branch, the SafeDelete re-check declined, and stdout reported the plan anyway. Now `false`. - `wt step prune`'s summary counts executed outcomes, so a deletion declined mid-run reads `✓ Pruned 1 worktree`, matching the per-item line above it. A declined *orphan* deletion (no worktree entry, nothing removed at all) now drops out of the removed list entirely instead of being counted as either lie. - The detached fallback emits the "kept branch" correction in the one case it can know (no CAS tail to append), instead of letting the progress message's "worktree & branch" stand uncorrected. The picker's `foreground: true, silent: true` contradiction collapses into one `RemovalExecution::{Foreground, Background(fallback), Silent}` axis, and the four hand-rolled capture-refs-then-CAS-delete sites share one `worktrunk::git::execute_branch_deletion` — the single spelling of "the plan said delete; do it now, against fresh refs". ## For the reviewer `src/output/handlers.rs` carries the bulk: each execution path constructs its fate where it observes the result, and rendering decisions (foreground propagates deletion errors, background warns, silent does neither) are unchanged. The merge with #3631's concurrent prune rework is the diff's riskiest region — the worker result channel widened from `Result<bool>` to `Result<Option<bool>>` so fates flow back through the done-channel and the collector stamps `candidate.deletes_branch` before the summary and JSON read it. Deliberately *not* done: reusing the planner from `wt merge`'s finish path (its divergences — clean-check strictness, preserve-vs-error on primary, merge-target — are intentional), and threading the fallback mode structurally (it just relocates an unreachable arm). Zero snapshot churn: the change is provably invisible wherever intent matched outcome. The divergence cases are pinned end-to-end by tests whose hook must be the branch tip to pass. The one deterministically unreachable arm is the detached-fallback warning (needs a cross-filesystem rename failure); expect `codecov/patch` to flag those few lines. > _This was written by Claude Code on behalf of max_ --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
4af04c2260 |
fix(remove, prune, merge): target the named worktree, retain shared branches (#3533)
Follow-up to the discussion on #3480: [@max-sixty asked](https://github.com/max-sixty/worktrunk/pull/3480#issuecomment-5039137116) to have `wt remove` honor an explicit path generally. #3607 has since landed the resolution half, `Repository::resolve_worktree`, which turns a path into a worktree. This is the removal half, and it is the part resolution cannot decide: which worktree to act on is a naming question, whether the branch may be deleted is not. ## Problem `wt remove` threw the resolved answer away. For any non-current worktree it re-targeted by branch name, and `prepare_worktree_removal` mapped that branch back to git's *first-listed* worktree. Two failures followed, both silent. **The wrong worktree is removed.** With `feature` checked out twice: ```console $ git worktree list …/dup [feature] …/first [feature] $ wt remove …/first ◎ Removing feature worktree & branch in background (same commit as main, _) ``` `…/dup` is gone. `…/first`, the one named, is still there. **The survivor is left broken.** The shared branch is deleted with it. Worktrunk deletes branches with `git update-ref -d`, git's compare-and-swap primitive, which unlike `git branch -d` does not refuse a ref that is checked out somewhere: ```console $ git worktree list …/first 0000000 [feature] $ git -C …/first rev-parse HEAD fatal: ambiguous argument 'HEAD': unknown revision or path not in the working tree. ``` `wt step prune` reached the same deletion unattended, and `wt merge` reached it with a freshly integrated branch, so nothing else declined. A branch gets a second worktree only through `git worktree add --force`; worktrunk never does it itself. ## Fix **Remove the worktree that was named.** `wt remove` drops non-current worktrees via `RemoveTarget::Path`. `wt step prune` does the same: its candidates already carry a path, and targeting a *stale* entry by branch name resolved to a live worktree that the same prune had just skipped as too young, then removed it. **Retain a branch another worktree holds.** One predicate, `live_sibling_checkout`, answers "would deleting this ref orphan a checkout?", and every path that can delete a branch asks it: `prepare_worktree_removal`'s worktree and pruned-branch-only arms (covering `wt remove`, `wt step prune`, and the picker, which already targeted by path) and `wt merge`'s finish. A hit forces `BranchDeletionMode::Keep`, the single chokepoint every deletion path honors, and names the surviving checkout: ```console $ wt remove …/dup ◎ Removing feature worktree in background ○ Branch feature retained; still checked out @ …/first ``` A sibling whose *directory* is already gone is stale metadata, not a checkout with anything to lose, so it does not retain: removing the last live checkout still deletes the branch. **`-D` is refused out loud.** Everywhere else `-D` is the override that wins, so one that cannot be honored warns rather than passing quietly: ```console $ wt remove …/dup -D ◎ Removing feature worktree in background ▲ Branch feature retained despite -D; still checked out @ …/first ``` The ordinary single-checkout case is unchanged, and a retained branch skips the integration check entirely rather than computing a verdict it would discard. #3480's duplicate-checkout hint now points at `wt remove <path>`, which this makes the safe answer. ## Testing Full gate green. New coverage, each case asserting the survivor still resolves `HEAD`, which is the corruption in question: - `remove`: by path, by name, refused `-D`, the pruned-directory fallback, and the mirror case where a stale sibling must *not* retain. - `step prune`: a stale entry whose branch is live in an age-skipped worktree. This test is what surfaced the wrong-worktree bug in prune. - `merge`: merging a branch that a `--force` duplicate also holds. ## Not addressed `wt step prune`'s summary counts candidates rather than outcomes, so a retained branch still reports `✓ Pruned 1 branch`. The per-item line above it already says the branch was retained. Fixing the count means threading removal outcomes back through prune's accounting, which is a separate change. > _This was written by Claude Code on behalf of Maximilian Roos_ --------- Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com> Co-authored-by: Maximilian Roos <m@maxroos.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
a285ed308d |
refactor(tests): bring the mock-stub env vars under the WORKTRUNK_TEST_ prefix (#3621)
`MOCK_CONFIG_DIR` and `MOCK_CALL_LOG_DIR` were the only two worktrunk-invented environment variables without the `WORKTRUNK_` prefix. That is not just a naming inconsistency: `isolate_subprocess_env` scrubs the parent environment by prefix — `GIT_*` and `WORKTRUNK_*` — so an unprefixed name is the one thing a test child inherits from whoever ran the suite. Renaming them brings them under that scrub. - `MOCK_CONFIG_DIR` → `WORKTRUNK_TEST_MOCK_CONFIG_DIR` - `MOCK_CALL_LOG_DIR` → `WORKTRUNK_TEST_MOCK_CALL_LOG_DIR` `TEST` rather than a bare `WORKTRUNK_` because both are read only by `tests/helpers/mock-stub` — they are the protocol between the harness and its helper binary, and `wt` itself never reads either one. That matches the ~15 existing `WORKTRUNK_TEST_*` knobs. ## The snapshot half Mechanical but not a substitution: the `env:` block is byte-sorted by key, so the renamed entry moves position within it and a `sed` in place would leave it where the old name sorted. All 971 affected blocks were rewritten by dropping the old line, inserting the new one, and re-sorting — the aggregate diff is exactly one removed and one added line per file: ``` 971 files changed, 971 insertions(+), 971 deletions(-) - MOCK_CONFIG_DIR: "[MOCK_CONFIG_DIR]" + WORKTRUNK_TEST_MOCK_CONFIG_DIR: "[TEST_MOCK_CONFIG]" ``` The redaction placeholder follows its neighbours' convention in `add_standard_env_redactions` (`WORKTRUNK_TEST_NU_VENDOR_AUTOLOAD_DIR` → `[TEST_NU_VENDOR_AUTOLOAD]`), so it now reads `[TEST_MOCK_CONFIG]` rather than repeating the full key. `MOCK_CALL_LOG_DIR` appears in no snapshot — its two call sites are `.output()` assertion tests — so it needs no redaction. ## Keeping it from regressing `tests/CLAUDE.md` gains the rule under "Where a new environment variable goes": name it `WORKTRUNK_TEST_*`, and the rule covers the harness↔helper-binary protocol, not just knobs `wt` itself reads. Without it the next helper-binary variable gets named `MOCK_*` again and the hermeticity hole reopens. ## Verification `cargo run -- hook pre-merge --yes` passes (exit 0) on the merged tree: 4607 tests including `--features shell-integration-tests`, `pre-commit run --all-files`, clippy, doctests. No pending snapshots. A repo-wide sweep finds no remaining unprefixed spelling. ## Merged main #3620 landed while this was in flight and regenerated several `for_each` snapshots that still carried the old key, so main is merged in here. It resolved with no conflicts, and the result is what you'd want rather than what git happened to produce: those blocks now carry #3620's new keys (`GIT_ALLOW_PROTOCOL`, `CLAUDE_CONFIG_DIR`, `WORKTRUNK_TEST_PARENT_SHELL`) *and* the renamed key, each in sorted position. The sweep and the gate above both ran after the merge. #3620 also rewrote the "Where a new environment variable goes" section this branch adds to — three layers became four. Both edits survived; the new naming paragraph follows the updated layer list. The two advisory `affected tests` checks are red for the same reason, and merging clears them: `cargo affected` errors on `git diff stdout was not valid UTF-8`, because the diff from this PR's base contains 16 binary files — the `tests/fixtures/standard/` git objects and index files that #3620 deleted. This branch's own commit contributes none. A PR based after #3620 won't see them. 🤖 Generated with [Claude Code](https://claude.com/claude-code) > _This was written by Claude Code on behalf of Maximilian Roos_ Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
14580de79c |
feat(worktree): accept a worktree path wherever a branch is accepted (#3607)
Follow-up to the [`wt remove <path>` discussion on #3480](https://github.com/max-sixty/worktrunk/pull/3480#issuecomment-5039137116), widened from that one command to the whole surface. #3480 has since landed and is merged in here — its duplicate-checkout warning composes with this: the warning names the shadowed worktrees, and a path is how you then address one. ## Audit Verified against the built binary. wt had three answers to "what does this token mean?": | Route | `@` `-` `^` | worktree path | `pr:N` | |---|---|---|---| | `wt switch` (`resolve_switch_target`) | yes | only if absolute or ≥2 components, and not `--create` | yes | | `wt remove` (`resolve_worktree_arg`) | yes | any token | no | | everything else (raw `worktree_for_branch`) | **no** | **no** | no | Same token, same cwd, two answers: ```console $ wt remove inner # ✓ Removed innerbranch worktree & branch $ wt switch inner # ✗ No branch named inner ``` And outside switch/remove the shortcuts didn't work at all — `wt step diff --branch @` was `✗ Branch @ has no worktree`, while `wt config state marker set --branch @` silently wrote state under the literal key `@`. Separately, wt prints paths as `~/…` but wouldn't accept that form back. ## Change One canonicalizer in the lib, `Repository::resolve_worktree`, absorbing the path fallback that lived in the bin crate's `resolve_worktree_arg` (now deleted). Resolution order is documented once, on that function: `@`, then `-`/`^`, then a branch with a worktree, then a path naming a registered worktree, then the branch alone. **Branch-first, everywhere.** A directory never shadows a branch that shares its name; a path answers only what a branch cannot — a detached worktree, or one of two checkouts of the same branch (#3480's case). The `looks_like_path` shape gate is gone, so a single-component path resolves like any other. Two shapes cover what callers need: `require_worktree` for commands that need a worktree to operate in, `require_selected_branch` for arguments that key by branch. The merge/rebase target validators fall through to the same path lookup, so a target can be named by the worktree it's checked out in. Routed through it: `switch` (including `--base`), `remove`, `step commit --branch`, `step diff --branch` and its target, `step copy-ignored --from`/`--to`, `step promote`, `step relocate`, `config state --branch` (9 sites), and `merge` / `step rebase` / `step squash` / `step push` targets. `resolve_input_path` — already documented as the one resolution point for user-supplied paths — now expands a leading `~`, so the tilde form worktrunk prints is a form it reads back. `~user` stays literal; wt doesn't reimplement that shell feature. ## Documentation A path is an alias, not a second addressing scheme, so it is stated once rather than on every argument: one paragraph in `wt switch`'s help and one sentence on the addressing line in `worktrunk.md`. Argument descriptions still read as branches. The two exceptions are the arguments whose descriptions are already catalogues of accepted forms — `wt switch`'s (`Branch, worktree path, shortcut, or PR/MR URL`) and `wt remove`'s, which has named the path since before this branch. The Worktree Model section of `CLAUDE.md` records which way to document it, so the next argument doesn't grow its own copy. ## Two silent no-ops fixed along the way - `wt step relocate <unmatched>` matched arguments against branch names by string equality, so a typo filtered everything out and the empty result rendered as `○ All worktrees are at expected paths` — a success message for work that never happened. Every way an argument can fail to land on a relocatable worktree now errors, including the detached and prunable cases the new path route makes reachable. - A selector matching nothing was reported as a branch without a worktree, hinting `wt switch <token>` — which creates a worktree only when the branch exists, so for a mistyped path it would just fail again. `WorktreeSelectorNotFound` now says `No branch or worktree named X`; a branch that genuinely exists without a checkout keeps the create hint. ## Testing Full gate green: 4596 tests, lints, docs sync, `--features shell-integration-tests` clippy. `codecov/patch` is 99.25% of diff hit against a 97.93% target. New coverage: - Unit: branch-and-path equivalence, branch-beats-same-named-directory, detached-by-path (and its `require_selected_branch` refusal), shortcuts never treated as paths, branch-only fallthrough, and the two distinct not-found errors. Plus `expand_tilde` round-tripping `format_path_for_display`. - Integration: `switch` by relative/single-component/absolute/tilde path, `--base` by path, `step diff --branch` by path and `@` (asserted equal to the by-branch output), `config state --branch` set via `@` and read via the worktree path, and both new relocate errors. `wt remove`'s resolution is unchanged — it already had this rule; it now shares the implementation. The 106-test `remove::` suite is untouched and green. - Integration: `wt step push <worktree-path>` (the `require_target_branch` half of the target fallback), and `wt step relocate` against a prunable worktree. One diff line is unhit: `expand_tilde`'s fallback when `home_dir()` returns `None`, which has no deterministic trigger. The `@`-resolution backstop in `resolve_worktree` is untested for the same reason — no CLI route reaches it — so it kept its original `match` arm rather than being re-indented into the diff. ## Left out `wt config state default-branch set` and `previous-branch set` take a branch name as a *value to store* rather than a selector, so they still take it literally. > _This was written by Claude Code on behalf of Maximilian Roos_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
a0c1b662d2 |
perf(fsmonitor): resolve all daemons in one lsof spawn (#3581)
## Why `wt remove`'s end-of-command fsmonitor sweep forked one `lsof` per *machine-wide* `git fsmonitor--daemon` — and with `core.fsmonitor` enabled globally, a machine accumulates one daemon per repo ever touched (100+ is routine). So every `wt remove` paid ~100 process spawns. The cost is superlinear under load, not linear: a fork storm makes macOS Gatekeeper/XProtect assess each new image under contention, inflating per-spawn cost for everything else on the box, sweeps included. The `~50ms each` figure in the old docstring was measured *under that load* and read as a fixed per-call cost — so the sweep looked merely slow rather than self-amplifying. Two concurrent test suites driving hundreds of `wt remove` calls pinned all 18 cores (load average 142) with nothing hot in `htop`. This was diagnosed and measured before (#2814 added the sweep; #3401 wrote the cost into the docstring and explicitly changed no behavior) but never fixed. ## The fix Resolve every daemon in a single `lsof -a -p <pid1,pid2,…> -U -F pn` call and split its output on the `p<pid>` record boundary, handing each PID exactly the output the per-PID call produced — so classification and the whole data-safety contract in the module docs are unchanged. Verified end-to-end against a live 108-daemon machine with a PATH shim counting invocations: **108 spawns → 1**. Two details the batched path must preserve, each covered by a new test: - **Don't gate on `lsof`'s exit status.** It exits non-zero when *any* requested PID vanished mid-scan, while still printing full records for every survivor. Gating would drop the whole sweep whenever one daemon happened to exit — the common case on a busy machine. - **A PID with no record is dropped**, never synthesized as a socket-less entry. A socket-less entry reads as orphan class 1 and gets SIGTERM/SIGKILL — on a possibly-recycled PID. ## Also here - **Test consolidation.** Three integration fixtures built their branch set by spawning git per loop iteration. A new `TestRepo::create_branches` creates them in one `git update-ref --stdin`. The over-threshold completion test drops **10.5s → 1.4s** (~230 git spawns → 5); `switch_picker` and `statusline` branch loops get the same treatment. `mock-stub` gains an opt-in `MOCK_CALL_LOG_DIR` call log so a test can assert *spawn count*, not just response — deliberately outside the repo under test, since a log written inside the tree would dirty it mid-`wt merge`. - **Docs.** Dropped the "disable `core.fsmonitor` globally" workaround from the troubleshooting docs (canonical `skills/`, mirror regenerated). It stays enabled by choice, and the daemons were never the actual fseventsd CPU driver. - **CI hardening.** pre-commit.ci's `typos` hook maps the token `pn`→`on` and had auto-rewritten `lsof -F pn` to the broken `lsof -F on` (offset+name — no `p<pid>` records, so the parser reaps nothing). Pinned `pn` in `.typos.toml` next to the existing `PN`-from-`PNGs` entry that documents the same mapping, and added a test covering the empty-PID-set early return (flagged by `codecov/patch`). ## Testing New unit tests cover `daemons_from_batched_lsof` against real captured `lsof -F pn` output, a vanished-PID gap, and empty output; the rewritten integration test asserts exactly one `lsof` spawn with a comma-joined PID list. Full integration suite green (1960 passed), clippy and fmt clean. > _This was written by Claude Code on behalf of max_ --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> |
||
|
|
edcfe5fe0c |
fix: honour -C in the statusline and in every supplied path (#3567)
`-C` sets the repository discovery path but never chdir's the process, so anything reading `std::env::current_dir()` silently ignores it. Two places did. ## `wt list statusline` described the wrong worktree `wt -C /path/to/other/worktree list statusline` printed the statusline for the *cwd's* worktree, and from a directory outside any repo it printed nothing at all. Both formats now resolve their worktree through `Repository::current_worktree()`, which anchors to the discovery path. That accessor already exists for this bug class (its docstring cites #2625), so this is routing the statusline onto the canonical path rather than adding a new one. Details worth knowing while reading the diff: - `--format=json` was mostly right already. It picked its worktree via `current_worktree()` and only its "are we in a worktree" gate read the cwd, so it misbehaved just when the process cwd sat outside a repo (printed `[]`). That gate moves to `current_worktree()` and the `cwd` binding is gone. - `git_status_segments` now takes a `WorkingTree` instead of `(&Repository, &Path)`. That also settles a split inside it: the worktree lookup used the cwd while the no-worktree fallback used `repo.current_worktree()`. - `--format=claude-code` is unchanged where it matters. The directory Claude Code sends on stdin still wins, since that names the session's directory rather than wt's. With no stdin it falls back to the same worktree as the other formats, so it honours `-C` too. Four integration tests. Three run from the main worktree with `-C` pointed at a `feature` worktree, one per format; the existing statusline tests all set the child's cwd, which is the route that already worked. The fourth pins the claude-code split: when stdin names a directory in a *different* repository, the worktree comes from stdin while `list_worktrees` comes from wt's discovery path, so nothing matches and only the branch is reported. That fallback previously had no test, which is what `codecov/patch` caught on the first push. Discovering the `Repository` from the stdin directory instead would remove that split, but it would miss the `prewarm` cache (keyed on the base path) that this prompt-redraw hot path depends on, so the split stays and is now asserted rather than incidental. ## Supplied paths resolved from the shell, not from `-C` `wt switch ../repo.feature` and `wt remove ../repo.feature` joined the argument onto the process cwd. With `-C` in play that split the invocation in two — wt chose the repository, the shell chose the path — and the combination resolved to nothing, so `switch` reported "No branch named ../repo.feature". `--config`, `WORKTRUNK_CONFIG_PATH`, `WORKTRUNK_SYSTEM_CONFIG_PATH`, and the trace file of `wt config state logs profile` name a file the same way and had the same gap: `wt -C repo --config mycfg.toml config show` reported "Config file not found" for a file sitting in `repo/`. Git resolves the paths in its command line against its own `-C`, because it chdir's there before doing anything else. wt's `-C` sets a discovery path instead, so `resolve_input_path` joins onto that — one resolution point for all five, so they cannot drift apart. It returns the path untouched when `-C` was not given, rather than joining `.` onto it: the process cwd already resolves those, and the join would surface as a stray `./` in output. `Path::join` leaves an absolute path alone, which drops an `is_absolute` branch and, in `resolve.rs`, a fallible `current_dir()` call. Three neighbours are deliberately outside the rule, and the docstring says why: - Paths wt derives itself — a worktree root from `git worktree list`, an XDG config directory — arrive absolute. - `WORKTRUNK_PROJECT_CONFIG_PATH` resolves from the worktree root, its documented base, because the project config is per-worktree. - `WORKTRUNK_BIN` and `XDG_CONFIG_DIRS` are never opened by wt: the first is expanded later by the generated shell wrapper, and the XDG spec already requires the second to be absolute. Four integration tests, one per surface, each running from *outside* the repo with `-C` pointed into it — the neighbouring tests run from a cwd inside the repo, which is the route that works under either rule. All four fail against the old code: `switch` with "No branch named ../repo.feature-detached", `remove` by leaving the worktree standing, and the two config paths by not finding their file. The `set_base_path` docstring picks up the rule, so the next reader has one place to find it: `-C` is the base for the two things git takes from its chdir — the repository discovered, and every path the user names — while the process cwd stays the answer to where the user's shell physically is. ## Noticed, not touched `run_json` and `git_status_segments` duplicate the worktree-lookup-and-populate sequence (`list_worktrees` → find by root → `is_home` → `build_worktree_item` → `populate_item` with identical `CollectOptions`). Worth collapsing separately. > _This was written by Claude Code on behalf of max_ |
||
|
|
e02453aa5f |
feat(list): demote branch-worktree mismatch from red flag to dim info (#3419)
Implements option 3 ("demote the presentation") from the design proposal
in #3415, for #3389: worktrees created by agent harnesses at their own
conventional paths (`.claude/worktrees/…`) made every `wt list` row
carry a red `⚑` at merge-conflict severity.
The list predicate, JSON fields, and Path-column behavior are unchanged;
only the presentation changes:
- `⚑` renders dim yellow (was red) in `wt list`, the picker, and the
`--help` legend: below the full-yellow actionable states (prunable,
locked), above plain dim.
- Priority within the status slot becomes `✘ > ⤴ > ⤵ > ⊟ > ⊞ > ⚑ > /`:
the yellow actionable states (prunable, locked) now outrank the
informational glyph instead of being masked by it. The JSON schema-1
`state` field follows the same priority; schema-2 booleans are
independent and unaffected.
- The inline notice on `wt switch` / `wt remove` / `wt merge` / `wt step
prune` is gone entirely: the `wt list` glyph is the state's one surface.
The `expected_path` plumbing is deleted end to end, so switching to an
existing worktree no longer expands the `worktree-path` template at all
(it only ran to feed the notice).
Before/after on a repo with agent worktrees:
```
+ claude/frosty-kilby-92c7d3 ⚑_ (red ⚑, reads as an error)
+ claude/frosty-kilby-92c7d3 ⚑_ (dim-yellow ⚑, reads as a note)
```
Option 1 from the design doc (narrowing the predicate so only genuine
collisions flag at all) can land separately on top of this.
Well-tested: the pre-merge gate passes (4390 tests), snapshots reviewed
line-by-line (glyph color and priority changes in list/help output; the
notice lines removed from switch/remove output), the removed-notice
tests renamed to pin the silence, and clippy is clean including
`--features shell-integration-tests`. Verified live: `wt list` shows the
dim-yellow flag; `wt switch`/`wt remove` print nothing about the
mismatch.
Thanks to @dmsmidt for the report in #3389.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
57b303fda2 |
fix(nix): provide lsof to the flake's test sandbox (#3410)
The nix-flake job has failed on main since #3396: `test_remove_reap_kills_process` discovers its child via `lsof -d cwd`, but the flake's `worktrunk-tests` check didn't provide `lsof`, so `processes_under` fails to spawn it, returns an empty list forever, and the test's 5s discovery poll panics with `child <pid> never discovered`. Failing main run: https://github.com/max-sixty/worktrunk/actions/runs/29066326142. Three changes: - `flake.nix`: add `pkgs.lsof` to the test check's inputs, the same pattern as `git`/`python3`/`procps`. `ps` (procps) already works in the sandbox, so /proc-based process inspection is available there and the test should be genuinely exercised rather than excluded. - `.github/workflows/nightly.yaml`: add `flake.nix`, `flake.lock`, and `nix/**` to the trigger paths (push and PR gate). A flake change couldn't previously trigger the nix-flake job that validates it; this PR relies on the fixed trigger for its own validation. - `tests/integration_tests/remove.rs`: point the discovery-timeout panic message at lsof, so the next environment missing it doesn't need re-diagnosing. Validation: nix isn't available locally, so the proof is this PR's own nightly run going green on the nix-flake job. > _This was written by Claude Code on behalf of max_ Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
5e58827a28 |
bench(prune): rust-scale prune fixture, criterion benches, and removal trace spans (#3401)
Makes `wt step prune` / `wt remove` staging performance measurable and reproducible. Users report prune taking many seconds while the synthetic benches scan in ~150 ms; this adds the observability and fixtures that reproduce the gap and attribute it. No `wt` runtime behavior changes — the `src/` diff is ~29 lines of trace spans. ## What a reviewer needs - **Trace spans** (`src/commands/step/prune.rs`, `src/commands/process.rs`, `src/git/fsmonitor.rs`): `prune-gather` / `prune-scan` / `prune-check:<ref>` / `prune-remove:<label>` plus `internal-sweep` around `wt remove`'s end-of-command janitor. A `prune-remove` span starts before the write-lock acquisition, so it reads as "how long this removal stalled the run" (documented in benches/CLAUDE.md). - **Fixtures** (`tests/helpers/wt-perf/src/lib.rs`, the bulk of the diff): `prune-M-U` — M squash-merged candidate pairs (content-integrated, the post-PR-squash shape) against a two-sided-diverged backdrop forked across history; `prune-real[-M-U]` — the same shape on a rust-lang/rust clone, default 12+24 → 36 linked worktrees. The real fixture is cache-managed under `target/bench-repos/` (~15 GiB, minutes to build): `ensure_prune_real_repo` classifies it Intact/Consumed/Broken on reuse, re-creates candidates consumed by a live prune in ~1 min (round derived from the surviving squash commits, no sidecar state), and heals invalidated worktree indexes. - **Benchmarks** (`benches/prune.rs`): criterion groups `prune_e2e` (dry-run cold/warm + live with per-iteration candidate re-creation) and `prune_real_repo` (warm dry-run only). The real group is gated behind a new `real-repo-benches` cargo feature so the nightly benchmarks workflow — plain `cargo bench` on a hosted runner — never builds a fixture bigger than the runner's disk and the actions cache cap. ## Measured (M-series Mac, in benches/CLAUDE.md) Live prune on the rust-scale fixture: **~12 s wall** — a 5.4 s stat-cold scan (36 `git status` at ~4.5 s each, absorbed by the rayon pool) plus 24 removals serialized under the scan write lock (~0.5–1.7 s per worktree candidate). Warm re-scan: 0.25–0.8 s. This reproduces the reported "prune takes seconds" experience and points optimization at status cost and removal overlap, not the integration probes. Rider fix picked up en route: `docs.anthropic.com/en/docs/build-with-claude/claude-code` now 404s (failing lychee on every push, including on main), so the llm-commits installation link and the `wt config plugins` install-hint now point at `code.claude.com/docs/en/setup`. Also fixes a latent fixture bug: `history_spread_shas` divided by its hardcoded 5000-commit cap, so on short synthetic histories every "history-spread" fork silently collapsed to the tip. ## Testing wt-perf unit tests cover the fixture lifecycle (state classification, derived repair rounds, squash content-integration); every `ensure_prune_real_repo` path (build, cached, repair, index-heal, foreign cwd) was exercised end-to-end locally, as were both criterion groups and the one-shot timelines. An integration test asserts the `wt remove -vv` trace surfaces the fsmonitor sweep (span plus daemon count), pinning the sweep's observability contract. The rust-scale numbers are I/O-bound and ambient-load-sensitive — documented as shape, not thresholds. > _This was written by Claude Code on behalf of max_ --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
f593438e7c |
feat(remove): add experimental --reap to kill worktree processes (#3396)
Implements the design agreed in #3365: an opt-in, experimental flag on `wt remove` that reaps processes left running in the worktree. ## The name `--reap` — it's the term the whole thread and the issue title already use, it's concise (matching worktrunk's flag style), and the user-facing messages read naturally (`◎ Reaping 2 processes under feature worktree`). ## What it does ```console $ wt remove --reap feature ◎ Reaping 2 processes under feature worktree ┃ 51234 node ┃ 51240 esbuild ✓ Reaped 2 processes ◎ Removing feature worktree & branch in background (same commit as main, _) ``` Processes are discovered by working directory (`lsof -d cwd`): any process whose cwd is at or under the worktree path. Termination reuses the existing `SIGTERM`→wait→`SIGKILL` escalation (`escalate_terminate`, shared with the fsmonitor sweep). ## Data-safety posture Killing a process the user didn't mean to kill — a terminal editor with unsaved buffers — is exactly the silent loss-of-work the project refuses without consent, so two guards keep `--reap` conservative: - **Controlling-terminal exclusion.** A process holding a controlling terminal (an interactive shell, or `vim`/`nvim`/`emacs -nw`) is never reaped (`ps -o tty=`). Only detached processes — the dev servers and watchers this issue is about — remain candidates. This also spares the shell `wt remove` was run from. - **Self-exclusion.** The current `wt` process is never a candidate. The flag itself is the explicit opt-in; the list is printed before signalling for transparency. ## Scope / limitations (matching the #3365 discussion) - **Under-inclusive by design.** cwd discovery misses a daemon that forked and `chdir`'d away, or one that reparented to `init` — they no longer report a cwd under the path. Those are what [`wt step tether`](https://worktrunk.dev/step/#wt-step-tether) is built to reap (whole process group). `--reap` and `tether` cover different gaps and are complementary, not substitutes — the docs say so. - **Ordering.** Reaping runs before the worktree directory is staged/renamed (cwd matching needs the directory in place), so it's independent of foreground/background removal, trash-vs-delete, and `--force`. - **Unix only.** Windows has no cheap per-process cwd; `--reap` is rejected there with a clear error. ## Tests - Pure parsers for `lsof`/`ps` output (`parse_lsof_cwd`, `parse_ps_tty`). - End-to-end against the real `lsof`/`ps`: spawns a child with a cwd under a tempdir, asserts `processes_under` discovers it, asserts the controlling-terminal guard keeps-or-drops it in agreement with the child's *actual* TTY state (so the test is host-independent — CI has no TTY, a dev box does), then reaps it and confirms `SIGTERM`. - CLI snapshot (`test_remove_reap_no_processes`) covering the no-candidates path, deterministic whether or not `lsof` is installed on the runner. Help text, `docs/content/remove.md`, and the skill reference mirror are regenerated and in sync. One thing worth a maintainer's eye: I chose to print-then-signal rather than add an interactive confirm/`--dry-run` in this first cut — the opt-in flag + TTY exclusion + printed list felt like enough for an experimental flag, and a confirm step is easy to layer on if you'd prefer it. Closes #3365. --------- Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> |
||
|
|
9be99ee21b |
test: consolidate absence-window sleeps behind a shared SLEEP_FOR_ABSENCE_CHECK (#3206)
Absence assertions (verifying something did NOT happen) need a fixed window rather than polling, since there's no event to wait for. That window was expressed three inconsistent ways across the test suite: a per-file `const SLEEP_FOR_ABSENCE_CHECK` duplicated in `user_hooks.rs` and `post_start_commands.rs`, and bare `Duration::from_millis(500)` literals scattered through `switch.rs`, `step_alias.rs`, `remove.rs`, `bare_repository.rs`, and `merge.rs` — none greppable as a group, and `merge.rs` sitting below the documented 500ms floor at 200ms. This promotes `SLEEP_FOR_ABSENCE_CHECK` to `src/testing/mod.rs`, next to the presence helper `wait_for_file`, re-exported via `tests/common`, and routes all 15 absence sleeps through it. The constant's doc comment states the discriminator that decides the tool: a *presence* assertion polls (and returns the instant the event lands); an *absence* assertion has no event to wait for, so it holds a bounded window. The three sleeps left as literals are genuinely different roles — bash startup, signal sequencing between two SIGINTs, and PTY output sequencing — not absence checks. It also rewrites the `tests/CLAUDE.md` timing section around that polarity discriminator (split into Presence / Absence subsections), with the absence example using the constant, plus two traps: pairing one sleep with both a presence and an absence assertion (the presence half goes flaky), and structural absence — when the event is gated on a condition the test never sets up, drop the window and poll the positive precondition instead. This is the consistency-and-guidance follow-up to the watchdog de-flaking in #3187: the marker convention makes legitimate absence sleeps self-labeling, which is what lets a reviewer (or the nightly sweep) tell them apart from a flaky fixed sleep before a presence assertion. ## Testing No behavior change — test-infrastructure and docs only. Each of the 15 conversions was verified to be followed by a negative assertion (adversarial review, 20/20 confirmed); the full `pre-merge` gate passes (4170 tests). `merge.rs`'s window widened 200ms → 500ms, which only makes its absence check more conservative. > _This was written by Claude Code on behalf of max_ --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
5eb17d441b |
refactor: delete dead code paths left by completed cut-overs (#2931)
Three independent dead-code removals, each a parallel implementation left behind by a completed cut-over and kept alive only by tests (or nothing). ~610 net lines removed, no behavior change (JSON output byte-identical, full test suite passes). - git: drop the by-ref integration API (resolve_ref + ref-taking probes); push.rs uses is_ancestor_by_sha. - list: drop the ListItem serde surface and pre-rendered display-string layer; JSON flows only through JsonItem. - worktree: drop the dead OperationMode enum and the now-constant params of resolve_worktree_arg. |
||
|
|
43e1592503 |
fix: data-safety fixes across the worktree merge/remove/prune lifecycle (#2870)
Five data-safety / correctness fixes in the worktree-lifecycle paths, plus one rebase-integration follow-up. Each closes a real TOCTOU or scoping gap and carries integration tests; together they form a dependency chain through `output/handlers.rs` and `step/prune.rs`. - **`merge` / `remove` — revalidate cleanliness after `pre-remove` hooks.** `wt merge` / `wt remove` snapshotted the clean check and branch-integration decision during planning, then ran `pre-remove` hooks before cleanup. A hook (or concurrent process) could dirty the worktree or advance the branch in between; the stale decision then trashed the directory or ran `git branch -D`. Cleanliness and integration are now recomputed from a fresh ref snapshot after hooks run. - **`remove` — recompute branch safety in the background path.** The background removal path also acted on pre-hook planning state, and synthesized `git worktree remove --force` whenever `.gitmodules` existed. It now re-runs `ensure_clean`, recomputes integration via `delete_branch_if_safe`, and fails closed for submodule worktrees on the fallback path instead of forcing. - **`prune` — serialize the rename-failure fallback.** When the fast-path rename failed, prune spawned a detached `git branch -d` that ran after the lock guard dropped, racing a live integration-check reader on `.git/config` (#2801's residual gap). It now runs synchronously under the write guard for non-current worktrees. - **`prune` — scope hook approval to the removal set.** Approval was built from every linked worktree (including ones prune would skip) plus an always-on primary `post-switch`, so a non-interactive prune could abort on an unrelated unapproved hook. Approval is now built only for the worktrees prune will actually remove. - **`prune` — prune stale *detached* worktree metadata.** A detached worktree whose directory was deleted outside Worktrunk (`branch == None`) was silently skipped and lingered in `git worktree list` forever; it is now pruned via an idempotent `git worktree prune`. The final commit drops a struct field — removed by the first commit — from a test constructor that landed in `picker/mod.rs` after this chain's base (#2845), which the cherry-pick's auto-merge did not catch. > _This was written by Claude Code on behalf of Maximilian Roos_ --------- Co-authored-by: Claude <noreply@anthropic.com> |
||
|
|
1716ecab37 |
fix(hooks): resolve all hook config from the invoking worktree (#2873)
Worktrunk resolved each hook's `.config/wt.toml` from a different worktree depending on the hook — `post-merge` from the merge target, `post-switch` from the destination, `pre-remove`/`post-remove` from each removed worktree, `wt step prune` from each prunable worktree, and `wt switch --create` from the base ref's *committed* config via `git show`. That last one is the bug behind #2856 and #2818: an uncommitted or branch-local `.config/wt.toml` silently failed to fire creation hooks, and `wt config show` (which reads the working tree) disagreed with what actually ran. This replaces all of it with one rule: **every hook resolves its commands from the `.config/wt.toml` of the worktree `wt` ran in** — the invoking worktree, read from its working tree, the same file `wt config show` displays. ## Behavior changes - `wt switch --create` / `pr:` / `mr:` creation hooks read the invoking worktree's config, so an uncommitted `.config/wt.toml` fires them; the base ref's or PR's committed config is no longer consulted. - `post-merge` runs the feature worktree's config, not the merge target's. - `post-switch` into an existing worktree uses the source, not the destination. - `wt remove <other-branch>` and `wt step prune` use the invoking worktree's config, not each removed worktree's. In the common case — a committed, repo-wide `.config/wt.toml` — these are identical; they diverge only when a branch carries its own working-tree edits. ## For reviewers The module docstring in `src/commands/hooks.rs` is the spec — its per-hook config-source table collapsed to one rule. The change is concentrated in five approval gates that now call `repo.load_project_config()` once instead of `Repository::at(<other-worktree>)`: `merge::approve_merge_plan`, `main.rs`'s `approve_remove`, `step::prune::approve_prune_hooks`, `picker::approved_removal_plan`, and `worktree::switch`. The `switch_hook_project_config` helper and the `base_ref_for_create` / `project_config_at_ref` `git show` machinery are deleted. The *anchor* — the worktree a hook runs in, the executor's plan-lookup key — is unchanged; only the config *source* unifies. The frozen `ApprovedHookPlan` still closes the approval-boundary TOCTOU. ## Testing Hook config-resolution tests across `switch`, `merge`, `remove`, and `step_prune` were rewritten to assert the new rule, each also checking that the non-invoking worktree's config is ignored. `test_post_merge_hook_from_rebased_in_config_does_not_run` is the TOCTOU regression: a `post-merge` that enters the invoking worktree's config only via the rebase, after the gate froze the plan, must not run. Ref #2856, #2818. --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
61f4efc1e4 |
refactor(cli): consolidate --no-verify deprecation into one path (#2868)
The deprecated `--no-verify` flag was declared as a clap arg five times — across `SwitchArgs`, `RemoveArgs`, `CommitArgs`, `SquashArgs`, and `MergeArgs` — under two field names, resolved through two helpers, with the deprecation-warning string hand-duplicated in two places. This collapses the four bool-resolving commands (`switch`, `remove`, `step commit`, `step squash`) onto a single flattened `HookFlags` args struct with one `resolve()` method, and routes every `--no-verify` deprecation warning — merge's included — through one emitter, so the warning text exists in exactly one place. `wt merge` keeps its own flags: its hooks flag is genuinely tri-state (`Option<bool>`, so config `[merge] verify` still applies) with a positive `--verify` override, which `HookFlags`'s `SetFalse`/default-true shape cannot express. It shares only the warning emitter — forcing it into the struct would need a special case. Behavior is unchanged: `--no-verify` still works as a deprecated alias and emits the same warning under the same conditions. One visible `--help` change, a direct consequence of the shared struct: `--no-hooks` for `step commit`/`step squash` moves under the `Automation` heading, matching where `switch`/`remove`/`merge` already place it. Doc mirrors regenerated. > _This was written by Claude Code on behalf of Maximilian Roos_ Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
84fb34c11a |
fix(hooks): structurally close the approval-boundary TOCTOU class (#2806)
## Why Project-defined hook commands (`pre-*`/`post-*`) are arbitrary code shipped in a repo the user may have just cloned. They were selected from `.config/wt.toml` **twice**: once at the approval gate to build the prompt, and again at execution when `register`/`execute_hook` re-read `load_project_config()`. Between the two reads, the operation itself mutates state — a merge moves the target ref, an auto-rebase rewrites the feature config, a removal scrubs the worktree, `git worktree add` materializes a `--create` worktree — so the second read could select a command the user never approved. On a fresh `git clone && wt <op>` that is remote code execution. On `main` the post-merge path was entirely unpinned; the others used point-fix config snapshots that the executor could still re-resolve around. ## Approach The gate selects the command set exactly once and freezes it into an immutable, type-state `ApprovedHookPlan` (new `src/commands/hook_plan.rs`). Covered executors consume only that value via `execute_planned_hook` / `register_planned` and hold no `ProjectConfig`/`Repository` for selection, so re-derivation is a compile error, not a review invariant. Rendering stays deferred (post-`*` hooks legitimately need post-operation context like the merge commit) but consumes the frozen `CommandConfig` list, never config. Covered (gate and execution separated by a state mutation): `pre-merge`, `post-merge`, `pre-remove`, `post-remove`, `post-switch`, `pre-start`, `post-start`. Deliberately not covered — they have no gate→exec mutation window and share the gate's cached `Repository`: `pre-commit`, `post-commit`, `pre-switch`, `wt hook <type>`, aliases. This scope boundary is documented in the `commands::hooks` module spec. Clean cutover: the point-fix snapshot apparatus is deleted (`RemoveResult::removed_project_config`, `register_with_project_config`, `collect_remove_hook_commands`, `collect_merge_commands`, `removal_hooks_approved`, `approve_or_skip_with_config`) — no parallel path, no compatibility flag. ## Reviewer orientation - `src/commands/hook_plan.rs` — the whole model: `HookPlanBuilder` (sole config→commands point), type-state `HookPlan` → `ApprovedHookPlan` (constructible only via `approve`/`approve_readonly`/`empty`), `lookup`/`render_planned`. Start here. - `merge.rs` / `main.rs` / `step/prune.rs` / `worktree/switch.rs` / `picker/mod.rs` — the five gates that build a plan. - `output/handlers.rs` / `worktree/finish.rs` — the executors that consume it. - `commands::hooks` module doc — the canonical "which `.config/wt.toml` a hook reads" spec, rewritten for the plan model including why the uncovered set is safe (shared never-invalidated config cache). Behavior parity is preserved: an empty plan (`--no-hooks`, declined, or no project config) runs no project hooks; the merge approval prompt is unchanged (still lists pre-commit/post-commit); the picker's read-only gate drops only unapproved project pipelines (strictly better than the old all-or-nothing verify boolean). The empty-plan fast path returns before any `Approvals` load or project-id resolution, so a malformed `approvals.toml` no longer aborts a command with nothing to authorize, and `wt merge --no-hooks` no longer parses the destination config. The removal data-safety re-validation, the Ctrl-C signal policy, and source-scoped filtering are untouched. ## Testing `cargo run -- hook pre-merge --yes` green (3751 tests), clippy + pre-commit clean. New regression tests: `test_post_merge_hook_from_merged_feature_config_does_not_run` (the TOCTOU itself, causally bounded), `test_remove_no_project_hooks_ignores_malformed_approvals`, `test_merge_no_hooks_ignores_malformed_destination_config`, plus `hook_plan` unit tests (frozen lookup, read-only filter, source-group ordering). Reviewed across eight structurally-distinct passes (adversarial, generalization, evidential, subtraction, metric, classification, holistic) plus a Codex review whose two P2 findings are fixed and locked with the malformed-config tests. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
f68626dd03 |
fix: six independent single-file data-safety and correctness fixes (#2784)
## Summary Six independent single-file fixes found during a static review pass. Each touches its own file (the only shared file is `tests/integration_tests/switch.rs`, by the two PR-remote fixes), so they're grouped here as low-risk, self-contained wins. **Data safety** - **`relocate`: refuse `--clobber` when the backup path already exists** — `--clobber` could overwrite a pre-existing backup, destroying the only copy of prior state. Now bails if the backup path exists. - **`remove`: re-check cleanliness before synthesized-force submodule removal** — the submodule-removal path synthesized a `--force` after the initial cleanliness check; a worktree dirtied in the interim could lose work. It now re-runs `ensure_clean` immediately before the forced removal (time-of-check/time-of-use). **Correctness** - **`switch`: probe base worktree via a fresh `Repository` after `worktree add`** — the post-create base lookup read a stale cached worktree list; it now re-discovers via `Repository::at(...)`, matching the caching contract. - **`pr`: derive owner/repo from the forge remote, not the primary remote** — PR/MR lookups used the primary remote's owner/repo even when the forge remote differed, producing wrong-host or wrong-repo lookups. Now parsed from the forge remote. - **`pr`: validate empty Azure PR source branch at the provider boundary** — an empty Azure source branch slipped past unvalidated; now rejected at the provider boundary with a clear error. **Defense in depth** - **`switch`: guard branch operands with `--`** — `git worktree add` / `branch --unset-upstream` invocations now place options first and terminate with `--` so a branch name starting with `-` can't be interpreted as a flag. ## Test plan - [x] `cargo check` clean on this branch off `main` - [x] Per-fix regression / integration tests added; affected snapshots accepted - [ ] CI green 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude <noreply@anthropic.com> |
||
|
|
23d6552471 |
feat(hooks): post-remove reads the removed worktree's config (snapshot before removal) (#2736)
`pre-remove` already reads the worktree-being-removed's `.config/wt.toml`, but `post-remove` read the *post-removal* working directory's config — the primary worktree for `wt remove` / `wt step prune`, the merge destination for `wt merge`. So a `post-remove` defined in a feature branch's branch-local config never fired, and a `[pre-remove]`/`[post-remove]` pair in the same `.config/wt.toml` didn't read the same file. The only reason for the asymmetry: the worktree ceases to exist between the two hooks. This snapshots the removed worktree's `ProjectConfig` before deletion (stashed on `RemoveResult::RemovedWorktree::removed_project_config`) and threads it to the `post-remove` executor. The rule the `commands::hooks` spec docstring now states up front: **every hook is anchored to one worktree — the one it's *about* — and reads that worktree's `.config/wt.toml`; when the worktree isn't on disk at read time, read a snapshot.** Two hooks need that: `post-remove` (worktree just deleted) and `wt switch --create`'s post-switch hooks at approval time (worktree not created yet — reads the base ref via `git show`). Same role, two physical sources; the spec's new "Snapshot paths" section frames them together, and `pre-remove`/`post-remove` collapse into one table row. ## What changed - **`RemoveResult::RemovedWorktree`** gains `removed_project_config: Option<Box<ProjectConfig>>` (boxed — `ProjectConfig` is ~600 bytes and would otherwise dominate the variant; clippy `large_enum_variant`). `prepare_worktree_removal` and `finish_after_merge` populate it before any deletion. A parse failure there is *not* propagated: the executor's own `load_project_config()` re-reads from disk and surfaces the parse error with full chain, and `pre-remove` aborts before `post-remove` fires, so the `None` is invisible. (Routing the parse error through `prepare_worktree_removal` would land it in `main.rs`'s `record_error`, which renders only the top message — a pre-existing display gap orthogonal to this work.) - **`output::handlers::spawn_hooks_after_remove`** registers `post-remove` via the new `HookAnnouncer::register_with_project_config` using that snapshot. `post-switch` after a removal keeps loading from `ctx.repo` (the post-removal cwd — the worktree it's *about*). - **`commands::picker::do_removal`** (the TUI in-place removal path) was the last hook-execution site reading the primary's config — `pre-remove` was rooted at `main_path`, `post-remove` likewise. Now `pre-remove` is rooted at the worktree being removed and `post-remove` uses the snapshot, matching `wt remove`. - **`collect_remove_hook_commands`** (the shared approval helper for `wt remove` / `wt merge` / `wt step prune`) collects `post-remove` per-worktree (same loop as `pre-remove`); `post-switch` keeps coming from `primary_repo`. - **`prepare_background_pipelines`** absorbs its single-use loading shim: it now takes a required `Option<&ProjectConfig>`, and `HookAnnouncer::register` (loads from `ctx.repo`) / `register_with_project_config` (explicit snapshot) are the two ergonomic wrappers — mirroring the approval side's `approve_or_skip` / `approve_or_skip_with_config`. - **Spec**: `commands::hooks` module docstring rewritten — "anchor worktree" framing, the `pre-`/`post-remove` row merge, the "Snapshot paths" section. ## Tests `test_post_remove_hook_reads_removed_worktree_config` (remove.rs) — the mirror of the existing `test_pre_remove_hook_reads_removed_worktree_config`: a `post-remove` in the removed worktree's branch-local `.config/wt.toml` fires correctly *after* the worktree is gone. Verified to fail without the snapshot wiring. Existing pre-remove / merge-teardown / prune / approval-prompt / picker suites all pass; `wt hook pre-merge --yes` (full suite + lints + doctests) is green. ## Follow-up (not in this PR) `commands::picker::do_removal` is still a parallel reimplementation of the `wt remove` pipeline (pre-remove → git removal → post-remove), ~80 lines that shadow `handle_remove_output`'s flow plus its own `PostRemoveContext` construction. This PR makes it follow the same config rule; routing it through `handle_remove_output` (which has a quiet/background mode) would delete the duplication outright — a separate, larger refactor with its own picker-test burden. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
c67803e8dd |
docs+test(hooks): tighten "(else the primary)" wording; cover the no-config approval path (#2727)
Two small consolidations on top of #2717 (which dropped the residual fallback in the shared `collect_remove_hook_commands` approval helper, finishing what #2714 started): **Spec tightening.** The `commands::hooks` "Which `.config/wt.toml` a hook reads" table row for `post-merge` / `post-remove` / `post-switch` after a removal previously read "the destination — for `wt merge`, the target branch's worktree (else the primary)". Once the fallback rule was gone, "(else the primary)" could be misread as a config fallback. Re-phrased it as the path choice for the destination — the merge target's worktree if one exists, otherwise the primary — explicitly noted "not a config fallback". **Regression test.** Added `test_remove_no_fallback_to_primary_pre_remove`: a worktree with no `.config/wt.toml` plus a `pre-remove` in the primary. Before #2717, `wt remove` without `--yes` aborted with "needs approval" because the helper surfaced the primary's hook and tests have no TTY; after #2717 the prompt is empty and removal succeeds. #2717's commit message argued the malformed-config tests covered the helper end-to-end, but no test covered the no-config (empty-prompt) path specifically — this fills that gap, so a future re-introduction of the fallback would surface immediately. Verified twice: stashed `project_config.rs` to its pre-#2717 state → test fails with exactly the symptom (`pre-remove: echo ran > ...` shown in the prompt, `Cannot prompt for approval in non-interactive environment`). Restored → test passes. `cargo run -- hook pre-merge --yes` is green. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
1dbbe84b8d |
refactor(hooks): drop primary-worktree config fallback; surface parse errors (#2714)
## Summary Each worktree's `.config/wt.toml` now stands alone. When the worktree a hook acts on has no `.config/wt.toml`, no project hooks run — the primary worktree's config is no longer consulted as a fallback. A present-but-malformed config aborts the operation with the parse error instead of silently using a different one. ## Why The fallback (#2690, extended to `wt switch` in #2703) was an internal detail meant to keep worktrees branched off *before* a hook was added still firing the hook. But it made which file each hook actually reads ambiguous — Codex flagged this in review of #2703 (P3): a destination worktree with a malformed `.config/wt.toml` silently ran the *primary's* hooks instead of surfacing the parse error. Removing the fallback gives a cleaner model: each worktree owns its config. Users who want a hook to apply project-wide commit it on the default branch — new worktrees branched off that branch inherit it. Worktrees branched off *before* the commit no longer fire the hook silently, which was the leaky part. ## Behavior change A `pre-remove` hook added on `main` no longer fires when removing a feature worktree that predates the commit adding it. To restore that behavior, branch the feature off `main` again (or commit the hook on the feature branch itself). ## What changed - `Repository::project_config_at_ref` now returns `anyhow::Result<Option<ProjectConfig>>` — `Ok(None)` for refs with no `.config/wt.toml`, `Err` for present-but-malformed. - Dropped the fallback from `commands::project_config::collect_remove_hook_commands` (the shared helper introduced in #2709) and from `output::handlers::execute_pre_remove_hooks_if_needed`, `worktree::switch::{switch_hook_project_config, hook_repo_for_worktree, spawn_switch_background_hooks}`, and `worktree::finish::finish_after_merge`. - Updated the `commands::hooks` spec docstring — table rows lose the fallback sub-clauses; trailing paragraph drops the fallback handling. ## Tests - Unit test `project_config_at_ref_reads_committed_config` now asserts `Err` on malformed committed config (was `None`). - Added `test_switch_existing_aborts_on_malformed_destination_config` and `test_remove_aborts_on_malformed_worktree_config` for the end-to-end abort path. - Updated three tests that relied on the fallback to commit configs on the relevant branches instead: `test_switch_pr_hooks_see_pr_vars` (commits on `pr-source`), `test_merge_teardown_hooks_read_destination_worktree_config` → `..._read_acting_worktree_config` (feature carries pre-remove), `test_approval_prompt_remove_decline` (commits before creating the worktree to remove). Snapshot regenerated for the last. Co-authored-by: Claude <noreply@anthropic.com> |
||
|
|
78b3a95872 |
fix(hooks): resolve project config from the worktree the hook acts on (#2690)
A hook resolves its project config from `ctx.repo.load_project_config()`, so whichever worktree that `Repository` is rooted at decides which `.config/wt.toml` it reads. Several call sites rooted it at the wrong worktree — most visibly `pre-remove`, which read the **primary** worktree's config rather than the worktree being removed, so a `pre-remove` you add on a feature branch never fired when removing that branch's worktree until the change landed on the default branch. This makes the rule uniform: **`ctx.repo` is rooted at the worktree the hook acts on** — and adds a spec docstring for it. ### What changed - **`pre-remove`** (`output/handlers.rs` `execute_pre_remove_hooks_if_needed`) now resolves config from the worktree being removed (it's still on disk at pre-remove time), falling back to the post-removal working directory's config (the primary worktree for `wt remove`, the destination for `wt merge`) if that worktree has no `.config/wt.toml` — so a project-wide `pre-remove` still applies to worktrees branched before it was added. The approval is split to mirror this: `main.rs` approves `pre-remove` per removed worktree against that worktree's config (`post-remove`/`post-switch` against the primary's), and `merge.rs` `collect_merge_commands` resolves `pre-remove` the same way the executor does — which closes the `TODO(pre-remove-context)` in `main.rs`. - **`post-merge`** (`worktree/finish.rs`) now resolves config from the target worktree (where it runs), not the feature worktree. `collect_merge_commands` splits the approval accordingly (feature config for `pre-commit`/`post-commit`/`pre-merge`; destination config for `post-merge`/`post-remove`/`post-switch`), which also fixes a pre-existing `wt merge` approval/execution mismatch on `post-remove`/`post-switch`. - **`wt step commit --branch <b>`** (`commands/context.rs` `CommandEnv::for_branch`) now roots the `Repository` at `<b>`'s worktree rather than the cwd, so `pre-commit`/`post-commit` for it read `<b>`'s config. As a side effect this fixes the diffstat suffix on that command's "Generating commit message…" line, which was empty because it was computed from the cwd worktree's (unstaged) index — see the updated `step_commit_branch_flag` snapshot. - **Spec**: `commands::hooks` module docstring now has a "Which `.config/wt.toml` a hook reads" section with the per-hook mapping and the construction site for each; `CommandContext.repo`, `Repository::project_config_path`, and `execute_hook` / `prepare_background_pipelines` point at it. The one exception, documented in the spec: `wt switch`'s `pre-start`/`post-start`/`post-switch` still read the invoking worktree's config — the new worktree doesn't exist yet when those hooks are approved, and for `wt switch --create` it's a fresh checkout of that worktree's HEAD anyway, so the config matches. ### Testing Three integration tests where the only worktree carrying a `.config/wt.toml` is the one the hook acts on, each verified to fail before the change: `test_pre_remove_hook_reads_removed_worktree_config` (remove.rs), `test_merge_teardown_hooks_read_destination_worktree_config` (merge.rs — covers `post-merge` and the `pre-remove` destination fallback), and the diffstat-suffix change in `step_commit_branch_flag` (merge.rs). Existing hook/merge/remove/step suites pass; `wt hook pre-merge --yes` (full suite + lints) is green. --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
1d97bb7a20 |
feat(config): add [remove] delete-branch config option (#2589)
## Summary Adds a `[remove]` config section that lets users default `wt remove` to keeping branches, requested in #2587. Setting `delete-branch = false` is equivalent to passing `--no-delete-branch` every time; CLI flags still override the config. ```toml [remove] delete-branch = false # never delete branches when removing worktrees ``` Implementation mirrors the existing `[merge]` pattern: new `RemoveConfig` section, a `[projects."<id>".remove]` override path, and a tri-state CLI flag pair (`--delete-branch` / `--no-delete-branch`) so users can override config either direction. ## Test plan - [x] Unit tests for `RemoveConfig` parse/default/merge + project override (`src/config/user/tests.rs`) - [x] Integration test: `[remove] delete-branch = false` keeps the branch on `wt remove` - [x] Integration test: explicit `--delete-branch` overrides the config - [x] All existing `wt remove` tests still pass (83 tests) - [x] `cargo test --lib --bins` and `cargo clippy --tests --all-features` clean - [x] Help/docs snapshots regenerated (`test_docs_are_in_sync`) Closes #2587. --------- Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
d66faea86b |
fix(prune): OR over local + upstream in step prune integration check (#2515)
## Summary `wt step prune` was the lone remaining caller of `Repository::effective_integration_target` — a "pick one ref" helper whose own docstring warned it was unsafe for safety-critical checks. In the diverged case it returned `origin/main`, then handed it to `integration_reason`, which couldn't resolve an upstream of an already-remote ref and ended up checking only that single side. Branches merged into local `main` while `origin/main` had unique commits never got pruned — the same diverged-case false negative #2513 fixed for `wt list` and #2507 fixed for `wt remove` / `wt merge`. This change passes the local default branch directly to `integration_reason`, letting it OR over local + upstream internally like `wt remove` and `wt merge` already do. The actual deletion gate inside `prepare_worktree_removal` was already correct, so this just stops the outer filter from silently dropping legitimately-integrated candidates. `effective_integration_target`, `integration_target`, and their `RepoCache` fields are now dead and deleted. ## Test plan - [x] New regression `test_prune_locally_merged_when_upstream_diverged` (mirrors `test_remove_merged_locally_when_upstream_diverged`). Verified to fail on the pre-fix code path with `No merged worktrees to remove`, passes on the fix. - [x] Symmetric `test_prune_squash_merged_on_remote_when_local_diverged` covers the merged-on-remote direction; pins forward-compatibility. - [x] `cargo run -- hook pre-merge --yes` — 3411/3411 pass, no snapshots to review. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
5a12324dd3 |
fix(integration): OR over local + upstream for integration detection (#2507)
## Summary `Repository::integration_reason` picked a single target via `effective_integration_target` and checked only that, silently dropping half of history in the diverged case. After `wt merge` updates local `main` (while it's still diverged from `origin/main`), the upstream-only check would report the just-merged branch as unmerged and skip removal. The symmetric hole existed in `wt remove`: a branch merged into local `main` on a diverged `main` was misreported. ## Behavior change `integration_reason(branch, target)` now considers both local and upstream: - No upstream / same commit: check local only. - Local strictly behind upstream: check upstream (superset). - Upstream strictly behind local: check local (superset). - **Diverged: check local, then upstream — integrated if either matches.** The local/upstream ancestor checks run git directly with ref names (`merge-base --is-ancestor`) rather than going through cached `is_ancestor`/`rev_parse_commit`. The local target may have just been updated mid-command (e.g. `wt merge`'s ref update), so cached SHAs would be stale. `effective_integration_target` stays as a display helper for `wt list`'s status column — the docstring is updated to make clear it's not for safety-critical checks. ## Test plan Two new regression tests cover the symmetric cases: - `test_merge_removes_branch_when_local_main_diverged_from_upstream` — `wt merge` after the user's reported scenario (remote-only commit + local-only commit on `main` create divergence). Verified failing without the fix; passing with it. - `test_remove_merged_locally_when_upstream_diverged` — `wt remove` for a branch merged into local `main` while `origin/main` has diverged. Verified failing without the fix; passing with it. One existing snapshot (`merge_auto_commit_combines_post_commit_post_remove_post_switch_post_merge`) was capturing the buggy `Branch unmerged` output for an in-process scenario where the SHA cache went stale across the merge ref-update; updated to the correct integration message. All 3405 tests pass; all lints clean. > _This was written by Claude Code on behalf of @max-sixty_ --------- Co-authored-by: Claude <noreply@anthropic.com> |
||
|
|
5f126d21fe |
docs(pages): add static command output to sections dominated by GIFs (#2405)
## Summary Adds static command-output blocks to the docs pages dominated by GIFs (addresses #2403). The blocks are **driven from insta snapshots** so they stay in lockstep with what `wt` actually prints — one source flows to all three surfaces: - terminal `wt <cmd> --help` (plain text, gutter-formatted) - `docs/content/*.md` (colorized `{% terminal(cmd="...") %}` shortcode) - `skills/worktrunk/reference/*.md` (plain `$ cmd\noutput\n` block) ## What changed - **Six scripted snapshot tests** produce realistic output (`cargo nextest run`, `flyctl scale count 0`, LLM-generated commit messages): - `test_docs_merge_pre_merge_hook` — `wt merge` with pre-merge hook - `test_docs_step_commit_llm` — `wt step commit` with LLM - `test_docs_step_squash_llm` — three-commit squash with LLM - `test_docs_merge_squash_llm` — `wt merge` (squash + LLM + merge) for `llm-commits.md` - `test_docs_remove_pre_remove_hook` — `wt remove` with pre-remove hook - `test_docs_hook_pre_merge` — `wt hook pre-merge` direct invocation - **Sync pipeline extension** in `tests/integration_tests/readme_sync.rs`: - New write-back pass `sync_cli_mod_example_bodies` fills the ```console``` body in each `<!-- wt <cmd> (docs-example) -->` placeholder in `src/cli/mod.rs` from the registered snapshot. Runs before `--help-page` reads the file. - `COMMAND_PLACEHOLDER_PATTERN` extended to match three forms (```bash```, `{{ terminal() }}` self-closing, `{% terminal %} body {% end %}`). This **fixes a pre-existing bug** in `docs/content/list.md` where the HTML-mode expansion was silently broken. - Stripped trailing `|||` corruption that arises when blank lines in snapshot bodies are interpreted as empty commands by `convert_dollar_console_to_terminal`. - **Docs page migration**: - `src/cli/mod.rs` — replaced four hand-written ```console``` blocks (merge, step, remove, hook) with `<!-- wt <cmd> (docs-example) -->` markers. - `docs/content/llm-commits.md` — replaced three hand-crafted HTML blocks with `<!-- ⚠️ AUTO-GENERATED-HTML from X.snap -->` markers. - **Refactor follow-up** in a separate commit: - `BADGE_EXPERIMENTAL_HTML`, `SUBDOC_MARKER_PREFIX`, `DEMO_MARKER_PREFIX` hoisted to `worktrunk::docs` so producer and consumer stay in lockstep. - `normalize_clap_help_fences()` consolidates the `text→` + `console→bash` replacement pair shared by `--help-md` and `help_reference_inner`. - **CLAUDE.md note** documenting the `.gitattributes` `linguist-generated=false` exemption requirement when adding skill-only files (carryover from #2409). ## Test plan - [x] `cargo test --test integration readme_sync` — all 13 tests pass, idempotent - [x] `cargo test --test integration test_help` — help snapshots updated - [x] `cargo run -- {merge,remove,step,hook} --help` — clean gutter formatting, no visible HTML comments - [x] `cargo run -- hook pre-merge --yes` — full project gate green (3355 tests) - [ ] Visual check on dev site (maintainer — terminal shortcodes now render with colors via ANSI→HTML; dark/light variants both expected to look like the GIFs they replace) 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> Co-authored-by: Maximilian Roos <m@maxroos.com> |
||
|
|
37fb27bc97 |
Deprecate table form for pre-* hooks (#2135)
## Summary
Multi-entry table form for pre-* hooks currently runs serially, while
the same form for post-* hooks runs concurrently. The parser produces
`HookStep::Concurrent` either way, but the foreground executor flattens
steps and runs them serially. To unify the semantics — table form will
run concurrently for all hook types in a future version — this
deprecates the current form for pre-* hooks and auto-migrates it to
pipeline form, which is explicitly serial.
## Implementation
Follows the existing deprecation recipe in `src/config/deprecation.rs`:
- **Detection** and **migration** for top-level hooks (user/project
config) and per-project overrides (`[projects."id".pre-*]`).
- **Warning**: matches the terse `{old} → {new}` pattern of existing
deprecations (`[merge] no-ff → ff`, `post-create → pre-start`, etc.).
- **Auto-migration** at load time: table form rewrites to pipeline of
inline tables so current behavior (serial) is preserved until users run
`wt config update`.
## Docs
Replaces the transitional "concurrent for post-*, sequential for pre-*"
framing with a neutral three-form description (string / table /
pipeline), plus a note recommending pipeline form for pre-* hooks to
avoid the upcoming behavior change.
## Tests
- `snapshot_migrate_pre_hook_table_form` — TOML migration diff
- `test_config_show_displays_pre_hook_table_form_deprecation` — full
user-facing `wt config show` output, covering the "Project config" label
and multi-hook list form
- Unit tests for detection/migration of top-level and per-project
variants
- Existing integration test fixtures migrated to canonical pipeline form
> _This was written by Claude Code on behalf of max-sixty_
Co-authored-by: Claude <noreply@anthropic.com>
|
||
|
|
b174658e29 |
Split directive file into CD (raw path) and EXEC (shell) files (#2118)
The shell wrapper previously used a single `WORKTRUNK_DIRECTIVE_FILE` where wt wrote shell commands (`cd '/path'`, arbitrary `--execute` payloads). This meant the cd path went through shell parsing — any content wt wrote was sourced as shell. This splits the protocol into two files with different trust levels: - **`WORKTRUNK_DIRECTIVE_CD_FILE`** — raw path, read with `cd -- "$(< file)"`. No shell parsing, no escaping, no injection surface. Safe to pass through to alias/hook child processes. - **`WORKTRUNK_DIRECTIVE_EXEC_FILE`** — arbitrary shell (from `--execute`), sourced by the wrapper. Scrubbed from alias/hook child environments so hook bodies cannot inject shell into the parent session. When a nested `wt` inside an alias body tries `--execute` without the EXEC file, the command is dropped with a warning linking to #2101 for user feedback. The old `WORKTRUNK_DIRECTIVE_FILE` is silently honored for one release (users who upgrade wt without restarting their shell). Bash, zsh, fish, and PowerShell self-update on restart; nushell requires `wt config shell install`. Closes #2101 > _This was written by Claude Code on behalf of @max-sixty_ --------- Co-authored-by: Claude <noreply@anthropic.com> |
||
|
|
4a7219b230 |
Sweep stale .git/wt/trash entries on wt remove (#2039)
## Summary Background `wt remove` renames worktrees into `.git/wt/trash/<name>-<timestamp>` before firing a detached `rm -rf`. If that cleanup is interrupted (SIGKILL, reboot, disk full), the directory is orphaned and nothing reclaims the space. After `wt remove` prints its primary output, it now sweeps trash entries older than 24 hours via a single detached `rm -rf`. Age is parsed from the filename suffix, so the sweep is deterministic under `WORKTRUNK_TEST_EPOCH`. ## Notes - Runs **after** `handle_remove_output` so it never delays time-to-first-output. `.claude/skills/writing-user-outputs/SKILL.md` documents the general rule for future handlers. - Uses existing `spawn_detached` infrastructure and a new `InternalOp::TrashSweep` log name (`.git/wt/logs/wt-trash-sweep.log`). - Unparseable entries are left alone — the sweep only touches names worktrunk produced. - Integration test seeds stale + fresh entries, runs `wt remove`, and polls for the stale entry to disappear while the fresh one stays. Ref #1974 — related safety improvement (data lingers in trash briefly before deletion), but doesn't resolve the prune confirmation prompt discussion there. > _This was written by Claude Code on behalf of max-sixty_ Co-authored-by: Claude <noreply@anthropic.com> |
||
|
|
65e58d3647 |
fix: JSON output follow-ups from --format=json rollout (#2001)
Four targeted fixes identified as follow-ups from the `--format=json` rollout (#1969, #1998): 1. **Sort stability in `partition_log_files_json`** — added filename tiebreaker to match `render_log_table`'s sort. Without this, entries with identical timestamps could appear in non-deterministic order in JSON output. 2. **for-each `error` field always present on failure** — previously, the `"error"` field only appeared for `SpawnFailed` errors, not `ExitCode`. Consumers parsing the JSON had to handle two shapes. Now both variants include `"error"`: `"exit code N"` for ExitCode, `"spawn failed: ..."` for SpawnFailed, `"killed by signal"` for signal termination. Error formatting lives in `CommandError::Display` impl with a unit test covering all three variants. 3. **Test: `config show --format=json` outside a git repo** — verifies that project path/config are `null` when not in a git repository. 4. **Test: multi-remove `--format=json` with current worktree** — covers the `plans.current` code path where the current worktree appears in the multi-remove target list (deferred to last in output). > _This was written by Claude Code on behalf of Maximilian Roos_ --------- Co-authored-by: Claude <noreply@anthropic.com> |
||
|
|
85c3dadf17 |
feat: add --format=json to config state subcommands (#1969)
Add `--format=json` to 10 commands across two commits.
## Config state subcommands (commit 1)
- `logs get` — `{command_log: [...], hook_output: [...]}` with
file/size/modified_at per entry
- `ci-status get` — `{status, source, stale, url}` (richer than the text
mode's bare status string)
- `marker get` — `{branch, marker, set_at}` or `null`
- `vars list` — `{key: value, ...}` object
- `hints get` — `["hint-name", ...]` array
Also extracts a shared `log_entry_to_json` helper, deduplicating the
DirEntry→JSON conversion.
## Top-level commands (commit 2)
- `config show` — serialized user/project/system config with paths and
existence flags
- `step prune --dry-run` — array of candidates with branch, path, kind,
reason, target
- `remove` — array of removed worktrees with branch, path, deletion
status
- `merge` — summary with branch, target, committed, squashed, rebased,
removed
- `step for-each` — per-worktree results with branch, path, exit_code,
success
For action commands (remove, merge, for-each), progress goes to stderr
as usual; JSON summary goes to stdout at the end — same pattern as `wt
switch --format=json`.
> _This was written by Claude Code on behalf of @max-sixty_
---------
Co-authored-by: Claude <noreply@anthropic.com>
|
||
|
|
20b8b22f6b |
fix: use effective integration target in RemovedWorktree display (#1993)
The background removal path was showing the local default branch (e.g., `main`) instead of the effective target (e.g., `origin/main`) when the upstream was ahead. This was a pre-existing inconsistency between foreground and background removal — foreground gets the effective target from `delete_branch_if_safe`, but background used `target_branch` directly. Now `prepare_worktree_removal` Phase 5 uses the effective target returned by `compute_integration_reason` (which was added in #1989 but discarded with `_`). One-line fix: `let target_branch = effective_target.or(target_branch);` Added `test_remove_worktree_squash_merged_on_remote` which creates a worktree where `origin/main` is ahead of local `main` after a squash merge, and verifies the background removal output shows `origin/main`. > _This was written by Claude Code on behalf of @max-sixty_ Co-authored-by: Claude <noreply@anthropic.com> |
||
|
|
679fe539fe |
Deprecate --no-verify in favor of --no-hooks (#1932)
`--no-hooks` describes what the flag does — skip hooks. `--no-verify` was inherited from git's naming but doesn't match worktrunk's semantics (there's no "verification" step being skipped). `--no-verify` remains as a hidden alias that emits a deprecation warning, retained for at least one release cycle per the project's deprecation policy. Changes across switch, remove, merge, step commit, and step squash: - `--no-hooks` is the canonical visible flag - `--no-verify` hidden, emits `▲ --no-verify is deprecated; use --no-hooks instead` - Error hints (`↳ To skip pre-merge hooks, re-run with --no-hooks`), info messages, help text, docs, and config examples all updated - `resolve_verify()` helper in main.rs deduplicates the deprecation logic - Backward-compatibility test verifies `--no-verify` still works > _This was written by Claude Code on behalf of @max-sixty_ --------- Co-authored-by: Claude <noreply@anthropic.com> |
||
|
|
f6e89009e5 |
fix: detect squash-merged branches when merge-tree conflicts (#1820)
## Problem `wt step prune` (and `wt remove`) fail to detect squash-merged branches when the default branch has since modified the same files that the branch touched. This is because `git merge-tree --write-tree` reports conflicts when both sides changed the same files, and the code conservatively treated conflicts as "not integrated." The same issue affects `wt list` — the `WouldMergeAdd` task calls the same function, so the integration symbol `⊂` is not shown for these branches. ## Solution When `git merge-tree --write-tree` conflicts, fall back to **patch-id matching**: compute the branch's squashed patch-id (`git diff-tree -p merge-base..branch | git patch-id --verbatim`) and check if any commit on the target has a matching patch-id. This detects squash merges because the squash-merge commit on the target has the exact same content changes as the branch. Uses `--verbatim` (not `--stable`) to avoid false positives from whitespace normalization — `--stable` strips whitespace, so tabs-vs-spaces would produce matching patch-ids even though file content differs. The fallback is only triggered when merge-tree conflicts — the happy path (no conflicts) is unchanged. The merge-tree → patch-id sequence is extracted into `Repository::merge_integration_probe()`, a shared method used by both `wt list` (parallel tasks) and `wt remove`/`wt merge` (sequential path). This fixes a drift where the two call sites handled patch-id errors differently. ## Known limitation The patch-id fallback checks if the branch's diff was **ever** applied to the target, not whether the effect **persists**. If a squash merge is later reverted on the target and then the same files are modified (causing merge-tree conflicts), the historical squash-merge commit's patch-id still matches. This is documented in #1818. A follow-up could add a revert-detection check after finding a match. ## Testing - `test_remove_squash_merged_then_same_files_modified` — reproduces the exact scenario from #1818 (branch modifies file, squash-merged, then target modifies same file) - `test_prune_squash_merged_same_files_modified` — verifies `wt step prune --dry-run` detects the branch - All existing squash-merge tests continue to pass (5 remove + 22 prune tests) Closes #1818 > _This was written by Claude Code on behalf of @max-sixty_ --------- Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Maximilian Roos <5635139+max-sixty@users.noreply.github.com> Co-authored-by: Maximilian Roos <m@maxroos.com> |
||
|
|
0d37405b38 |
fix: only create placeholder directory when removing current worktree (#1868)
The empty placeholder at the removed worktree path (for Nushell PWD validity) was being created unconditionally for all background removals. It's only needed when removing the *current* worktree — where the shell's PWD points and the wrapper needs to cd away. Threads `changed_directory` through `spawn_background_removal` → `execute_instant_removal_or_fallback` → `build_remove_command_staged`. When `false`, skips `create_dir` and emits just `rm -rf` instead of `sleep 1 && rmdir ...; rm -rf`. Test assertions for non-current worktree removals tightened from `assert_worktree_removed()` (accepts empty dirs) to `!path.exists()`. > _This was written by Claude Code on behalf of @max-sixty_ Co-authored-by: Claude <noreply@anthropic.com> |
||
|
|
bd87f2a146 |
fix: background removal blocks for 1s due to shell parsing bug (#1858)
`spawn_detached_unix` constructed the shell command as `sh -c "sleep 1
&& rmdir ...; rm -rf ... &"`. In POSIX shell, `;` has lower precedence
than `&`, so this parses as two statements: `sleep 1 && rmdir ...` runs
**synchronously** (1 second block), then only `rm -rf ... &` is
backgrounded. Every `wt remove` paid a 1-second penalty.
The fix wraps compound commands in braces — `{ sleep 1 && rmdir ...; rm
-rf ...; } &` — so `&` backgrounds the entire group. This affects all
callers of `spawn_detached` (remove, prune, merge, hooks).
Tests that asserted `!path.exists()` after removal now use
`assert_worktree_removed()` which accepts an empty placeholder directory
(the placeholder is cleaned up by the now-correctly-backgrounded `sleep
1 && rmdir`). Also fixes a pre-existing race in
`test_standalone_hook_post_merge` / `post_create` where background hooks
were checked immediately instead of polled.
Also adds `benches/remove.rs` for measuring end-to-end remove
performance.
> _This was written by Claude Code on behalf of maximilian_
---------
Co-authored-by: Claude <noreply@anthropic.com>
|
||
|
|
68869761da |
Fix fork CI queries, branch tracking remote check, and diverged integration target (#1812)
Three edge-case fixes found via Codex: **1. CI check-runs fallback queries the wrong repo for fork workflows** — `detect_github_commit_checks` was using `github_owner_repo()` (first GitHub remote in the repo) instead of the branch's actual push destination. For fork workflows where branches push to a different repository, this queried the upstream repo's API and found no check runs. Added `github_owner_repo_for_branch()` that resolves through `pushRemote` → `remote.pushDefault` → tracking remote, with fallback to repo-wide remote. **2. `branch_tracks_ref` only checked `.merge`, not `.remote`** — When `wt switch pr:N` found an existing branch tracking `refs/pull/N/head`, it considered it a match regardless of which remote was configured. A branch could track the right ref path but have the wrong remote, so push/pull would be wired to the wrong repository. Now also checks `branch.<name>.remote` when an expected remote is provided. **3. `effective_integration_target` missed remote merges when local main diverged** — When local `main` had extra commits (diverged from `origin/main`), the function returned `main` instead of `origin/main`, causing `wt remove` to miss branches that were squash-merged on the remote. Now prefers upstream in the diverged case, since remote merges should still count as integrated. All three have regression tests. > _This was written by Claude Code on behalf of maximilian_ --------- Co-authored-by: Maximilian Roos <maximilian@Maximilians-MacBook-Pro.local> Co-authored-by: Claude <noreply@anthropic.com> |
||
|
|
a2482f1da9 |
refactor: change test git_command() to return Cmd instead of Command (#1718)
The test infrastructure's `git_command()` returned
`std::process::Command`, so test git commands bypassed `Cmd`'s debug
logging (`$ git status [ctx]`) and timing traces (`[wt-trace]`). This
changes it to return `worktrunk::shell_exec::Cmd`.
## Approach
Added `configure_git_env(Cmd, &Path) -> Cmd` alongside the existing
`configure_git_cmd(&mut Command)`. The `Command` version is kept because
`configure_wt_cmd` (which configures wt binary commands) still needs it
— wt commands go through `Command`, not `Cmd`.
Key changes in `tests/common/mod.rs`:
- `TestRepoBase::git_command()` and `TestRepo::git_command()` now return
`Cmd`
- All wrapper methods (`run_git`, `git_output`, `head_sha`, etc.) use
`.run()` instead of `.output()`
- `BareRepoTest::new()` and `NestedBareRepoTest::new()` use
`Cmd::new("git")` for init
247 call sites across 25 files converted: `.output()` → `.run()` and
`.status()` → `.run()` on git command chains. Zero `Command::new("git")`
remaining in the test directory.
Follows #1714 and #1716 which converted raw `Cmd::new("git")` and
`Command::new("git")` in test bodies to `repo.run_command()`.
> _This was written by Claude Code on behalf of maximilian_
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|