Skip to content

fix(git): surface staged diff errors - #4192

Merged
max-sixty merged 2 commits into
max-sixty:mainfrom
Duang777:fix/propagate-staged-diff-errors
Sep 21, 2026
Merged

max-sixty merged 2 commits into
max-sixty:mainfrom
Duang777:fix/propagate-staged-diff-errors

Conversation

@Duang777

@Duang777 Duang777 commented Sep 20, 2026 •

Copy link
Copy Markdown
Contributor

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)

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

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.

Comment thread src/git/repository/working_tree.rs Resolved
Comment thread src/git/repository/working_tree.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.

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

max-sixty added a commit that referenced this pull request Sep 21, 2026
#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>
@max-sixty
max-sixty merged commit efa59ee into max-sixty:main Sep 21, 2026
40 checks passed
max-sixty added a commit that referenced this pull request Sep 21, 2026
…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>
max-sixty added a commit that referenced this pull request Sep 27, 2026
…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>
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