Skip to content

fix(git): close hidden status safety gaps - #4112

Closed
Duang777 wants to merge 15 commits into
max-sixty:mainfrom
Duang777:fix/close-hidden-status-safety-gaps
Closed

Duang777 wants to merge 15 commits into
max-sixty:mainfrom
Duang777:fix/close-hidden-status-safety-gaps

Conversation

@Duang777

@Duang777 Duang777 commented Sep 15, 2026 •

Copy link
Copy Markdown
Contributor

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 dirt could be hidden by submodule.<name>.ignore or diff.ignoreSubmodules;
  • --stage=tracked and --stage=none could make merge commit and cleanup decisions from state they would not actually stage;
  • a file created between the pre-stage status query and git add -A could be committed without disclosure;
  • cleanup eligibility could change after the target ref moved, including a pre-remove hook locking the worktree;
  • porcelain rename/copy parsing only recognized index-side R / C statuses.

Fix

  • Override user display configuration in destructive dirty checks and staged-gitlink detection.
  • Make merge auto-commit and cleanup decisions match StageMode::{All, Tracked, None} exactly.
  • Derive auto-staged additions from the before/after index delta, with bounded terminal-safe output that preserves non-UTF-8 bytes as \xNN.
  • Freeze the removal disposition before updating the target ref and preserve a worktree locked by pre-remove.
  • Parse rename/copy markers from both porcelain status columns.
  • Route merge, step commit, step squash, and relocate through the shared staging disclosure path.

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.

cargo run -- hook pre-merge --yes
4876 tests passed, 1 skipped
lockfile, formatting, Clippy, doctest, rustdoc, and snapshot checks passed

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.

@Duang777

Copy link
Copy Markdown
Contributor Author

@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 pre-remove lock handling? The full local pre-merge gate passed; GitHub CI and automated review are now running.

@worktrunk-bot worktrunk-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/commands/merge.rs Resolved
Comment thread src/commands/commit.rs Resolved
Comment thread src/git/parse.rs Resolved
Comment thread src/git/parse.rs Resolved
@Duang777
Duang777 force-pushed the fix/close-hidden-status-safety-gaps branch 2 times, most recently from d1f27d9 to ccfb7c0 Compare September 15, 2026 08:54
@Duang777

Copy link
Copy Markdown
Contributor Author

@worktrunk-bot Thanks; ccfb7c0 addresses all four inline suggestions, updates the finish.rs lifecycle documentation, and adds coverage for no-reason hook locks plus post-stage inspection failure. The full local gate now reports 4,875 passed and 1 skipped; the new GitHub checks are in progress.

@worktrunk-bot

Copy link
Copy Markdown
Collaborator

Confirmed — all four inline suggestions landed verbatim at ccfb7c084, and the coverage gate is now green.

codecov/patch reports 99.08% of diff hit against the 98.29% target, up from the 97.61% that was failing on 1afa1262. Both lines I flagged as cheap to close are gone: the unreachable!() arm no longer exists (should_auto_commit now matches StageMode once and early-returns has_staged_changes() for None), and the new #[case]("git worktree lock .", "Worktree preserved (locked)") exercises the no-reason branch in finish.rs. test_step_commit_preserves_staged_changes_when_post_stage_inspection_fails also reaches one of the six subprocess-failure arms I'd written off as having no deterministic trigger — the argv-matching wrapper script is a better lever for those than I gave it credit for.

Two verification notes on the rest:

  • The --no-relative hardening updated both exact-argv case patterns in the test wrappers alongside the query, so the wrappers still intercept what they intend to rather than silently falling through to real git.
  • The finish.rs module docstring now describes the frozen disposition, the pre-existing-lock vs. hook-created-lock split, and handle_remove_output_after_pre_remove — it matches the function again.

test (linux), test (macos), and test (windows) all pass on this head. The merge verdict on the data-loss surface is still @max-sixty's.

@worktrunk-bot worktrunk-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/commands/merge.rs Resolved
Comment thread src/commands/merge.rs Resolved
Comment thread src/commands/commit.rs Resolved
@Duang777
Duang777 force-pushed the fix/close-hidden-status-safety-gaps branch from ccfb7c0 to 46453ef Compare September 15, 2026 09:29

@worktrunk-bot worktrunk-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/commands/worktree/finish.rs Resolved

@worktrunk-bot worktrunk-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/commands/picker/prs.rs Resolved
Comment thread src/output/handlers.rs Resolved
@worktrunk-bot

worktrunk-bot commented Sep 15, 2026 •

Copy link
Copy Markdown
Collaborator

