feat(worktree): name worktrees wedged in an abandoned merge - #4499
Conversation
A worktree paused mid-merge presents to every tool here as nothing more than "N dirty", which is indistinguishable from a session editing right now. Nothing in src/lib or scripts read MERGE_HEAD, rebase-merge, or CHERRY_PICK_HEAD, so the state had no name and no age. The cost is not hypothetical: an abandoned merge on docs/cave-zs85n-chat-sidebar-attention stalled 2026-08-07 and survived until 2026-08-10 because each session that found its 137 dirty paths read them as a colleague's in-flight work and correctly declined to clobber them. Backing off was the right call every time; the missing information was whether anyone was actually still there. Add a WEDGED verdict to the local worktree dashboard that detects the paused operation, dates it from the marker's mtime, lists the unresolved paths, and answers the question backing off leaves open: compare every dirty path's mtime against the moment the operation stalled. Nothing touched since means no hand resolution exists to lose, so the tree is raw merge output the two parents reproduce; anything touched since means a human is mid-resolution and the report says do NOT abort. An unreadable tree fails closed to the same warning, because "nobody touched it" is the fact an abort decision leans on. Retention is reported alongside, network-free, so an abort is only ever called lossless when both sides are reachable from a remote-tracking ref or tag; otherwise the archive-tag command prints instead. WEDGED outranks every other verdict and is never SAFE-RETIRE, so neither --prune nor wt:retire-on-exit can remove a tree with unfinished work in it. Verified against the real wedged worktree: the tool independently reproduces the manual forensics — 5 conflicts, zero hand resolution, both parents retained, abort lossless.
There was a problem hiding this comment.
Pull request overview
Warning
Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.
Adds a new WEDGED verdict to worktree-status so unfinished git operations (merge/rebase/cherry-pick/revert/bisect) are detected, aged, and given actionable remediation guidance—preventing abandoned paused operations from being mistaken as “someone’s live dirty work”.
Changes:
- Detect in-progress git operations via per-worktree git-dir markers and surface them as
WEDGED(with age, conflicted paths, and “touched since stall” analysis). - Update human output ordering/formatting and add a detailed wedge remedy block (including retention hints).
- Add node test coverage for WEDGED behavior (merge + rebase scenarios) and document
pnpm wt:statususage in agent docs.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| scripts/worktree-status.mjs | Implements WEDGED detection, sorting precedence, and human remedy output. |
| scripts/worktree-status.test.mjs | Adds fixtures/tests for merge- and rebase-based wedged worktrees and --prune behavior. |
| CLAUDE.md | Documents pnpm wt:status and explains the WEDGED verdict semantics. |
| AGENTS.md | Adds a workflow pointer to run pnpm wt:status before assuming dirtiness is live work. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| process.stdout.write(` finish it: cd ${shq(row.path)} && git ${w.op === "rebase" ? "rebase --continue" : "commit"}\n`); | ||
| process.stdout.write( | ||
| ` or drop it: cd ${shq(row.path)} && git ${w.op === "bisect" ? "bisect reset" : `${w.op} --abort`}` + | ||
| `${handResolved ? " ← only with the owner's say-so" : ""}\n`, | ||
| ); |
| const scan = (res) => { | ||
| if (!res.ok) return { count: 0, newest: null, readable: false }; | ||
| let count = 0; | ||
| let newest = null; | ||
| for (const rel of res.out.split("\0").filter(Boolean)) { | ||
| const at = mtimeMs(path.join(wtPath, rel)); | ||
| if (at === null || at <= cutoff) continue; | ||
| count += 1; | ||
| if (!newest || at > newest.atMs) newest = { path: rel, atMs: at, at: new Date(at).toISOString() }; | ||
| } | ||
| return { count, newest, readable: true }; | ||
| }; |
| const OPERATION_MARKERS = [ | ||
| { marker: "rebase-merge", op: "rebase" }, | ||
| { marker: "rebase-apply", op: "rebase" }, | ||
| { marker: "MERGE_HEAD", op: "merge" }, | ||
| { marker: "CHERRY_PICK_HEAD", op: "cherry-pick" }, | ||
| { marker: "REVERT_HEAD", op: "revert" }, | ||
| { marker: "BISECT_LOG", op: "bisect" }, | ||
| ]; |
Co-authored-by: BunsDev <68980965+BunsDev@users.noreply.github.com>
|
Triage: this PR is not broken — it's blocked by a break on The only failing check is This branch does not touch Fix is up as #4510, verified with a real local Context for why it appeared here at all: the iOS job no longer runs on pull requests (it moved to |
Closes
cave-97svy.The signal
A worktree left mid-merge blocked its branch for three days and was reported repeatedly as a persistent blocker. Every session that found it did the right thing — saw 137 uncommitted paths, read them as another session's in-flight work, and backed off rather than clobber a colleague.
Backing off was correct each time. The missing information was whether anyone was still there.
Root cause
Nothing in
src/liborscriptsreferencesMERGE_HEAD,rebase-merge, orCHERRY_PICK_HEAD— verified by grep before this change. So a paused git operation has no name, no age, and no remedy anywhere in the tooling:137 dirty.All four are satisfied by a wedged worktree. It looks exactly like live work, forever.
scripts/worktree-status.mjswas also documented in neitherAGENTS.mdnorCLAUDE.md, so the one tool positioned to answer this was not something a session would think to run.The fix
A
WEDGEDverdict on the local dashboard that detects merge / rebase / cherry-pick / revert / bisect markers, dates the stall from the marker mtime, lists the unresolved paths, and — the part that unblocks a decision — compares every dirty path's mtime against the moment the operation stalled:do NOT abortand marks the abort command "only with the owner's say-so"Retention of both merge parents is reported alongside (network-free, via remote-tracking refs and tags), so an abort is only called lossless when nothing would be stranded; otherwise the archive-tag command prints instead.
WEDGEDoutranks every verdict includingPRIMARY, and is neverSAFE-RETIRE— so neither--prunenorwt:retire-on-exitcan remove a tree holding unfinished work.Verification
Against the real wedged worktree, the tool independently reproduces the manual forensics that took ~20 minutes:
node --test scripts/worktree-status.test.mjs— 11/11 pass (6 pre-existing, 5 new covering: mid-merge is WEDGED not DIRTY; abandoned reports no hand resolution; hand-resolved warns against aborting; a paused rebase reports rebase-shaped remedies and survives the detached HEAD it creates;--prunenever offers a WEDGED tree however merged it looks).Docs added to
CLAUDE.md(full section) andAGENTS.md(pointer at the "is this someone's live work?" decision point).