Skip to content

fix(rebase): surface Ctrl-C during rebase as interrupt, not conflict - #3539

Merged
max-sixty merged 6 commits into
mainfrom
nightly/rebase-interrupt-29899142288
Jul 23, 2026
Merged

fix(rebase): surface Ctrl-C during rebase as interrupt, not conflict#3539
max-sixty merged 6 commits into
mainfrom
nightly/rebase-interrupt-29899142288

Conversation

@worktrunk-bot

@worktrunk-bot worktrunk-bot commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator

Surfaced by the nightly code-quality survey; the fix layer was corrected after review, and the review discussion then drove the Interrupted rendering work (see below).

Problem

When a git rebase child is killed by a signal (SIGINT/SIGTERM) but leaves the worktree in REBASING state, handle_rebase classified the failure by worktree_state() alone (now REBASING) and returned GitError::RebaseConflict, printing conflict-resolution guidance and a non-130 exit code instead of a clean interrupt. This violates the signal-handling policy in CLAUDE.md: signal-derived child exits must be detected via the structured signal channel before any other classification.

The original attempt added an interrupt check to classify_rebase_failure, but it was inert on the real path: handle_rebase runs repo.run_command(["rebase", …]), which is Cmd::run() capture mode. On failure that returns a CommandError (via from_failed_output), never a WorktrunkError::ChildProcessExited, and the interrupt detector only matched ChildProcessExited. Worse, from_failed_output stored only output.status.code() (which is None for a signal kill) and dropped output.status.signal() entirely, so the signal number wasn't recoverable downstream at all.

A second problem fell out of the review discussion: every interrupt classification funneled into AlreadyDisplayed { exit_code: 128 + sig }, erasing the signal identity — so a SIGTERM-killed wt exited silently. Because wt traps SIGINT/SIGTERM to forward them to children, the shell never sees wt die and never prints its own Terminated line, so the standard shell convention was suppressed with nothing in its place. And an interrupt that left the worktree mid-rebase said nothing about the REBASING state it left behind (git ran in capture mode, so none of its output was ever shown).

Fix

The capture layer preserves the signal:

  • CommandError gains a signal: Option<i32> field, populated in from_failed_output from output.status.signal() on Unix (None elsewhere). A signal-killed child has no exit code, so the display summary names the signal instead: git rebase failed (signal 11).
  • ErrorExt::interrupt_signal() (né interrupt_exit_code()) walks the anyhow chain for a CommandError carrying SIGINT or SIGTERM, so it works through run_command's .context("Failed to execute: git …") wrapper.

