docs(extending): keep the wt up sweep from failing on an edited worktree - #3861
docs(extending): keep the wt up sweep from failing on an edited worktree#3861worktrunk-bot wants to merge 2 commits into
Conversation
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
left a comment
There was a problem hiding this comment.
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.
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.
|
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 This branch also targets Thanks — the three assertions here all held up under testing, and #3882 keeps them.
|
#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>
Problem
The documented
wt uprecipe fails the sweep for any worktree with a modified tracked file, even one with nothing to rebase.git rebase --no-autostashrefuses to start on such a worktree rather than conflicting, so the recipe's|| git rebase --abortfired with no rebase in progress and returned 128 — afatal: no rebase in progressthat 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 &&—--allexits 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:uncommitted changes; skippingline so the sweep's summary says skipped rather than failed. The predicate isgit 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.git rebase --abortonly 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-autostashis 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-autostashand thegit diff-indexline hands the decision torebase.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
wtand 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:
After:
Full verification matrix
Each case run against the real binary, not reasoned about:
rebase-mergeleft, clean status, exit 0feat-brebased, exit 0pre-rebasehook)error: The pre-rebase hook refused to rebase., exit 1 — nofatal: no rebase in progressmasking itThe predicate itself was checked against every state the issue's table names, confirming
git diff-index --quiet HEAD --matches rebase's refusal set:git rebase @{u} --no-autostashgit diff-index --quiet HEAD --cargo test --test integration test_docs_are_in_syncregenerated the two mirrors (skills/worktrunk/reference/extending.md,plugins/worktrunk/skills/worktrunk/reference/extending.md) and passes.Closes #3860 — automated triage