Skip to content

docs(extending): keep the wt up sweep from failing on an edited worktree - #3861

Closed
worktrunk-bot wants to merge 2 commits into
mainfrom
docs/issue-3860-up-recipe
Closed

docs(extending): keep the wt up sweep from failing on an edited worktree#3861
worktrunk-bot wants to merge 2 commits into
mainfrom
docs/issue-3860-up-recipe

Conversation

@worktrunk-bot

Copy link
Copy Markdown
Collaborator

Problem

The documented wt up recipe fails the sweep for any worktree with a modified tracked file, even one with nothing to rebase. git rebase --no-autostash refuses to start on such a worktree rather than conflicting, so the recipe's || git rebase --abort fired with no rebase in progress and returned 128 — a fatal: no rebase in progress that names neither the worktree's state nor anything the user can act on, and that buries git's real explanation one line above. Since aliases are used as hook steps and a non-zero step silently stops the rest of the pipeline (#3858), a sweep alias that returns non-zero in ordinary use cancels whatever the user put after it.

Separately, git fetch --all --prune &&--all exits non-zero if any single remote fails, and the && then skipped the sweep entirely, so one remote with lapsed credentials left every worktree unrebased, including those whose refs fetched fine.

Solution

Three changes to the recipe in docs/content/extending.md:

  • Skip a worktree with uncommitted tracked changes, alongside the existing no-upstream and mid-rebase guards, with a per-worktree uncommitted changes; skipping line so the sweep's summary says skipped rather than failed. The predicate is git diff-index --quiet HEAD --, which matches rebase's refusal set exactly — untracked files don't trigger it, so a worktree carrying only new files still sweeps.
  • Run git rebase --abort only when a rebase is actually in progress, mirroring the mid-rebase guard above it. A conflict still auto-aborts and the sweep stays at exit 0; a genuine failure now surfaces with git's own message instead of a masked 128. This also covers the case where a worktree turns dirty between the guard and the rebase — plausible here, since agents run in these worktrees.
  • ; rather than && after the fetch, so one unreachable remote no longer skips the sweep. The error stays visible either way.

The prose is updated to explain all three, and to note that --no-autostash is deliberate — a sweep that stashes and pops across every worktree can leave conflicts in several at once. The tradeoff is surfaced rather than decided for the reader: dropping both --no-autostash and the git diff-index line hands the decision to rebase.autostash, which is the third direction the issue raised. Keeping the flag means an autostash user's setting doesn't apply during the sweep; that seemed the right default for a command that touches every worktree at once, but it is a real cost.

Testing

Built wt and ran the recipe end-to-end against a scratch repo with three worktrees (main + feat-a + feat-b), all tracking upstreams.

Before the change, the reported failure reproduces byte-for-byte:

◎ Running in feat-a (on feat-a)...
error: cannot rebase: You have unstaged changes.
error: Please commit or stash them.
fatal: no rebase in progress
✗ Failed in feat-a (on feat-a) (exit code 128)
...
▲ 1 of 3 worktrees failed
EXIT=1

After:

◎ Running in feat-a (on feat-a)...
uncommitted changes; skipping
...
✓ Completed in 3 worktrees
EXIT=0
Full verification matrix

Each case run against the real binary, not reasoned about:

Scenario Result
all clean, all behind all rebased, exit 0
dirty tracked file, nothing to rebase skipped, work preserved, exit 0
dirty tracked file and behind skipped, work preserved, still behind; clean sibling rebased in the same sweep; exit 0
untracked file only, behind rebased, untracked file kept, exit 0
real conflict auto-aborted, HEAD back at the pre-rebase commit, no rebase-merge left, clean status, exit 0
one broken remote fetch error visible, sweep still ran, feat-b rebased, exit 0
genuine non-conflict failure (rejecting pre-rebase hook) error: The pre-rebase hook refused to rebase., exit 1 — no fatal: no rebase in progress masking it

The predicate itself was checked against every state the issue's table names, confirming git diff-index --quiet HEAD -- matches rebase's refusal set:

Worktree state git rebase @{u} --no-autostash git diff-index --quiet HEAD --
unstaged tracked changes, behind exit 1 exit 1
unstaged tracked changes, up to date exit 1 exit 1
staged changes exit 1 exit 1
untracked only exit 0 exit 0

cargo test --test integration test_docs_are_in_sync regenerated the two mirrors (skills/worktrunk/reference/extending.md, plugins/worktrunk/skills/worktrunk/reference/extending.md) and passes.


Closes #3860 — automated triage

git rebase --no-autostash refuses to start when a tracked file is
modified, whether or not the worktree has anything to rebase, so the
recipe's trailing `|| git rebase --abort` fired with nothing to abort
and returned 128. A worktree the user was simply editing failed the
sweep, and `fatal: no rebase in progress` hid git's real explanation
one line above.

Skip such a worktree with git diff-index --quiet HEAD -- (exactly the
predicate rebase refuses on: untracked files still sweep), and run the
abort only when a rebase is actually in progress, so a genuine failure
surfaces with git's own message instead of a masked 128.

Separately, git fetch --all exits non-zero if any single remote fails,
so the leading `&&` skipped the whole sweep when one remote's
credentials had lapsed. Use `;` so the sweep runs on what did fetch.

Closes #3860
@worktrunk-bot worktrunk-bot added the automated-fix Automated CI fix label Aug 21, 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.

Self-authored PR, so this is a COMMENT rather than an approval. I ran the recipe's inner script against a scratch repo with a real upstream rather than reasoning about it, and every claim in the diff holds: clean+behind rebases (exit 0), a modified tracked file skips with the work intact (exit 0), untracked-only still sweeps (exit 0), a real conflict auto-aborts with HEAD back at the pre-rebase commit and no rebase-merge left (exit 0), and a pre-rebase hook refusal now surfaces error: The pre-rebase hook refused to rebase. with no fatal: no rebase in progress masking it. The alias block also parses as TOML and its sh -c '…' quote closes correctly.

One finding, on the escape hatch in the last paragraph.

git rebase refuses on a modified tracked file whether or not --no-autostash is passed — the flag only suppresses rebase.autostash, it isn't what causes the refusal. So "drop both --no-autostash and the git diff-index line" only works for a reader who has actually set rebase.autostash = true. With the setting at its default, I ran the drop-both variant against a dirty worktree and got the original bug back:

error: cannot rebase: You have unstaged changes.
error: Please commit or stash them.
EXIT=1

That is exactly the failure this PR exists to remove, so the sentence shouldn't read as an unconditional alternative. Suggestion inline naming the precondition.

The other half of the drop-both path, for the record

With rebase.autostash = true actually set, the same variant rebases and exits 0 — but when the autostash pop conflicts it leaves UU tracked.txt in the worktree while the sweep still reports success:

Created autostash: 9c3e082
Your local changes are stashed, however applying them
resulted in conflicts.
Successfully rebased and updated refs/heads/main.
EXIT=0

That is the cost the paragraph's first sentence already warns about, so it argues for the existing default rather than against it — no change needed there.

The suggestion lands on docs/content/extending.md, the primary for this page; the two mirrors need the same edit, which cargo test --test integration test_docs_are_in_sync regenerates. I'll push that as a follow-up commit since there's no separate author to apply it.

Comment thread docs/content/extending.md Outdated
The escape hatch read as unconditional, but git refuses to rebase a
worktree with a modified tracked file whether or not --no-autostash is
passed -- the flag only decides whether rebase.autostash applies. A
reader without that setting who dropped both lines got the original
failure back (exit 1 on any dirty worktree), which is what the recipe
change exists to remove.
@max-sixty

Copy link
Copy Markdown
Owner

Superseded by #3882, which takes the same three fixes further: a dirty worktree now fast-forwards rather than being skipped, so the worktrees you are actually working in stop falling behind, and --no-autostash is settled with a measurement (an autostash whose pop conflicts leaves UU markers in the tree and still exits 0) rather than left open.

This branch also targets docs/content/extending.md, which the Astro rebuild moved to docs/src/content/docs/extending.md, so it no longer merges.

Thanks — the three assertions here all held up under testing, and #3882 keeps them.

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

@max-sixty max-sixty closed this Aug 23, 2026
max-sixty added a commit that referenced this pull request Aug 23, 2026
#3882)

The documented `wt up` recipe failed the entire sweep for any worktree
with a modified tracked file, even one with nothing to rebase, and
skipped the sweep entirely when a single remote failed to fetch. Since
aliases are used as hook steps and a non-zero step stops the rest of the
pipeline, a sweep that returns non-zero in ordinary use silently cancels
whatever the user put after it.

Fixing that exposed a second question the issue raised but didn't
settle: what a sweep *should* do with a dirty worktree. Skipping is safe
but leaves the dirtiest worktrees — the ones you're actually working in,
and the primary worktree that project post-merge hooks write into —
permanently behind. So this brings them up to date instead.

## The recipe

Four changes, each verified against git rather than reasoned about:

- **`;` rather than `&&` after the fetch.** `git fetch --all` exits
non-zero if any single remote fails, so `&&` let one remote with lapsed
credentials skip every worktree's update, including those whose refs
fetched fine. The error stays visible either way.
- **A dirty worktree fast-forwards instead of failing.** `git rebase`
refuses to start when a tracked file is modified or staged, whether or
not that worktree has anything to rebase. `git merge --ff-only` is the
part of the rebase git will still do there: it advances a branch that is
simply behind, and otherwise changes nothing. It never creates or
rewrites a commit, and it refuses per file when an incoming change
collides with an edit, so the worktree is either advanced or left
exactly as it was.
- **`git rebase --abort` runs only when a rebase is actually in
progress.** The mid-rebase test is named `rebasing` and reused at both
call sites, which also drops the duplicated `test -d … -o -d …` and its
obsolescent `-o`. A refusal leaves nothing to abort, and the
unconditional abort answered it with `fatal: no rebase in progress` and
exit 128 in place of git's own message. The refusals all share one
property — git declined atomically and left nothing behind — so a rebase
that never starts is not a sweep failure, and the sweep exits non-zero
only for an abort that itself fails, leaving a worktree that needs
attention.
- **`--no-autostash` on both arms.** This is the third direction the
issue raised, and the measurement settles it: with `rebase.autostash =
true` and no flag, an autostash whose pop conflicts leaves `UU` markers
in the worktree, a stash entry, **and exits 0** — so the sweep prints `✓
Completed in 3 worktrees` over a worktree it just left mid-conflict.
`merge.autostash` breaks the ff arm the same way from the other side: an
autostashed tree is momentarily clean, so a fast-forward that should
have refused goes through and the collision lands on the pop instead.

`git diff --quiet HEAD` replaces the `git update-index --refresh` line
as well as guarding the arms. It's exactly the set `git rebase` refuses
on, it refreshes the index itself (checked with
`diff.autoRefreshIndex=false`, where `git diff-index` false-positives on
a bare `touch` and `git diff` doesn't), and it's the idiom the next
recipe on the page already uses.

## Testing

Built `wt` and ran the recipe — extracted verbatim from the rendered doc
— against scratch repos with three worktrees, for every state below.
Each row is the real binary, not reasoning.

<details><summary>Verification matrix</summary>

| Worktree state | Result |
|---|---|
| clean, behind | rebased, exit 0 |
| clean, up to date | no-op, exit 0 |
| dirty tracked, behind, **no overlap** | fast-forwarded, edit
preserved, exit 0 |
| dirty tracked, behind, **overlapping** the incoming change | refused
per file, worktree byte-for-byte intact, exit 0 |
| dirty tracked, up to date | `Already up to date.`, exit 0 |
| staged new file, behind | fast-forwarded, staged entry preserved, exit
0 |
| untracked only, behind | rebased, untracked file kept, exit 0 |
| clean, untracked file colliding with a file the incoming commits add |
declined, untracked file kept, exit 0 |
| dirty **and diverged** (local commit + behind) | declined, worktree
intact, exit 0 |
| clean, real conflict | auto-aborted, HEAD back at the pre-rebase
commit, no `rebase-merge` left, exit 0 |
| mid-rebase (stopped at conflicts) | skipped, in-progress rebase
preserved, exit 0 |
| mid-merge (`MERGE_HEAD`, unmerged files) | declined, `MERGE_HEAD`
preserved, exit 0 |
| detached HEAD | skipped, exit 0 |
| one broken remote | fetch error visible, sweep still ran, other
worktrees updated, exit 0 |
| `rebase.autostash` + `merge.autostash` both set | behaves identically
— no stash created, no markers left |
| `pre-rebase` hook refuses | `error: The pre-rebase hook refused to
rebase.` shown, worktree untouched, exit 0 — no `fatal: no rebase in
progress` masking it |

The predicate was checked against every state the issue's table names,
confirming `git diff --quiet HEAD` matches rebase's refusal set:

| Worktree state | `git rebase --no-autostash` | `git diff --quiet HEAD`
|
|---|---|---|
| unstaged tracked changes, behind | exit 1 | exit 1 |
| unstaged tracked changes, up to date | exit 1 | exit 1 |
| staged changes | exit 1 | exit 1 |
| stale stat entry (bare `touch`) | exit 0 | exit 0 |
| untracked only | exit 0 | exit 0 |

The script also parses under `dash`, `sh`, `zsh`, and `bash`.

</details>

`cargo test --test integration test_docs_are_in_sync` regenerates the
two mirrors (`skills/worktrunk/reference/extending.md`,
`plugins/worktrunk/skills/worktrunk/reference/extending.md`) and passes;
`cargo run -- hook pre-merge --yes` is green at 4673 tests, along with
the Astro `check` and `build`.

Supersedes #3861, which took the skip-only route against the pre-Astro
docs path and no longer merges.

Closes #3860

> _This was written by Claude Code on behalf of max-sixty_

---------

Co-authored-by: Claude Opus 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

automated-fix Automated CI fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Documented wt up recipe exits non-zero for any worktree with a modified tracked file, even one with nothing to rebase

2 participants