codecov/patch is red on d8c7f2dff — 97.77% of diff hit against the 98.29% target — and per CLAUDE.md → Coverage that gates the merge. This landed after the review above, so folding the numbers in here. From the codecov compare API, 28 added lines are unhit:

File Lines What
src/commands/run_pipeline.rs 168-174, 180-182 the symlink_metadata error arm and the REMOVAL_WAIT_TIMEOUT bail in wait_for_worktree_removal
src/commands/picker/mod.rs 401, 652-654, 671, 4649-4652 do_removal's kept-branch arm, the morph path's Err arm, and the deadline loop inside the new test
src/output/handlers.rs 1925, 1936, 2049, 2084 three ? arms plus None => "Worktree preserved (locked)"
src/commands/remove.rs 273, 282, 290 execute_removal_plan's branch-only fallthrough, the hook-failure ?, and the --force arm — all under --reap
src/commands/worktree/finish.rs 197, 275 ? arms
src/commands/merge.rs, src/git/repository/working_tree.rs 181, 1041 command-failure arms
src/utils.rs 105 the '\u{2066}'..='\u{2069}' isolate range

Roughly five of those are cheap: handlers.rs:2084 is the no-reason lock under wt remove, which a second #[case] on test_pre_remove_hook_lock_preserves_worktree reaches the same way last round's "git worktree lock ." case reached the merge one; remove.rs:273/290 are wt remove --reap against a branch-only target and against --force; and utils.rs:105 wants one isolate character added to escape_text_for_terminal_escapes_controls_and_bidi. Clearing the target needs about seven, so that alone is tight.

The largest single block is the seven lines in wait_for_worktree_removal, and its timeout arm has no trigger short of a 300-second wait. That is the same machinery the review flags as independent of this PR's subject: split out, it carries its own coverage argument instead of costing this patch a third of its misses, and what remains here is mostly ? propagation arms with no deterministic trigger — the arithmetic worth handing over rather than contorting tests around.

@max-sixty

Copy link
Copy Markdown
Owner

@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...

@max-sixty

Copy link
Copy Markdown
Owner

@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 worktrunk-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.txt

diff.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.txt

submodule.<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
sub

ls-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.txt

That 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.

Comment thread src/git/repository/working_tree.rs Resolved
Comment thread src/output/handlers.rs Resolved
@worktrunk-bot

Copy link
Copy Markdown
Collaborator

Retracting the coverage gate I left standing: codecov/patch is green on 21b5bb210 — 98.83% of diff hit against the 98.29% target, up from the 97.77% that was failing on d8c7f2dff, with codecov/project at 98.31% (+0.01%). The two blocks I'd written off as having no deterministic trigger are covered from the other side after all: removal_completion_does_not_depend_on_the_old_path and removal_completion_reports_marker_inspection_errors reach the wait loop's poll and error arms, and test_do_removal_branch_only_keep_skips_deletion plus test_apply_reverts_morph_when_removal_fails reach the picker arms. So the arithmetic I handed over is moot — no coverage decision is outstanding.

test (linux), test (macos), and test (windows) all pass on this head. The merge decision is unaffected by any check; it rests on the review and on the size question above.

@worktrunk-bot worktrunk-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/commands/run_pipeline.rs Resolved
Comment thread src/commands/hooks.rs Resolved
max-sixty added a commit that referenced this pull request Sep 16, 2026
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)
max-sixty added a commit that referenced this pull request Sep 16, 2026
…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>
@max-sixty

Copy link
Copy Markdown
Owner

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 -uall from #4111 made a directory of 300 new files print 300 paths. It differs in one detail: past ten paths it lists nine and a hint counts the rest (… and 291 other paths), so the hint always stands for at least two paths.

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.

This was written by Claude Code on behalf of max-sixty

max-sixty added a commit that referenced this pull request Sep 16, 2026
#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>
@Duang777

Copy link
Copy Markdown
Contributor Author

Addressing the remaining review threads

The two open threads on run_pipeline.rs:163 and hooks.rs:356 both flagged the same issue: 57f3df26 switched the completion signal from a success-gated marker to the git metadata directory, but git worktree remove deletes worktrees/<id> before attempting physical directory deletion — so the wait would release post-remove against a worktree that is still on disk, exactly the failure mode the legacy fallback exists for.

ef72d948c reverts to the marker approach with two improvements:

  • The marker is only created when ApprovedHookPlan actually contains post-remove hooks, so there is no artifact for the common (no-hooks) case.
  • Marker deletion stays && rm -f, chained to git worktree remove exit status — post-remove only runs on full removal success.
  • A 24-hour stale-marker sweep handles the detached-process-crash case.

