Repository navigation
Conversation
|
@max-sixty Could you take a focused data-preservation review of this follow-up to #4111, especially the pre-ref cleanup gate, StageMode semantics, and |
worktrunk-bot
left a comment
There was a problem hiding this comment.
This changes the worktree-removal decision path (src/commands/worktree/finish.rs, src/output/handlers.rs) and what wt merge refuses before deleting a worktree. That is the data-loss surface this repo's review guidance holds for a human reviewer regardless of how the diff reads, so this is a comment rather than an approval — the verdict is @max-sixty's, not mine. Adding them as a reviewer to match the ask above.
codecov/patch is red — 97.61% of diff hit against a 98.29% target — and per CLAUDE.md → Coverage that gates the merge. From the cobertura.xml in the code-coverage run for 1afa1262d6cdd21dbf655346d4364aabf0280243, eight added lines are unhit:
| File | Line | Code |
|---|---|---|
src/commands/merge.rs |
162 | StageMode::None => unreachable!(), |
src/commands/worktree/finish.rs |
201 | None => "Worktree preserved (locked)".into(), |
src/commands/worktree/finish.rs |
194, 269 | the ? on execute_pre_remove_hook / handle_remove_output_after_pre_remove |
src/commands/commit.rs |
39, 40, 61 | error arms in stage_with_untracked_warning / staged_added_paths |
src/git/repository/working_tree.rs |
1041 | the non-0/1 exit arm of has_staged_changes |
Two of those are cheap to close. merge.rs:162 disappears entirely with the restructure suggested inline. finish.rs:201 is reachable from a pre-remove hook that runs git worktree lock without --reason — test_merge_pre_remove_lock_preserves_worktree currently exercises only the with-reason branch. The remaining six are subprocess-failure arms with no deterministic trigger; if closing the two above doesn't clear the target, that's the arithmetic worth handing over rather than contorting tests around.
The finish.rs module docstring no longer matches the function. It still says removal is blocked by "Five conditions … --no-remove, on-target, primary-worktree, locked, and default-branch" and that "handle_remove_output performs it". This PR adds a sixth blocker — a lock created by the pre-remove hook, checked after the hook returns — and routes the removal through handle_remove_output_after_pre_remove instead. CLAUDE.md → Code Quality asks that system docstrings stay current, and this one is the map a reader uses for exactly the ordering the PR changed. It sits outside the diff so I can't attach a suggestion to it; I'm happy to push that edit if you'd rather not hand-apply it.
The remaining inline comments are the unreachable!() restructure, a --no-relative hardening on the new staged-additions query, and two readability suggestions in parse.rs.
d1f27d9 to
ccfb7c0
Compare
|
@worktrunk-bot Thanks; ccfb7c0 addresses all four inline suggestions, updates the |
|
Confirmed — all four inline suggestions landed verbatim at
Two verification notes on the rest:
|
worktrunk-bot
left a comment
There was a problem hiding this comment.
Two findings on ccfb7c084, both in code this PR adds.
The pre-update refusal names paths that don't block it. dirty_files() returns every porcelain record, so under --stage=tracked a modified tracked file is listed beside the untracked one — but --stage=tracked would have committed that file, and only the untracked path blocks the removal. The hint that follows ("Commit or stash changes first") then points at a path where committing changes nothing. test_merge_non_all_stage_modes_refuse_hidden_untracked_before_update has only an untracked file in its fixture, so nothing pins the listing.
escape_filename_for_terminal lets Unicode bidi controls through. char::is_control() is general category Cc only; U+202E and the rest of the Cf directional overrides and isolates reorder the rendered line without being control codes, so an auto-staged path can print as something other than the path being committed — the spoofing the ESC escaping already blocks, by a different mechanism. -z is what makes it reachable here: it turns off git's own core.quotePath, which octal-escapes those bytes.
ccfb7c0 to
46453ef
Compare
worktrunk-bot
left a comment
There was a problem hiding this comment.
One new finding on this head, inline in finish.rs: a pre-remove hook that
locks the worktree now means two different things depending on which command
ran it. Merge treats it as a request to preserve; wt remove still fails the
removal with WorktreeLocked.
The verdict stays with @max-sixty, per the earlier pass on this PR.
worktrunk-bot
left a comment
There was a problem hiding this comment.
Two findings on d8c7f2dff, both in code this PR adds, plus a note on the PR's shape.
wt switch --prs can lose every PR row to a concurrent alt-x. The new publication_is_current() gates the --prs append on the row-mutation generation as well as the spawn generation, but the two aren't interchangeable: dropping a superseded spawn's batch is free because the alt-r that superseded it starts a --prs thread that refetches everything, while a completed alt-x removal starts no new stream and stream_open_prs runs once per refresh. Press alt-x on a worktree row while gh pr list is still in flight and the whole batch is discarded — no rows, no stashed warning, and the "loading…" marker clears anyway, so the picker just has no PRs in it until the user hits alt-r. Mechanism and a narrower guard inline.
The deferred-removal wait also gates post-switch. Waiting for the detached fallback is right for post-remove, whose template variables describe a worktree that has to be gone. post-switch is anchored at the destination and has no such dependency, but it is registered before the checkpoint scope closes, so it inherits the same condition: a dev server started from post-switch now blocks until the old directory disappears, and doesn't start at all if that removal never succeeds — the Windows-file-locking and permissions cases the legacy fallback exists to cover. Moving the wait_for_worktree_removal_since call above the post-switch registration scopes it to post-remove; details inline, and I'm happy to push it.
On the PR's shape: the picker's refresh-race reconciliation (row_mutation_generation, reconcile_successful_drop / reconcile_successful_morph, ~900 changed lines in picker/mod.rs) and the post-remove wait are both independent of the status-visibility subject this PR is named for, and neither appears in the PR body or the CHANGELOG entry. Both fix races that predate this branch — a refresh can publish a pre-removal snapshot whenever a pre-remove hook is slow, lock change or not. Split out, each could be judged on its own evidence; bundled into a 3,400-line diff over the removal surface, they make the data-safety change harder to review than it needs to be.
The verdict stays with @max-sixty, per the earlier pass on this PR.
|
Roughly five of those are cheap: The largest single block is the seven lines in |
|
@Duang777 please can we radically reduce the size of the PRs? I very much appreciate the recent one, thank you; but these +3K lines are really difficult to review... I would much prefer 10 PRs each with 100 (non-test / comments) lines than 1 PR with 800 lines... |
|
@worktrunk-bot rather than add all these flags to the porcelain git commands, are there git commands that answer the question more literally, don't defer to user preferences? |
worktrunk-bot
left a comment
There was a problem hiding this comment.
Answering @max-sixty's question first, then two findings on the five commits pushed since the last pass.
For the two index queries, yes — git diff-index is the literal form and drops both display flags. For submodules, no. For untracked, not for free. The plumbing diff commands (diff-index, diff-files, diff-tree) read git's basic diff config rather than the UI config git diff reads, so they never consult diff.relative or diff.renames. Swapping git diff --cached for git diff-index --cached <tree-ish> in staged_added_paths and has_staged_changes therefore makes --no-relative and --no-renames unnecessary: the property stops being a flag each new call site has to remember and becomes a property of the command. Two real costs — diff-index requires an explicit tree-ish and exits 128 on an unborn HEAD where git diff --cached falls back to the empty tree, so it needs head_sha() (a fresh rev-parse per call, ~5 ms by its own doc comment) plus an empty-tree fallback for the None case.
--ignore-submodules=none is load-bearing in both families and should stay. diff.ignoreSubmodules is UI config that plumbing ignores, but submodule.<name>.ignore is read by plumbing too, and it hides a staged gitlink from diff-index exactly as it does from git diff.
For the untracked half there is a literal command but no free win. git ls-files --others --exclude-standard asks precisely "which files are untracked" and reads no display config at all — but it trades the config dependency for a cwd dependency: it reports only the cwd subtree, cwd-relative, unless you add --full-name -- :/, where git status --porcelain is whole-repo and repo-root-relative by definition regardless of status.relativePaths. And is_dirty / dirty_files want tracked modifications, untracked paths and submodule dirt in one record stream from one subprocess; splitting that into diff-index + diff-files + ls-files --others is three calls and three parsers. --untracked-files=normal on status --porcelain is the better answer there.
git 2.55 transcripts behind the three claims
diff.relative truncates the porcelain listing and is invisible to the plumbing form. a/new1.txt and b/new2.txt staged, run from a/:
$ git -c diff.relative=true diff --cached --name-only --diff-filter=A
new1.txt
$ git -c diff.relative=true diff-index --cached --name-only --diff-filter=A HEAD
a/new1.txt
a/renamed.txt
b/new2.txtdiff.renames folds an addition into a rename for the porcelain form only, so --diff-filter=A still sees the new path under plumbing with no --no-renames:
$ git -c diff.renames=true diff --cached --name-status
A a/new1.txt
R100 a/tracked.txt a/renamed.txt
A b/new2.txt
$ git -c diff.renames=true diff-index --cached --name-status HEAD
A a/new1.txt
A a/renamed.txt
D a/tracked.txt
A b/new2.txtsubmodule.<name>.ignore reaches plumbing, including for a staged gitlink change, so the explicit flag is the only override:
$ git -c submodule.sub.ignore=all diff-index --cached --name-only HEAD
$ git -c submodule.sub.ignore=all diff-index --cached --name-only --ignore-submodules=none HEAD
subls-files --others ignores status.showUntrackedFiles but answers only for the cwd subtree. Run from a/, with a/tracked.txt modified and a/untracked.txt present:
$ git -c status.showUntrackedFiles=no status --porcelain
M a/tracked.txt
$ git -c status.showUntrackedFiles=no ls-files --others --exclude-standard
untracked.txt
$ git -c status.showUntrackedFiles=no ls-files --others --exclude-standard --full-name -- :/
a/untracked.txtThat leads into the first finding, inline: has_staged_changes is the one index query in this PR that didn't get --no-relative, and because it reads an exit code rather than a path list, the leak inverts its answer instead of shortening a listing.
The second finding, also inline: the new completion marker leaves a file under .git/wt/removal-markers/ that nothing reclaims when the detached removal fails — the case the fallback exists for — and that no doc lists. Git already maintains a per-worktree signal with the path-reuse property the marker's docstring gives as its reason.
Both threads from the last pass are addressed; resolving them. The post-switch reorder and the mutation-replay rewrite both land where they were asked to, and collect_published_branches correctly keeps a dropped row's branch out of the dedup set so its PR stays visible.
|
Retracting the coverage gate I left standing:
|
worktrunk-bot
left a comment
There was a problem hiding this comment.
Both open threads land where they were asked to — has_staged_changes carries --no-relative with a nested-discovery-path test that fails without it, and the marker file is gone. Resolving them.
One finding on 57f3df26, inline in run_pipeline.rs: the Git-directory signal is not success-gated the way the marker was. git worktree remove deletes $GIT_COMMON_DIR/worktrees/<id> even when it could not delete the worktree directory, so on the fallback's failure path — Windows file locking, a permission problem, the cases the legacy fallback exists for — post-remove is now released against a worktree directory that is still on disk, where the &&-chained rm -f -- <marker> left it waiting to REMOVAL_WAIT_TIMEOUT. Prompt hooks over a five-minute stall is arguably the better of those two, so the suggestion corrects the docstrings rather than the behavior; if the strict "the directory is gone" contract is what post-remove is meant to have, the wait needs to confirm the worktree path too, and I'm happy to push that instead.
test_remove_failed_background_fallback_leaves_no_completion_artifact doesn't reach this: its wrapper exits 1 without touching anything, so worktrees/<id> survives and the wait behaves exactly as the docstring describes. It pins the absence of the removed machinery, which is worth having — it just isn't a test of the new signal's failure path.
Version bump to 0.78.0 plus the changelog section for this release, a comment-preservation fix the release review turned up, a PTY test-filter fix, and merges of `main` (commits that landed during the review, including #4124, #4122, #4141, #4140 and #4145). **Bump level**: minor. `cargo semver-checks` reports 2 breaking library changes — a new `leftover_branch` field on the `GitError::WorktreeCreationFailed` struct variant, and `DEPRECATED_TEMPLATE_VARS` removed. Pre-1.0, so a breaking change takes a minor bump. ## Comment-preservation fix A config save — declining the commit-generation offer, say — dropped the trailing comment on every value it rewrote. That was already true in v0.77.0 for a line the save itself changed (`skip-commit-generation-prompt = false # note` → `true`, reproduced on the v0.77.0 binary). #4080 widened it to lines the command never touched: retired template variables now migrate on every load, so the next unrelated save rewrote any line naming one. #4080's own PR said this "needs a call before merge" and merged without one. - `replace_keeping_decor` keeps the value's decor (spacing after `=`, trailing comment) when `merge_tables` replaces a scalar. Comments *above* a line sit on the key and were never at risk. - `main`'s #4120 independently reworked the inline-table arm and moved a key's leading comment onto the rewritten header, but documented that a trailing comment after the closing brace "is still dropped". `replace_inline_with_table` now carries it after the header's `]`, so both of its callers keep it. - Tests: the existing comment-preservation test goes back to a retired name (the only fixture that exercises the replacement) and adds a comment on the mutated line; a new test snapshots and re-parses an inline `[projects]` entry. Each fails without its half of the fix. Not fixed, and identical on v0.77.0: a save deletes an explicitly written *default* value (e.g. `skip-commit-generation-prompt = false # note`) as a stale key, comment included. ## `--help` correction #4104 moved the OpenCode plugin path to etcetera's `Xdg` strategy, which uses `$XDG_CONFIG_HOME` only when absolute — a deliberate, documented choice — but `wt config plugins opencode --help` still said install mirrors OpenCode's precedence. OpenCode reads a relative value as-is, so the help now says an empty `$OPENCODE_CONFIG_DIR` and an empty or relative `$XDG_CONFIG_HOME` are ignored. ## Data-loss surface review Independent finders swept the full diff, including the five commits merged from `main`. Nothing holds the tag; candidates were adjudicated against v0.77.0 binaries: - **Fixed in this release, reproduced on v0.77.0**: #4104 stops `wt config shell install` (with Nushell) deleting a `nushell/vendor/autoload/wt.nu` under the current directory when `$XDG_CONFIG_HOME` is empty or relative; #4120 stops a save or migration from writing an inline section's comment inside `[commit]` brackets, which made the config unparseable, and from dropping unknown keys. - **`wt step prune --min-age` doesn't apply where no reflog exists** — pre-existing, proven by building the pre-#4077 binary. `git clone --bare` and `git fetch` into `refs/heads/*` write no reflog, so in the bare layout `tips-patterns.md` recommends every branch starts unprotected. Docstring corrected here; behavioral fix is follow-up. - **`wt config update --output <path>` replaces any named file whole**, no existence check or prompt — pre-existing, follow-up. - **`wt config shell install fish --yes` replaces a hand-written wrapper at the XDG path** — accepted ("ownership is the path", predates #3602), newly reachable only for users with `$XDG_CONFIG_HOME` set. The FAQ's delete inventory now cross-references it. - **`--var <retired-name>` binds a name nothing references** after #4080 — inert, no shipped usage; follow-up. - **#4135 migrates nothing**: an existing oh-my-pi hook stays put, `pi uninstall` points at `omp uninstall`. `PI_CODING_AGENT_DIR='~/x'` is used literally (Pi expands `~`) — creates a stray directory, deletes nothing; follow-up. ## Changelog 37 entries; each new or edited entry was re-verified against v0.77.0 until a pass came back clean (six passes in all). Passes caught, among others: the OpenCode `-x` recipe was wrong since 0.76.0, not 0.75.0; #4080's JSON reaches every hook, not just `pre-start`/`post-start`; a quoted git error string git never prints; a copy-button overlap that existed only inside this release; #4104's relative-`XDG_CONFIG_HOME` OpenCode change described as a fix when OpenCode reads that path as-is; and which commands a broken inline section stopped. **Fixed** leads with the data-loss entries. ## Open PRs checked against v0.77.0 - **#4124** fixed a bug new in this release, caused by #4080, and has landed and been merged in here. The hand-rolled template-variable scanner it extends is slated for removal; see its commit message. - **#4112** — no data-loss regression vs v0.77.0; two small non-safety regressions from #4111 (an uncapped auto-staging file list, a narrow `Nothing to commit` refusal under `status.showUntrackedFiles = no`). Not merged, by decision. - **#4122** (merged in) and **#4123** — pre-existing bugs, identical on v0.77.0. ## PTY filter fix `test (macos)` failed once on a shell's failed-`setpgid` diagnostic that landed straight after the capture's trailing `\x1b[0m`, with no newline between, so #4136's line-anchored filter missed it. The filter now also matches right after an SGR escape and keeps the escape; its test gains that shape and fails on the old pattern. ## Validation - `cargo run -- hook pre-merge --yes` — 4930 tests passed, 1 skipped; clippy, fmt, doc-sync, snapshot and lockfile checks green - Nightly on the original cut-from tip (https://github.com/max-sixty/worktrunk/actions/runs/35060617020): green across `full-tests` on linux/macos/windows, `feature-powerset`, all `release-target`s, `nix-flake`, `minimal-versions`, `link-check` > _This was written by Claude Code on behalf of max-sixty_ 🤖 Generated with [Claude Code](https://claude.com/claude-code)
…what wt reads (#4146) Porcelain `git diff` reads display configuration that changes what it reports, and `wt` parsed its output. With `diff.relative = true`, `wt remove <branch>` run from a subdirectory deleted an unmerged branch whose changes sat outside that directory, reporting "no added changes". `color.ui = always` and `diff.external` put escape codes or an external tool's output into the commit, squash, and summary prompts. Every diff `wt` parses, counts, caches, renders, or sends to an LLM now runs `diff-tree`, `diff-index`, or `diff-files`, which ignore that configuration. This follows the plumbing question raised in the review of #4112. ## Where to look - `src/git/repository/diff.rs`: `PreparedDiff` is the one path for rendered and prompted diffs, with working-tree, staged, and commit-pair sides read from the real index or a borrowed `TempIndex`. It restores the `git diff` defaults plumbing lacks (rename detection, textconv, hidden intent-to-add entries), overrides `submodule.<name>.ignore` (which plumbing still honors), names the output format, and ends revisions with `--`. `DIFF_PREFIX_OVERRIDES` is gone. - `src/git/repository/integration.rs`: `has_added_changes_by_sha` runs `diff-tree --quiet --ignore-submodules=none`, and both sides of the patch-id squash-merge check pass `--ignore-submodules=none` too. `BranchDiffSpec` carries a resolved `diff_base`, since `diff-tree` has no three-dot form and `--merge-base` refuses several merge bases. The empty tree follows the repository's object format, so a SHA-256 repository can make its first commit. - `HEAD±`, `main…±`, `changed_files`, `unmerged_paths`, the picker's per-commit log stats, and the commit, squash, and merge diffstats read plumbing too, all with `--ignore-submodules=none`. `main…±` sparse pathspecs now resolve from the repository root. Two related fixes where user config meets the commands that write: - `wt merge --no-ff` builds its merge commit with `commit-tree`, which ignores `commit.gpgSign`; it now passes `--gpg-sign` when that setting is on. - `wt step rebase` and `wt merge` pass `--no-update-refs`, so `rebase.updateRefs` can't move other branches stacked in the rebased range. `wt step diff` stays porcelain (`TempIndex::stream_diff`), because it shows the user git's own view. `has_staged_changes` and the `git status` checks are untouched, since #4112 rewrites them. ## Behavior users can see Picker diff previews, LLM summaries, and prompts no longer follow `diff.external`, `diff.noprefix`, or `color.ui = always`. `main…±` detects renames whatever `diff.renames` says. ## Testing New tests cover branch deletion under `diff.relative`, a submodule bump under `submodule.<name>.ignore = all` (including a squash merge that dropped or carried the bump), prompts under hostile diff config, a `main/` directory beside the `main` target, criss-cross merge bases, intent-to-add entries and renames in the fallback commit message, a SHA-256 first commit, sparse checkout from a subdirectory, a signed `--no-ff` merge, and a stacked branch under `rebase.updateRefs`. Each fails with its fix reverted, except the criss-cross case, whose fix removed the code path it would revert to. `diff_tree_failure_is_command_error` covers the integration check's error branch. Running the suite with a hostile global gitconfig swapped into the test harness leaves only the harness's own porcelain assertions and intended display output failing. > _This was written by Claude Code on behalf of max-sixty_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Thanks @Duang777 for the cap on the auto-staging file list. #4152 takes that piece of this PR on its own, with you credited as co-author, since Once #4152 lands, the listing truncation here will conflict with it and can come out. For the rest of this PR, splitting it into smaller PRs with one change each, as asked above, would make each piece reviewable.
|
#4111 switched the status read behind the auto-staging warning to `-uall`, so the warning sees untracked files that `status.showUntrackedFiles=no` hides. That flag also expands every untracked directory into its files, so `wt step commit`, `wt step squash`, and `wt merge` listed 300 paths for a directory of 300 new files before committing. The warning now reads git's `normal` untracked mode instead. It still overrides `status.showUntrackedFiles=no`, but names a wholly untracked directory once as `dir/`, so a directory of generated files takes one row and can't push a separate new file out of view. The file count stays visible in the commit progress line that follows. The listing also stops at ten rows. Past ten paths it lists nine and a hint counts the rest, so the hint always stands for at least two paths: ``` ▲ Auto-staging 11 untracked paths: ┃ 0-generated/ ┃ 01.txt ┃ … ┃ 08.txt ↳ … and 2 other paths ◎ Generating commit message and committing changes... (310 files) ``` The status read also moves into the warning itself. Commit and squash each built that command before calling the warning, one through `WorkingTree` and one through `Repository`; both now go through the `WorkingTree` they stage. Thanks to @Duang777, whose #4112 proposed the cap. They're credited as co-author. > _This was written by Claude Code on behalf of max-sixty_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: DDL <duanjialing.777@bytedance.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Addressing the remaining review threadsThe two open threads on
New advisory run on SHA |
|
Both threads land — mechanism notes are on each. The blocker is upstream of the code: no CI has run on the marker revert.
A rebase onto
|
There was a problem hiding this comment.
9afb90e7 takes the "confirm the removal actually succeeded" option from the last round's run_pipeline.rs thread, and the shape holds: the marker is deleted only on a git worktree remove that exited 0, it is created only when the frozen plan has post-remove hooks for that worktree, and the 24-hour sweep answers the unbounded accumulation the first marker design had. Resolving both open threads.
The rebase blocker is covered in the comment above, so only the extra data point here: on the branch as it stands, cargo clippy --all-targets is clean and the new unit and integration tests pass locally, test_remove_failed_background_fallback_does_not_run_post_remove included. The missing matrix is hiding the conflict, not a failure in this code.
Two findings inline. One more has no line to attach to: "removal-markers" is spelled out independently at both ends of the mechanism — create_removal_completion_marker in handlers.rs and sweep_stale_removal_markers in process.rs — while REMOVAL_MARKER_PREFIX, which handlers.rs already imports from process.rs, is shared. If those two literals ever drift, the sweep stops finding markers and they accumulate for the life of the repo, which is the failure this commit exists to prevent. A sibling const beside the prefix closes it the way PROMOTE_STAGING_DIR does for wt step promote; happy to push that.
The merge verdict stays with @max-sixty, per the earlier passes on this PR.
reposting! |
|
Splitting this is awkward at commit granularity, so here is where the seams actually are, measured on the current head. The diff is +4,301 / −844 across 47 files, but roughly 1,550 of the additions are production lines — the rest are tests. At the 100-line bar that is about ten pieces, and the pieces are separable even though the history is not:
Counts exclude tests and are approximate where one file spans two rows. Two of these are free-standing and cost nothing to lift out first. 1 takes only a The Happy to open the first one or two as PRs with @Duang777 credited as co-author, the way #4152 went, if that is more useful than describing them. |
9afb90e to
44d82c9
Compare
|
Rebased the branch onto current The rebase drops this PR’s duplicate auto-staging list implementation and keeps #4152 as the source of that behavior: untracked directories stay collapsed, and listings cap at nine paths plus the omitted count. The byte-safe path handling and the post-stage TOCTOU disclosure remain. The three removal-marker findings are addressed on this head:
Local |
|
please reopen after reading #4112 (comment) |
|
Closed before I finished checking the rebased head — recording what's verified on All three removal-marker findings land:
The rebase is onto The matrix ran for the first time since Every review thread on the PR is resolved, though the |
|
The
$ wt step prune --yes --min-age=0s
◎ Running pre-remove project hook @ …/repo.merged
git worktree lock --reason hook .
○ Worktree preserved (locked: hook)
○ No merged worktrees to remove
The "Worktree preserved (locked: …)" line is built four times (seams 9 and 10).
Repro for the first findingBuilt from git init -b main repo && cd repo
git commit --allow-empty -m init
mkdir -p .config && printf 'pre-remove = "git worktree lock --reason hook ."\n' > .config/wt.toml
git add .config && git commit -m hooks
git worktree add ../repo.merged -b merged
git commit --allow-empty -m advance
wt step prune --yes --min-age=0s
wt step prune --yes --min-age=0s --format=json # []Nothing else new surfaced: the picker's mutation-replay lock order is consistent across all four publication paths, |
|
I read the size feedback and have started splitting the branch instead of reopening the full 4,200-line diff. The first independent piece is #4190: one file, 19 additions and 2 deletions, covering only worktree-side rename/copy parsing in porcelain status output. I will keep the remaining pieces separate. |
## Summary - Distinguish `diff-index` exit code 1 from command failures in `has_staged_changes`. - Build the check through `PlumbingDiff::Index` so user diff and submodule-ignore configuration cannot hide staged changes. - Keep intent-to-add entries invisible, matching `git diff --cached`. - Add focused regressions for command errors, nested discovery paths, ignored gitlinks, and intent-to-add entries. This is the next independent staged-change safety fix extracted from #4112 after the request to split that PR. ## Verification - Red check: the command-error and nested-discovery tests both fail with the old implementation. - `cargo test --lib has_staged_changes` - `cargo fmt --all -- --check` - `cargo clippy --all-targets --all-features -- -D warnings` - `cargo run -- hook pre-merge --yes` (4,937 passed, 1 skipped)
Follow-up to #4111.
Problem
The original fix protected untracked files hidden by
status.showUntrackedFiles, but related Git display settings and race windows could still produce unsafe or misleading decisions:submodule.<name>.ignoreordiff.ignoreSubmodules;--stage=trackedand--stage=nonecould make merge commit and cleanup decisions from state they would not actually stage;git add -Acould be committed without disclosure;pre-removehook locking the worktree;R/Cstatuses.Fix
StageMode::{All, Tracked, None}exactly.\xNN.pre-remove.Testing
Added red/green coverage for hidden untracked files, dirty submodules, staged gitlinks, all stage modes, approval and staging TOCTOU windows, worktree lock transitions, terminal control characters, non-UTF-8 paths, warning truncation, relocate disclosure, and worktree-side rename/copy records.
Review focus
This changes destructive cleanup and index-mutation safety paths. A focused data-preservation review of merge/remove race handling and StageMode semantics would be useful.