Skip to content

docs(extending): bring every worktree up to date in the wt up recipe - #3882

Merged
max-sixty merged 7 commits into
mainfrom
wt-up-alias-recipe
Aug 23, 2026
Merged

docs(extending): bring every worktree up to date in the wt up recipe#3882
max-sixty merged 7 commits into
mainfrom
wt-up-alias-recipe

Conversation

@max-sixty

@max-sixty max-sixty commented Aug 23, 2026

Copy link
Copy Markdown
Owner

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.

Verification matrix
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.

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

max-sixty and others added 3 commits August 22, 2026 21:14
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 worktrunk-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

Comment thread docs/src/content/docs/extending.md Outdated
max-sixty and others added 2 commits August 23, 2026 12:02
`@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 worktrunk-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

Comment thread docs/src/content/docs/extending.md Outdated
max-sixty and others added 2 commits August 23, 2026 12:23
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>
@max-sixty
max-sixty merged commit ad62f2a into main Aug 23, 2026
54 checks passed
@max-sixty
max-sixty deleted the wt-up-alias-recipe branch August 23, 2026 19:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

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