New advisory run on SHA 9afb90e72 exercises the failure path: test_remove_failed_background_fallback_does_not_run_post_remove pins that post-remove stays absent when git worktree remove reports failure.

@worktrunk-bot

Copy link
Copy Markdown
Collaborator

Both threads land — mechanism notes are on each. The blocker is upstream of the code: no CI has run on the marker revert.

ef72d948c and 9afb90e72 have no ci, affected, or coverage run; the only check on the head is the in-flight tend-review. #4152 merged on 2026-09-16, so the PR was already conflicting when these were pushed and GitHub could not build the merge ref — pre-commit.ci reports "error during mergeable check", and the pull_request-triggered workflows were never created. The last full green matrix on this branch is 05ff5ee0f, two commits back, so test_remove_failed_background_fallback_does_not_run_post_remove has been exercised locally and nowhere else.

A rebase onto main brings the matrix back. Twelve paths conflict:

  • src/commands/commit.rs — the piece fix(commit): collapse and cap the auto-staging untracked listing #4152 took over; per @max-sixty above, the listing truncation here is the part to drop rather than merge
  • src/cli/mod.rs and tests/snapshots/integration__integration_tests__help__help_remove_long.snap — remove's help text and its rendered snapshot
  • faq.md and remove.md under docs/src/content/docs/, skills/worktrunk/reference/, and plugins/worktrunk/skills/worktrunk/reference/ — six files, of which only docs/src/content/docs/faq.md is hand-edited; the other five are generated from it and from src/cli/mod.rs
  • src/commands/repository_ext.rs, src/commands/step/squash.rs, tests/integration_tests/merge.rs

@worktrunk-bot worktrunk-bot left a comment •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/commands/process.rs Resolved
Comment thread tests/integration_tests/remove.rs Resolved
@max-sixty

Copy link
Copy Markdown
Owner

@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...

reposting!

@worktrunk-bot

Copy link
Copy Markdown
Collaborator

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: 00f1701cb ("fix(remove): preserve hook-locked worktrees safely") carries 753 of the 946 added lines in picker/mod.rs, and 46453efa3 bundles the repository_ext.rs → commit.rs move in with the status-flag hardening. So git cherry-pick per commit will not produce the split — several seams run below commit boundaries.

# Change Prod lines Needs
1 Deferred-removal completion signalling — marker file, &&-chained deletion, 24-hour sweep (run_pipeline.rs, process.rs, hooks.rs) ≈150 —
2 Keep post-switch off the removal wait 8 1
3 Terminal-safe path rendering — control chars, bidi isolates, non-UTF-8 as \xNN (utils.rs) ≈70 —
4 Parse rename/copy markers from both porcelain columns (git/parse.rs) ≈10 —
5 Override user display config in destructive dirty checks (working_tree.rs) ≈30 —
6 One shared staging-disclosure path — lift warn_if_auto_staging_untracked out of repository_ext.rs, route merge / step commit / step squash / relocate through stage_with_untracked_warning ≈90, mostly a move 3, 4
7 StageMode::{All,Tracked,None} decides merge auto-commit and cleanup exactly (merge.rs) ≈75 5
8 Freeze the removal disposition before the target ref moves (finish.rs, handlers.rs, worktree/types.rs) ≈300 —
9 Preserve a pre-remove-locked worktree consistently across wt merge and wt remove (remove.rs, git/error.rs) ≈150 8
10 Picker: restore the row when a hook preserves the worktree ≈200 9
11 Picker: reconcile rows published during removal, dedupe PRs after mutation replay ≈230 —

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 &Repository, has been through three design rounds of its own on this branch (ce6f84f55 → 57f3df263 → ef72d948c), and is the machinery the review already called independent of this PR's subject. 11 is a picker bug fix that touches none of the removal work. Both are also cheaper to land now than later: the branch is conflicting with twelve paths and the last two commits have no CI at all, so rebasing one small branch beats rebasing all of it.

The commit.rs listing truncation drops out against #4152 rather than landing in any of these.

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.

@Duang777
Duang777 force-pushed the fix/close-hidden-status-safety-gaps branch from 9afb90e to 44d82c9 Compare September 19, 2026 14:57
@Duang777

Copy link
Copy Markdown
Contributor Author

Rebased the branch onto current main (a6e2bed4b) and force-pushed the reconciled head as 44d82c923.

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:

  • stale sweeps leave the shared marker directory in place;
  • marker creation and sweeping share REMOVAL_MARKER_DIR;
  • the failed-removal test asserts that a marker exists before releasing the blocked post-remove pipeline.