Only SIGINT/SIGTERM classify as interrupts in capture mode. Capture children get no signal forwarding or escalation, so a death by any other signal (SIGSEGV, an OOM SIGKILL) is never wt's own doing, and a silent exit would discard the captured stderr the user never saw. Those stay on the visible error path: mid-rebase, the REBASING state classifies them as RebaseConflict, whose rendering carries the --abort hint; otherwise GitError::Other. Stream mode's ChildProcessExited branch counts any signal: its output already streamed to the terminal, and user-initiated kills are normalized upstream to the originating SIGINT/SIGTERM (the seen_signal branch in shell_exec, the concurrent runner's originating-signal override).

Interrupt exits are now typed and announced. A new WorktrunkError::Interrupted { signal, hint } replaces AlreadyDisplayed { exit_code: 128 + sig } at every interrupt-classification site (handle_command_error, for_each, classify_rebase_failure, run_custom's signal branch). It still exits 128 + signal, but renders once, at exit, per shell convention:

  • SIGINT: silent — the terminal already echoed ^C, and shells print nothing for an interrupted job.
  • SIGTERM: ✗ Terminated — the line the shell would have printed had wt not trapped the signal.
  • Any other signal (reachable from stream mode): ✗ Killed by signal N.

hint carries an optional recovery line rendered under the message. classify_rebase_failure uses it for the state an interrupt leaves behind:

◎ Rebasing onto main...
✗ Terminated
↳ Rebase onto main left in progress; to abort, run git rebase --abort

(For SIGINT the hint stands alone under the user's own ^C.)

Reachability caveat

The common terminal Ctrl-C case is subtler than the original description implied: on paths where wt has not installed its signal handler, a terminal Ctrl-C is delivered to the whole foreground group and terminates wt itself at 130 before classification runs. The capture-mode fix is reachable, and correct, when the signal reaches only the git child while wt survives (e.g. within wt merge after the handler is installed, a targeted kill, or a supervisor signalling the child), and it also closes the latent, broader gap where capture-mode signal deaths were classified without consulting the structured signal channel at all.

Tests

  • capture_mode_interrupt_is_not_classified_as_conflict feeds classify_rebase_failure the real CommandError-with-signal shape (wrapped in the .context(...) layer run_command adds) and asserts Interrupted { signal: 2, hint: Some(…) } with the git rebase --abort hint, not RebaseConflict.
  • capture_mode_crash_signal_stays_a_visible_error asserts a SIGSEGV death mid-rebase classifies as RebaseConflict and a SIGKILL death outside REBASING as Other, never a silent interrupt.
  • interrupt_signal_capture_mode_narrows_to_sigint_sigterm pins the capture-mode signal set: 2 and 15 classify, 9 and 11 don't.
  • from_failed_output_preserves_signal_and_interrupt_signal_recovers_it pins the capture layer directly: a signal-killed Output yields signal: Some(2) / exit_code: None, recovered through a .context(...) wrapper.
  • interrupted_renders_by_signal_and_exits_128_plus_signal pins the exit-time rendering: silent SIGINT, ✗ Terminated for SIGTERM, ✗ Killed by signal 9 otherwise, hint placement, and the 128 + signal exit codes.
  • test_interrupt_signal covers the stream-mode channel plus idempotence (an Interrupted error re-checked by an outer loop stays an interrupt).
  • Existing integration tests drive the paths end-to-end: test_pre_merge_pipeline_aborts_on_signal_exit (hook pipeline), test_for_each_aborts_on_signal_exit (worktree loop), run_custom_propagates_signal_exit_code (custom subcommands) — all now flowing through Interrupted.

@worktrunk-bot worktrunk-bot added the nightly-cleanup Issues found by nightly code quality sweep label Jul 22, 2026

@worktrunk-bot worktrunk-bot left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

This fix classifies the wrong error type, so it won't fire on the path it targets.

handle_rebase runs the rebase with repo.run_command(&["rebase", …]), which is Cmd::run() capture mode. On any non-zero/signal exit, run_command returns CommandError::from_failed_output(...) — see run_command in src/git/repository/mod.rs (return Err(super::error::CommandError::from_failed_output("git", args, &output).into())). It never produces a WorktrunkError::ChildProcessExited. Only Cmd::stream() constructs ChildProcessExited { signal } — and only stream() installs the signal forwarder and cmd.process_group(0); capture mode does neither.

interrupt_exit_code() only matches WorktrunkError::ChildProcessExited (self.downcast_ref::<WorktrunkError>() in src/git/error.rs). So in the real interrupt path e is a CommandError, interrupt_exit_code() returns None, and control still falls through to the is_rebasingRebaseConflict branch — the exact behavior the PR says it fixes.

Worse, the signal number can't even be recovered downstream as written: CommandError::from_failed_output stores exit_code: output.status.code(), which is None for a signal-killed child, and drops output.status.signal() entirely. So teaching interrupt_exit_code() to also inspect CommandError wouldn't help — the fix has to live at the capture layer (preserve status.signal() when building the CommandError, or route the rebase through a path that yields ChildProcessExited).

The unit test passes only because child_exit() fabricates a WorktrunkError::ChildProcessExited { signal: Some(2) } directly — an error shape run_command never returns for this path. The test green-lights a change that doesn't alter runtime behavior. A regression test that actually pins the bug would need to drive handle_rebase (or at least feed classify_rebase_failure the CommandError that run_command really produces on a signal exit) and observe that it's not surfaced as a RebaseConflict.

Flagging for @max-sixty since this touches the rebase/merge failure path. I haven't reproduced the end-to-end interrupt in CI (no TTY), but the error-type mismatch is verifiable from the code above — worth confirming the intended fix layer before this lands, since CI is green on an inert change.

Comment thread src/commands/step/rebase.rs Outdated
…tion actually fires

The prior fix checked interrupt_exit_code() in classify_rebase_failure,
but run_command (capture mode) returns a CommandError, not a
ChildProcessExited, and from_failed_output dropped status.signal()
entirely — so interrupt_exit_code() always returned None on the real
path and control still fell through to RebaseConflict.

Preserve status.signal() on CommandError (Unix) in from_failed_output,
and teach ErrorExt::interrupt_exit_code() to walk the anyhow chain for a
CommandError carrying a signal (128 + sig). This makes signal
classification correct for every capture-mode command, not just rebase,
per the structured-signal policy in CLAUDE.md.

Replace the regression test that fabricated a ChildProcessExited (a shape
run_command never returns for this path) with one that feeds the real
CommandError-with-signal, plus a capture-layer test asserting
from_failed_output preserves the signal and interrupt_exit_code recovers
it through a .context(...) wrapper.
@worktrunk-bot

Copy link
Copy Markdown
Collaborator Author

Confirmed — the review was right: the original change was inert on the real path. handle_rebase runs the rebase through repo.run_command, which is Cmd::run() capture mode and returns a CommandError (via from_failed_output), never a WorktrunkError::ChildProcessExited, so interrupt_exit_code() returned None and control fell through to RebaseConflict. And as flagged, from_failed_output dropped status.signal(), so the signal wasn't recoverable downstream at all.

Fixed at the capture layer, per the review's suggestion:

  • CommandError now carries signal: Option<i32>, set in from_failed_output from output.status.signal() (Unix; None elsewhere).
  • ErrorExt::interrupt_exit_code() also walks the chain for a CommandError with a signal → 128 + sig, so it survives run_command's .context("Failed to execute: git …") wrapper.

classify_rebase_failure is unchanged; its interrupt branch now actually fires. This also makes signal classification correct for every capture-mode command, not just rebase.

The regression test that fabricated a ChildProcessExited is replaced with one feeding the real CommandError-with-signal shape, plus a capture-layer test asserting from_failed_output preserves the signal and interrupt_exit_code() recovers 130 through a .context(...) wrapper — both fail against the previous code.

One honest caveat I added to the PR description: the common terminal Ctrl-C case is subtler than the original framing. Capture mode doesn't isolate the child in its own process group and wt installs no SIGINT handler here, so a terminal Ctrl-C hits the whole foreground group and kills wt at 130 before classification runs. This fix is reachable — and correct — when the signal reaches only the git child while wt survives (targeted kill -INT, a supervisor signalling the child), and it closes the broader latent gap where capture-mode signal deaths were classified without consulting the structured signal channel. Flagging that reachability point for @max-sixty in case it changes the disposition.

@worktrunk-bot worktrunk-bot left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

The capture-layer fix looks right and the tests now pin the real error shape. One consequence worth a conscious decision, since it's broader than rebase:

interrupt_exit_code() now maps any capture-mode signal death to Some(128 + sig), not just SIGINT/SIGTERM. Because classify_rebase_failure converts that into a silent AlreadyDisplayed, a git process killed by a non-interrupt signal mid-rebase — SIGSEGV (139), SIGKILL (137), SIGPIPE (141) — now exits silently with 128 + sig instead of surfacing git's stderr as a RebaseConflict/Other error. And since the recovery lives in the shared ErrorExt method, this reclassification applies to every capture-mode (Cmd::run) command, not only rebase.

This is symmetric with the pre-existing stream-mode ChildProcessExited { signal } arm (which already treats every signal as an interrupt), so it's arguably the intended generalization — but the function name reads as "interrupt", and swallowing a git crash's diagnostics is a real (if rare) behavior change. Flagging for @max-sixty to confirm that "all signals ⇒ silent 128+sig" is the desired semantics for capture mode, or whether it should narrow to SIGINT/SIGTERM.

@worktrunk-bot

Copy link
Copy Markdown
Collaborator Author

CI status for the record (opened by the nightly sweep):

  • test (windows) / affected tests (windows, advisory) are red, but this is pre-existing on main, not caused by this change. The current main HEAD (89b58e25) fails the same required test (windows) job — the integration_tests::switch_picker::* tests panic with "interactive picker failed: Keyboard progressive enhancement not implemented for the legacy Windows API" (main CI run). This change only touches error classification in src/commands/step/rebase.rs and adds OS-agnostic unit tests, so it can't affect the Windows picker path.
  • test (linux), macOS/Linux advisory, lint, and code-coverage all pass.
  • codecov/patch had not posted yet when the sweep session ended (codecov.io was lagging well behind the successful code-coverage job). classify_rebase_failure is fully exercised by the three added unit tests — every branch (interrupt, rebasing-conflict, other) — so patch coverage is expected to pass; final word is on the check itself.

@worktrunk-bot

Copy link
Copy Markdown
Collaborator Author

CI note: the red test (windows) / affected tests (windows, advisory) are the pre-existing integration_tests::switch_picker::* snapshot failures, not from this change. The identical set (test_switch_picker_abort_with_escape snapshot mismatch, the alt_* tests panicking at switch_picker.rs:746) fails on main at this PR's base SHA 89b58e2main ci run 29884002936. This diff only touches error classification (src/git/error.rs, src/commands/step/rebase.rs) and doesn't go near the picker; linux/macos tests, lint, and code-coverage are green.

max-sixty and others added 2 commits July 22, 2026 18:22
…IGTERM

interrupt_exit_code() classified any capture-mode signal death as an
interrupt, so a git killed by SIGSEGV or an OOM SIGKILL mid-rebase
exited silently with 128+sig, discarding the captured stderr and leaving
the REBASING worktree unexplained. Capture mode has no signal forwarding
or escalation, so a non-SIGINT/SIGTERM death is never wt's own doing;
those now fall through to the visible error path (RebaseConflict
mid-rebase, whose rendering carries the --abort hint, else Other).

Stream mode keeps counting any signal: output already streamed to the
terminal, and user-initiated kills are normalized upstream to the
originating SIGINT/SIGTERM before classification reads the raw signal.

Also name the signal in CommandError's summary ("git fetch failed
(signal 11)") so the surfaced error says what happened, and record both
structured channels in CLAUDE.md's signal-handling section.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@max-sixty

Copy link
Copy Markdown
Owner

Resolved the open question by narrowing, but only the capture-mode branch: interrupt_exit_code() now classifies a capture-mode (CommandError) signal death as an interrupt only for SIGINT/SIGTERM. The stream-mode (ChildProcessExited) branch still counts any signal.

The asymmetry is principled, and it turns on two facts the review flagged around but didn't pin down:

  1. Capture mode has no signal forwarding or escalation (forward_signals only affects .stream()), so a capture child killed by SIGKILL or SIGSEGV is never wt's own doing. It crashed, was OOM-killed, or something external killed it. And since capture mode never displayed the child's output, the silent AlreadyDisplayed exit would discard the captured stderr along with any explanation. Those deaths now stay on the visible error path: mid-rebase the REBASING state renders "Rebase onto X incomplete" with the --abort hint; otherwise GitError::Other.
  2. On the stream path, "any signal" is both safe and required. Safe because the child's output already streamed to the terminal; required because a user's second Ctrl-C escalates to SIGKILL there, and the seen_signal / originating-signal machinery normalizes those back to the user's SIGINT before classification, so what reaches the raw-signal match is only ever a death wt didn't cause.

Also in the commit: a signal-killed CommandError now names the signal in its summary (git rebase failed (signal 11) rather than a bare git rebase failed), and the CLAUDE.md signal-handling section records both structured channels and the capture-mode narrowing. New tests pin the signal set (2 → 130, 15 → 143, 9/11 → None) and the crash-mid-rebase classification.

This was written by Claude Code on behalf of max

A passing positive matches! never runs its generated false arm; when the
macro opens on its own line, llvm-cov attributes that arm's region to
the opener line, which then reads as missed and failed codecov/patch
(97.70% vs 97.79% target, three such lines). Binding the downcast and
importing the variant keeps each call within rustfmt's fn_call_width so
the opener shares a line with executed regions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@worktrunk-bot

Copy link
Copy Markdown
Collaborator Author

Resolved — the narrowing is exactly the semantics I was flagging for. Verified at the PR head (error.rs#L1518-L1524): the capture-mode arm now gates on signal: Some(sig @ (SIGINT | SIGTERM)) via the CommandError::find_in chain-walk, so a SIGSEGV/SIGKILL/SIGPIPE death mid-rebase falls through to the visible error path instead of a silent 128+sig, while stream mode keeps counting any signal. The asymmetry holds up: capture mode has no signal forwarding/escalation, so a non-SIGINT/SIGTERM kill is never wt's own doing, whereas a stream-mode SIGKILL can be wt escalating a second Ctrl-C — normalized upstream before the raw-signal match reads it. No remaining concerns from my end.

A SIGTERM against wt previously exited silently: wt traps SIGINT/SIGTERM
to forward them to children, so the shell never sees wt die and never
prints its own "Terminated" line — and the interrupt classification
funneled every signal-derived child exit into
AlreadyDisplayed { exit_code: 128 + sig }, erasing the signal identity
exactly where the exit path would need it.

Interrupt exits are now typed: WorktrunkError::Interrupted { signal, hint }
replaces AlreadyDisplayed at every classification site
(handle_command_error, for_each, classify_rebase_failure, run_custom's
signal branch) and renders once at exit, per shell convention — silent
for SIGINT (the terminal already echoed ^C), "Terminated" for SIGTERM,
"Killed by signal N" otherwise. Exit codes stay 128 + signal.

The hint field carries a recovery line for state the interrupt left
behind: an interrupted rebase (capture mode, so none of git's output was
shown) now prints "Rebase onto <target> left in progress; to abort, run
git rebase --abort" under the message instead of silently stranding the
worktree in REBASING state.

ErrorExt::interrupt_exit_code() is renamed interrupt_signal() and
returns the signal; the 128 + signal convention lives in one place
(WorktrunkError::exit_code). interrupt_signal() also recognizes
Interrupted itself, so an outer loop re-checking a bubbled error can't
demote it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@max-sixty
max-sixty merged commit 3554f49 into main Jul 23, 2026
37 checks passed
@max-sixty
max-sixty deleted the nightly/rebase-interrupt-29899142288 branch July 23, 2026 23:16
max-sixty added a commit that referenced this pull request Jul 23, 2026
Resolves src/commands/step/rebase.rs against #3539, which extracted the
failed-rebase classification into `classify_rebase_failure`. Its
`is_rebasing` check reads the structured `InProgressOperation` this branch
introduces instead of matching "REBASING" on a display string.

> _This was written by Claude Code on behalf of Maximilian_

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
max-sixty added a commit that referenced this pull request Jul 24, 2026
> _This was written by Claude Code on behalf of max_
max-sixty added a commit that referenced this pull request Jul 24, 2026
…3558)

## The bug

With a rebase in progress (a conflicted stop, or a killed `git`), `wt
step rebase main` reported success over a conflicted tree:

```console
$ git rebase main          # stops on a conflict; HEAD detached mid-replay
$ wt step rebase main
○ Already up to date with main
$ echo $?
0
```

`handle_rebase` asked `is_rebased_onto` first. Mid-rebase, HEAD sits on
a linear extension of the target, so that returns true and the function
returns `UpToDate` before anything consults `worktree_state`.

`wt merge` from the same state failed differently but no better:

```console
✗ Cannot merge: not on a branch (detached HEAD)
↳ To switch to a branch, run git switch <branch>
```

The hint names the one command that throws the rebase away instead of
finishing it.

## The fix

Both commit-replaying commands now check for an open operation before
doing anything else. `wt merge` runs the check ahead of its branch
check, so the open operation is what gets named rather than the detached
HEAD it causes.

```console
$ wt step rebase main
✗ Cannot rebase: a git operation is already in progress
```

The gate covers every state detection reports, not just rebase. Measured
on the others beforehand: a rebase started mid-bisect ran and stacked a
second operation on the first, and one started mid-merge or
mid-cherry-pick died inside git's autostash (`Cannot save the current
index state`, `f: needs merge`). None told the user what to do next.

## Why it prescribes no remedy

An earlier draft of this branch carried a per-operation table of
`--continue`/`--abort` hints. It was wrong on first contact with the
long tail: `git am` borrows the `rebase-apply` directory, so an open am
session was reported as a rebase and pointed at `git rebase --continue`,
which git refuses outright:

```console
$ git rebase --continue
fatal: It looks like 'git am' is in progress. Cannot rebase.
```

Adding an `Am` arm would have treated the enumeration as the fixable
part. `git status` already answers this better than the table did, in
git's own words and translations, for states worktrunk has no variant
for:

```console
$ git status
interactive rebase in progress; onto b162044
You are currently rebasing branch 'feat' on 'b162044'.
  (fix conflicts and then run "git rebase --continue")
  (use "git rebase --skip" to skip this patch)
  (use "git rebase --abort" to check out the original branch)
```

It names the operation, the branch, the progress, and every way out
including `--skip`, which the table never offered. So the table is gone
rather than extended, and the refusal is a single line: it says what is
blocked and why, and leaves the rest to the command every git user
reaches for next.

Detection still returns a structured `InProgressOperation` rather than a
display string, but now purely as detection: the only caller that reads
a variant is the one classifying a rebase worktrunk itself just started.

The neighbouring `RebaseConflict` error took the same treatment. It
forwards git's stderr in the gutter when it has it, and otherwise
substituted two hint lines of its own:

```console
↳ To continue after resolving conflicts, run git rebase --continue
↳ To abort, run git rebase --abort
```

That is the operation table again in miniature — one operation's
remedies, minus `--skip` — and the one path that reaches it is the
safety check for a rebase git *reported as finished*, where worktrunk
knows least about what is actually open. It now prints its title, plus
git's own words when git said any.

## One detector

Two places probed the same files for the same answer:
`Repository::worktree_state` for the gate, and
`WorkingTree::is_rebasing`/`is_merging` for the `wt list` gutter
symbols. They are now one method, `WorkingTree::operation_in_progress`,
which `Repository::operation_in_progress` delegates to — the rename also
retires a name that collided with `wt list`'s unrelated `WorktreeState`.

Detection now runs in the order `git status` consults the state files,
which is a change from the old `wt list` path: it checked rebase before
merge. Nothing can reach the difference — no rebase backend writes
`MERGE_HEAD`, checked against all three — but matching git's order is
one less thing to reason about. `wt list` maps the operations without a
gutter symbol (cherry-pick, revert, bisect) to `None`, so its output is
unchanged.

## The queued sequencer, now closed

Having one detector is what made the last gap worth closing. `git
cherry-pick A B` that stops on A writes `CHERRY_PICK_HEAD`; resolving
that stop with `git commit` instead of `git cherry-pick --continue`
removes the file and leaves B queued:

```console
$ git status
On branch main
Cherry-pick currently in progress.
  (run "git cherry-pick --continue" to continue)
$ ls .git/CHERRY_PICK_HEAD
ls: .git/CHERRY_PICK_HEAD: No such file or directory
$ cat .git/sequencer/todo
pick 1f07533 side1
pick 6b32540 side2
```

Git reads that same file to keep calling the sequence in progress, so
detection reads it too: the first instruction word, `pick` or `revert`,
exactly as git's own `sequencer_get_last_command` does.

The reason this waited for its own change is the risk of refusing a `wt
merge` the user can't un-refuse, so the lifecycle is measured rather
than assumed. The directory is gone once the sequence finishes, and gone
after `--quit` or `--abort`; a single-commit cherry-pick never creates
one; a rebase keeps its todo elsewhere, under `rebase-merge/`. An empty
or unrecognized todo reports nothing, so the failure direction is the
old behavior rather than a refusal with no way out.

## Verification

The repro above, plus the merge, am, cherry-pick, revert, and bisect
states, checked by hand against real repos: all five refuse, including
am, which is now correct precisely because nothing here claims to know
what am needs.

Three integration tests drive the real binary from a genuinely stopped
operation (`test_step_rebase_refuses_mid_rebase`,
`test_merge_refuses_mid_rebase`, `test_merge_refuses_mid_merge`).
`test_merge_refuses_mid_merge` is what holds the broadened scope: a
conflicted merge keeps HEAD on the branch, so it is the case the
detached-HEAD check waves through. Narrowing the gate back to
rebase-only turns it red, which I verified by making that edit and
re-running.

The sequencer case is driven end to end from a real stopped pick —
`test_operation_in_progress_reads_the_queued_sequencer` commits the
resolution by hand, asserts `CHERRY_PICK_HEAD` is gone, and still
expects a cherry-pick, so it can only pass through the sequencer read.
It then runs `git cherry-pick --quit` and expects the state to clear,
which is the false-refusal half. Running the same sequence against the
built binary refuses where it previously reported `○ Already up to date
with main`.

Confirmed unchanged: already-up-to-date, fast-forward, true-rebase, and
rebase-conflict output; a plain detached HEAD still gets the `git
switch` hint, which is the right remedy when no operation is open; a
sibling worktree mid-rebase does not block a clean one; `wt list`'s
gutter symbols.

Full gate green (4494/4494 tests, lints, fmt), plus `cargo clippy
--all-targets --features shell-integration-tests`, which the gate
doesn't compile.

## Merged with main

`main` gained #3539, which extracted the failed-rebase classification
into `classify_rebase_failure`, and #3549, which adds `span_upstream` —
both inside `handle_rebase`. The conflict was semantic as well as
textual: #3539's `is_rebasing` check still read the old string API
(`worktree_state()?.is_some_and(|s| s.starts_with("REBASING"))`), which
this branch removes, so resolving the markers alone left a type error.
The resolution keeps #3539's classification and reads the structured
enum instead:

```rust
let state = repo.operation_in_progress()?;
let is_rebasing = matches!(state, Some(InProgressOperation::Rebase));
return Err(classify_rebase_failure(e, is_rebasing, &integration_target));
```

All five of #3539's classification tests pass alongside the gate.

> _This was written by Claude Code on behalf of Maximilian_

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

nightly-cleanup Issues found by nightly code quality sweep

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants