Files
Maximilian Roos f5d99d4fe5 fix(step): refuse to commit unresolved conflicts; one gutter symbol for every operation (#3579)
Two follow-ups from #3558, which stopped `wt merge` and `wt step rebase`
from running out of a half-finished operation. Probing the sibling
commands for the same root cause found one that was worse, and the
symbol work is what that PR's detection change made possible.

## `wt step squash` committed conflict markers

Mid-merge, invoked directly, it didn't refuse. It generated a commit
message for the unresolved markers and committed them:

```console
$ git merge side
CONFLICT (content): Merge conflict in f
$ wt step squash --yes
◎ Generating commit message and committing changes... (1 file, +6, no squashing needed)
  Merge branch 'side'

  This resolves a merge conflict between main and side branches.
✓ Committed changes @ e6262c6
$ git show HEAD:f
<<<<<<< HEAD
main
||||||| 56e6eb3
base
=======
side
>>>>>>> side
```

`MERGE_HEAD` is gone and the working tree is clean, so the broken merge
reads as complete. `wt merge` was never exposed — its own gate stops it
before it reaches squash — so this was the direct-invocation path only.

## The index is what knows

The obvious fix is the gate #3558 added, and it is not sufficient. The
hazard is narrower than "an operation is open", and also wider.

`git add -A` collapses an unmerged path's three index stages into one
entry. That resolves the conflict as far as the index is concerned,
while `<<<<<<<` is still on disk — and it takes with it git's own
refusal to commit an unmerged index, which is what would otherwise have
stopped this. So the exposure is exactly the commands that stage on the
user's behalf, and it does not need an operation to be open at all:

```console
$ git stash pop
CONFLICT (content): Merge conflict in f
$ ls .git/MERGE_HEAD
ls: .git/MERGE_HEAD: No such file or directory
$ wt step commit --yes
✓ Committed changes @ 1f5387c        # markers and all
```

A conflicted `git stash pop` leaves unmerged paths with no state file
written, so no reading of `.git/` can see it.
`WorkingTree::ensure_no_unmerged_paths` reads the index instead — `git
diff --diff-filter=U`, the same question `git commit` asks — and both
staging paths call it before staging:

```console
$ wt step commit
✗ Cannot commit: 1 path with unresolved conflicts
   ┃ f
```

The paths are worth carrying where the operation refusal carried
nothing: they are the one thing the user needs and git isn't being
asked.

`wt step squash` additionally takes the operation gate, ahead of its
branch check, so mid-rebase it names the open rebase rather than blaming
the detached HEAD and offering `git switch <branch>` — the one command
that throws the rebase away, which is the same wrong remedy #3558
removed from `wt merge`.

Both guards run before the pre-commit hooks and the LLM call. Neither is
worth running for a commit that can't happen, and a refused commit runs
no project commands — checked with a `pre-commit = "touch HOOK_RAN"`
project config that never fires.

`wt step commit --dry-run` is deliberately not gated. It mutates
nothing, and guarding it displaced
`test_step_commit_dry_run_propagates_git_add_failure`, which pins a
distinct error path; a tested behavior is worth more than cosmetic
parity.

## One symbol for every operation

The Status column had `⤴` for rebase and `⤵` for merge, and nothing for
the other three states git can leave open. A worktree stopped
mid-cherry-pick, mid-revert, or mid-bisect rendered as idle — including
the case #3558 closed, a multi-commit cherry-pick whose stop was
resolved with `git commit`, which leaves a clean tree, no
`CHERRY_PICK_HEAD`, and only the queued sequencer to say anything is
wrong.

Three more glyphs was the obvious fix and the wrong one. What the reader
does about any of the five is identical: run `git status`, then finish
or abort it. Splitting the column across a glyph per operation asked
them to distinguish states that lead to the same next step, and the
split is what left the other three invisible. So they collapse to one:

```console
$ wt list
  Branch  Status  …  Message
@ main       ↻^   …  resolved by hand
```

`git status` names which operation it is, in git's own words — the same
division of labor as the refusal message. `--format=json` keeps the
identity the symbol drops, so a consumer that needs it still has it:
`operation_state` gains `cherry_pick`, `revert`, and `bisect` alongside
`rebase` and `merge`.

That retires `ActiveGitOperation`, which existed only to re-encode
`InProgressOperation` down to the two states the gutter knew.
`WorktreeData.git_operation` is now
`Option<Option<InProgressOperation>>` — outer `None` is "not loaded" —
matching its neighbour `has_working_tree_conflicts`. `GitOperationTask`
also stops swallowing errors: it reports a failed probe through the same
`ctx.error` channel every other task uses, rather than reporting "no
operation" for a probe that didn't answer.

## Verification

Three integration tests, each asserting HEAD is unmoved rather than only
matching the message:

- `test_step_squash_refuses_mid_merge` — the case that committed
markers.
- `test_step_commit_refuses_unmerged_paths` — driven from a conflicted
`git stash pop`, so it can only pass through the index read; the
operation check cannot see that state.
- `test_list_shows_symbol_for_bisect` — bisect because it is the only
operation that leaves HEAD on the branch and the tree clean, so nothing
else in the Status cell stands in for it. Snapshots pin both the symbol
and `"operation_state": "bisect"`.

Confirmed by hand against real repos: mid-merge, mid-rebase, and the
conflicted-stash-pop state all refuse; a mid-merge commit whose
conflicts *are* resolved still succeeds, as does a clean squash; a
queued cherry-pick and a stopped revert both render `↻` and name
themselves in JSON.

Full gate green (4500/4500 tests, lints, fmt, doc sync), plus `cargo
test --features shell-integration-tests` (2148/2148) and `cargo clippy
--all-targets --features shell-integration-tests`, which the gate
doesn't compile.

> _This was written by Claude Code on behalf of Maximilian_

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-24 16:33:49 -07:00
..