docs(extending): bring every worktree up to date in the wt up recipe - #3882
Conversation
The recipe failed the whole 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. `git rebase --no-autostash` refuses to start on a worktree with a modified or staged tracked file rather than conflicting, so the unconditional `git rebase --abort` fired with no rebase in progress and exited 128. Aliases run as hook steps and a non-zero step stops the pipeline, so an ordinary dirty worktree cancelled whatever came after `wt up`. - Skip on `git diff --quiet HEAD`, which matches rebase's refusal set exactly — untracked files don't trigger either. This also replaces `git update-index --refresh`, since `git diff` refreshes the index itself (checked against a stale stat entry with `diff.autoRefreshIndex=false`, where `git diff-index` does not). - Name the mid-rebase test `rebasing` and reuse it to guard the abort, so a genuine failure keeps git's own message and exit code rather than `fatal: no rebase in progress` and 128. The guard is also what keeps the sweep from aborting a rebase the user stopped to resolve conflicts. - `;` rather than `&&` after the fetch, so one remote with lapsed credentials no longer leaves every worktree unrebased. `--no-autostash` stays: when an autostash pops with conflicts, git leaves the markers in the worktree and the rebase still exits 0, so a sweep without the flag reports success on a worktree it left mid-conflict. Verified end-to-end against the built binary over clean, dirty, staged, stale-stat, untracked, conflict, mid-rebase, mid-merge, detached and broken-remote worktrees: each exits 0 with work preserved. A pre-rebase hook refusal exits 1 and names the worktree. Closes #3860 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The skip left the dirtiest worktrees — the ones agents are working in, and the primary worktree that post-merge hooks write into — permanently behind. A fast-forward is the piece of the rebase git will still do on a dirty tree: it advances a branch that is simply behind, refuses per file when an incoming change collides with an edit, and never creates or rewrites a commit, so nothing can be lost. - Dirty worktrees run `git merge --ff-only --no-autostash` instead of skipping. `-c advice.diverging=false` trims the diverged-branch refusal to one line. - `--no-autostash` on the merge arm too: `merge.autostash` would stash, fast-forward, and pop — bypassing the per-file refusal and able to leave conflict markers behind an exit 0, verified. Verified through the built binary over 14 states (clean, dirty overlapping/non-overlapping, staged, untracked, diverged, up to date, conflict, mid-rebase, mid-merge, detached, broken remote, autostash set, pre-rebase hook refusal): every safe advance happens, every unsafe one refuses with the worktree intact, and only the hook refusal exits non-zero. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Not worth the flag: the diverged-dirty skip keeps git's stock hint block, and the recipe line shrinks back to one line. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
worktrunk-bot
left a comment
There was a problem hiding this comment.
Reproduced each git behaviour the new prose asserts against git 2.55 rather than reading it — the fetch &&→; rationale (including a broken remote listed first), rebase refusing on a modified tracked file even with nothing to rebase, --ff-only preserving non-colliding edits and refusing colliding ones byte-for-byte, git rebase --abort's fatal: no rebase in progress / exit 128, and the rebase.autostash pop-conflict that leaves UU markers and still exits 0. All match. The diff.autoRefreshIndex=false claim in the description holds too, which is what makes dropping git update-index --refresh safe: with it set, git diff-index --quiet HEAD false-positives on a bare touch and git diff --quiet HEAD still returns 0. The three mirrors are identical in the changed region.
One precision nit inline.
check-docs is red, but not from this diff. npm ci fails ERESOLVE: docs/package.json pins "typescript": "^7.0.2" while @astrojs/check@0.9.10 peers on typescript@"^5.0.0 || ^6.0.0". The same job failed the same way on main at 03:45 today (run 32615714664), this PR doesn't touch package.json, and #3877 already pins it back to ^6. Everything else on the head is green, including test (linux|macos|windows) and codecov/patch.
`@astrojs/check@0.9.10` peers `typescript@^5.0.0 || ^6.0.0`, and 0.9.10 is the latest release, so the bump to 7.0.2 has no compatible checker to pair with. `npm ci` fails with ERESOLVE and takes `check-docs` down with it — red on main since the bump landed, and inherited by every docs PR through the merge commit. Reverting is the only move that resolves: there is no @astrojs/check that accepts TypeScript 7 yet. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
worktrunk-bot
left a comment
There was a problem hiding this comment.
The typescript revert lands the fix — check-docs is green on this head, and the lockfile is byte-for-byte identical to the pre-bump one at 8e405bced, so the diff is exactly the inverse of #3870 with no npm-version churn.
Two things on the new commit.
The revert alone doesn't hold past next Wednesday. .github/dependabot.yaml's npm /docs entry groups patterns: ["*"] with no ignore, so once docs/package.json says ^6.0.3 again Dependabot reproposes the identical 7.x bump on its next weekly run and the fix is a revert each time. That's not hypothetical caution — check-docs had already failed on #3870's own head (run 32558778950) and it merged anyway, so nothing in the current setup stops the round trip. #3877 is the same one-line pin plus the ignore entry and a note in the weekly CI Pin Bumps pass naming the condition for removing it, which is the part that actually makes this durable. Worth landing that half rather than only the revert — either drop the pin from here and let #3877 go in, or say the word and I'll push the ignore entry to this branch.
#3877 and this PR now overlap. Both change docs/package.json and docs/package-lock.json to the same content; one of them should close.
One precision fix on the recipe inline — I reproduced it against git 2.55 rather than reasoning about it.
Repro for the inline finding
Bare origin + clone; upstream adds newfile.txt; the local worktree has an untracked newfile.txt. Running the recipe body verbatim:
error: The following untracked working tree files would be overwritten by checkout:
newfile.txt
Please move or remove them before you switch branches.
Aborting
error: could not detach HEAD
EXIT=1
Worktree intact (? newfile.txt, still scratch, branch -1), but exit 1 — so wt step for-each reports ✗ and, as a pre-* hook step, the rest of the pipeline stops.
The tracked analogue, same setup with a local uncommitted edit to a file the upstream commit also touches:
error: Your local changes to the following files would be overwritten by merge:
shared.txt
Please commit your changes or stash them before you merge.
Aborting
Updating 60c070f..bb62be4
EXIT=0
Same benign outcome for the user's data, opposite exit code — the ff arm ends in exit 0, the rebase arm falls through to rebasing returning false. I don't think the script can tell that refusal apart from a pre-rebase hook refusal without parsing the message, which CLAUDE.md rules out under Structured Output Over Error-Message Parsing, so documenting the case looks like the right depth rather than engineering around it.
A clean worktree holding an untracked file whose name matches one the incoming commits add made `git rebase` refuse the checkout. No rebase started, so `rebasing` was false, the guarded abort returned non-zero, and the sweep exited 1 — the ordinary-case non-zero exit this recipe exists to avoid, in a state agents hit routinely with scratch files. The refusal cases all share a property the tail can key on: git declined atomically and left nothing behind. So a rebase that fails without starting one is not a sweep failure, and only an abort that itself fails leaves a worktree needing attention. Also drops the local `typescript` pin — #3877 landed the same revert plus a `dependabot.yaml` ignore entry that stops the bump recurring, so this branch takes main's version. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The documented
wt uprecipe 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 --allexits 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.git rebaserefuses to start when a tracked file is modified or staged, whether or not that worktree has anything to rebase.git merge --ff-onlyis 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 --abortruns only when a rebase is actually in progress. The mid-rebase test is namedrebasingand reused at both call sites, which also drops the duplicatedtest -d … -o -d …and its obsolescent-o. A refusal leaves nothing to abort, and the unconditional abort answered it withfatal: no rebase in progressand 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-autostashon both arms. This is the third direction the issue raised, and the measurement settles it: withrebase.autostash = trueand no flag, an autostash whose pop conflicts leavesUUmarkers in the worktree, a stash entry, and exits 0 — so the sweep prints✓ Completed in 3 worktreesover a worktree it just left mid-conflict.merge.autostashbreaks 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 HEADreplaces thegit update-index --refreshline as well as guarding the arms. It's exactly the setgit rebaserefuses on, it refreshes the index itself (checked withdiff.autoRefreshIndex=false, wheregit diff-indexfalse-positives on a baretouchandgit diffdoesn't), and it's the idiom the next recipe on the page already uses.Testing
Built
wtand 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.Verification matrix
Already up to date., exit 0rebase-mergeleft, exit 0MERGE_HEAD, unmerged files)MERGE_HEADpreserved, exit 0rebase.autostash+merge.autostashboth setpre-rebasehook refuseserror: The pre-rebase hook refused to rebase.shown, worktree untouched, exit 0 — nofatal: no rebase in progressmasking itThe predicate was checked against every state the issue's table names, confirming
git diff --quiet HEADmatches rebase's refusal set:git rebase --no-autostashgit diff --quiet HEADtouch)The script also parses under
dash,sh,zsh, andbash.cargo test --test integration test_docs_are_in_syncregenerates the two mirrors (skills/worktrunk/reference/extending.md,plugins/worktrunk/skills/worktrunk/reference/extending.md) and passes;cargo run -- hook pre-merge --yesis green at 4673 tests, along with the Astrocheckandbuild.Supersedes #3861, which took the skip-only route against the pre-Astro docs path and no longer merges.
Closes #3860