Local cargo run -- hook pre-merge --yes passed on the final rebased head: 4,969 tests passed, 1 expected test skipped, with fmt, Clippy, lockfile, doctest, rustdoc, snapshots, and docs sync all clean. GitHub CI is now running. The data-safety verdict remains with @max-sixty.

@max-sixty

Copy link
Copy Markdown
Owner

please reopen after reading #4112 (comment)

@max-sixty max-sixty closed this Sep 19, 2026
@worktrunk-bot

Copy link
Copy Markdown
Collaborator

Closed before I finished checking the rebased head — recording what's verified on 44d82c923 so the split pieces don't have to re-derive it.

All three removal-marker findings land:

  • sweep_stale_removal_markers deletes entries and returns, with no trailing fs::remove_dir — matching sweep_stale_trash, and closing the window where a concurrent wt remove's sweep landed between create_dir_all and tempfile_in and aborted a removal with ENOENT.
  • REMOVAL_MARKER_DIR sits beside REMOVAL_MARKER_PREFIX, and create_removal_completion_marker imports it rather than respelling "removal-markers", so the two ends can't drift into markers that accumulate for the life of the repo.
  • test_remove_failed_background_fallback_does_not_run_post_remove asserts the marker directory is non-empty while post-remove is withheld, before deleting the markers and waiting for the hook — so a regression that stopped creating the marker fails that assertion rather than passing for the wrong reason.

The rebase is onto main's exact tip (a6e2bed4b) with no commits missing, and the #4152 reconciliation is as described: this branch's own listing truncation is gone from commit.rs, the ten-row cap and -unormal directory collapsing come from warn_about_untracked_paths as #4152 wrote them, and .git/wt/removal-markers/ is inventoried in the FAQ's What files does Worktrunk create? table.

The matrix ran for the first time since 05ff5ee0f. At the time of closing, test (linux), check-docs, lint, feature-check, and code-coverage were green, codecov/patch at 98.60% against a 98.32% target and codecov/project at 98.33% (+0.00%); test (macos) and test (windows) were still in flight.

Every review thread on the PR is resolved, though the tend-review run on this head hadn't finished when it closed. The seam breakdown is the part that carries over — pieces 1 (deferred-removal marker signalling) and 11 (the picker row reconciliation) are free-standing and cheapest to lift out first.

@worktrunk-bot

Copy link
Copy Markdown
Collaborator

The tend-review pass that was still running when this closed finished with three findings. Recording them here rather than as a review, since they belong to the split pieces rather than to this branch.

wt step prune loses the preservation outcome wt remove reports (seam 9). try_remove returns Ok(None) for a hook-preserved candidate, which is the value a genuine no-op returns, so the candidate reaches neither surface that reports what prune did. The run prints its own contradiction, because removed is empty and skipped_young / skipped_approval — the existing buckets for a candidate deliberately not removed — never saw it:

$ 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

--format=json is the sharper half: it prints [], where wt remove --format=json against the same worktree reports the outcome this branch added for exactly this case ("worktree_outcome": "preserved_locked"). A third bucket alongside skipped_young / skipped_approval suppresses the misleading line and gives prune's JSON somewhere to name the preservation.

The "Worktree preserved (locked: …)" line is built four times (seams 9 and 10). preserved_worktree_message is already the shared form; finish.rs writes it twice more (the MergeRemovalDisposition::Locked(Some(reason)) arm and the post-pre-remove lock check) and handlers.rs once in handle_removed_worktree_output. The duplicated part is the escape_text_for_terminal call, and is_bidi_control is a hand-listed range set — so a site that drops the call prints a project hook's lock reason verbatim, which is the spoofing the escaping exists to stop. worktree::types, beside RemovalOutcome::PreservedLocked, is where it wants to live.

untracked_files re-implements the query half of warn_about_untracked_files (seam 6). The new helper is that function minus the warning. The split that produced warn_about_untracked_paths left the git status --porcelain -z -unormal call behind, so the query now exists twice, spelled two ways, with the same parser behind each. Landing this one in repository_ext.rs and having warn_about_untracked_files call it leaves a single place that knows how to ask git the question.

Repro for the first finding

Built from 44d82c923 (wt v0.78.0-50-gaaf169f1b):

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, collect_published_branches keeps a shortcut entry for every data row so the PR dedup set doesn't shrink, and no cached generator changed, so no CACHE_EPOCH bump is owed.

@Duang777

Copy link
Copy Markdown
Contributor Author

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.

max-sixty pushed a commit that referenced this pull request Sep 21, 2026
## 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)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants