Repository navigation
fix(git): surface staged diff errors - #4192
Conversation
worktrunk-bot
left a comment
There was a problem hiding this comment.
All three fixes reproduce against git 2.55 in a scratch repo, and the submodule.<name>.ignore one is the sharpest: with ignore = all registered for a submodule, a staged gitlink bump makes git diff --cached --quiet exit 0, so wt step commit bails with "Nothing to commit" on a change that really is staged. One design point below; the two concrete gaps are inline.
The flags re-derive hardening PlumbingDiff already supplies. has_staged_changes stays on porcelain git diff --cached and pins --no-relative and --ignore-submodules=none by hand. The repo's standing answer to this class is plumbing — CLAUDE.md, under Command Execution Principles → Plumbing for Output wt Consumes, names this exact incident ("diff.relative once made wt remove delete an unmerged branch"), and PlumbingDiff::args puts --ignore-submodules=none on every plumbing diff with test_plumbing_diffs_are_built_by_plumbing_diff rejecting a hand-spelled one. That guard matches on the literals "diff-index", "diff-tree", and "diff-files", so it cannot see this call site: nothing fails if a later edit drops either flag.
The plumbing form is PlumbingDiff::Index.args(&["--cached", "--ita-invisible-in-index", "--quiet", "--end-of-options", &base]) with base = self.index_base()? — the same diff-index --cached --ita-invisible-in-index that prepare_diff's DiffSides::Staged arm builds. That makes the predicate agree with the diff commit_staged_changes renders one line later by construction, rather than by keeping two flag lists in step, and --no-relative disappears entirely because plumbing ignores diff.relative. --ita-invisible-in-index is load-bearing there, not decoration: without it, diff-index --cached reports an add -N entry as a change where git diff --cached does not, which would flip the predicate on an intent-to-add-only index.
The one cost I can find is a fork: index_base() goes through head_sha, which is deliberately uncached. Unborn HEAD isn't a cost — diff-index --cached --quiet HEAD does exit 128 there, but against index_base()'s empty-tree fallback it returns 1, which I checked. So if unborn HEAD is what kept you on porcelain, it's covered; if it was the extra fork, that's a defensible call and worth recording in the doc comment.
worktrunk-bot
left a comment
There was a problem hiding this comment.
#4193 rewrites has_staged_changes in the same hunk. Its version keeps porcelain git diff --cached --quiet --exit-code and adds the same Some(0) / Some(1) / _ => CommandError::from_failed_output match, plus a has_staged_changes_distinguishes_diff_from_a_real_git_failure test that corrupts the index the way has_staged_changes_surfaces_git_errors does here. So the two will conflict whichever lands first, and this PR is the superset — it closes the exit-code overload and the diff.relative, submodule.<name>.ignore, and intent-to-add gaps porcelain leaves open, which makes the has_staged_changes commit in #4193 the one to drop. Raising it for merge order, not as a change to make here.
Porcelain → plumbing parity, for whoever settles that order
Checked on git 2.55.0, comparing git diff --cached --quiet against diff-index --ignore-submodules=none --cached --ita-invisible-in-index --quiet --end-of-options <base> in a scratch repo. The two agree everywhere the swap could have moved the answer:
| State | porcelain | plumbing |
|---|---|---|
| Unborn HEAD, nothing staged | 0 | 0 (vs. empty tree) |
| Unborn HEAD, one staged add | 1 | 1 (vs. empty tree) |
Conflicted index (UU entry) |
1 | 1 |
HEAD unresolvable (ref: refs/heads/) |
1 | 1 (vs. empty tree) |
That last row is the one worth recording: index_base() reaches HEAD through head_sha(), which maps a failed rev-parse to None and falls back to the empty tree. Git's own diff --cached treats an unresolvable HEAD as unborn and exits 1 too, so the fallback does not turn a broken HEAD into a silent "has staged changes" that porcelain would have caught — it is the same answer either way. A HEAD that resolves to a corrupt commit object does surface: rev-parse succeeds, and diff-index against that SHA exits 128 into the new error arm.
The three guards are each load-bearing under the new tests: dropping --ita-invisible-in-index turns has_staged_changes_ignores_intent_to_add_entries red, and the other two are pinned by has_staged_changes_checks_whole_worktree_from_nested_discovery_path and has_staged_changes_sees_staged_gitlink_behind_submodule_ignore.
#4192 makes the same change with more coverage (git errors, nested discovery under diff.relative, intent-to-add, ignored gitlinks), so this PR keeps only the prompt-test repair. Also corrects the capture-path comment in the generation-prompt test, which described a shared command the loop doesn't use. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…tdout (#4207) `test_show_prompt_ignores_diff_display_config` was meant to pin that the LLM prompt ignores `color.ui`, `diff.external`, and `diff.noprefix`. It compared `--show-prompt` stdout, which `.output()` pipes, so anstream stripped ANSI and the `color.ui = always` case passed whatever the diff code did. Now `test_generation_prompt_ignores_diff_display_config`, it points `commit.generation.command` at a script that saves its stdin, and compares the bytes the LLM command actually receives for `wt step commit --dry-run` and `wt step squash --dry-run`. It asserts no escape byte directly and checks the prompt matches the default-config prompt byte for byte. The branch-summary prompt had no such test; `picker/summary.rs` gets the same check. With `PreparedDiff::capture` reverted to porcelain, both tests fail on the escape-byte assertion. An earlier version of this PR also moved `has_staged_changes` onto plumbing; #4192 makes that change with more coverage, so it's dropped here. > _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>
…ges don't abort the squash (#4303) #4192 (unreleased) made `has_staged_changes` see a staged submodule pointer hidden by `submodule.<name>.ignore=all`, which sends such a squash into the safety backup. The backup used `git stash create`, which honors that ignore setting, saw nothing, and returned an empty SHA, so `wt step squash` and a default-squash `wt merge` aborted with `✗ git stash create returned empty SHA - no changes to backup`. In 0.79.0 a multi-commit squash in that setup succeeded (leaving the pointer out), so this would ship as a regression. The 0.80.0 changelog verification found it. The backup now snapshots the index with plumbing — `write-tree`, then `commit-tree --no-gpg-sign <tree> -p HEAD`, recorded in `refs/wt-backup/<branch>`'s reflog as before — so no config hides part of it. By the time the backup runs, squash has already staged per its stage mode, so the index is exactly what the squash absorbs; unstaged and untracked files, which the squash leaves in place, are no longer in the backup. `--no-gpg-sign` keeps the backup from prompting for a signature, as `stash create` never signed. `test_step_squash_backs_up_submodule_bump_hidden_by_ignore` covers the two-commit, one-commit, and commits-cancel-out shapes, each with a hidden staged pointer: all three failed with the stash error before, and now squash including the pointer, with the backup's tree equal to the squash tree and its parent the pre-squash tip. Nine snapshots change only on their `Backup created @ <sha>` line. > _This was written by Claude Code on behalf of max-sixty_ Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Summary
diff-indexexit code 1 from command failures inhas_staged_changes.PlumbingDiff::Indexso user diff and submodule-ignore configuration cannot hide staged changes.git diff --cached.This is the next independent staged-change safety fix extracted from #4112 after the request to split that PR.
Verification
cargo test --lib has_staged_changescargo fmt --all -- --checkcargo clippy --all-targets --all-features -- -D warningscargo run -- hook pre-merge --yes(4,937 passed, 1 skipped)