feat(worktree): run a repo-declared command after worktree creation - #71
Merged
Conversation
Spec, design and tasks for a repo-declared command that runs in each new worktree. Records AD-013: repo-local .app/config.json, keep-the-worktree failure semantics, all three create paths, inline dialog feedback, and the withPostCreateHook decorator architecture. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Mirrors workspaceTemplates one level down: read-on-use, trimmed, null for an absent/blank/non-string value, and null plus a console.error for malformed JSON. Repo and workspace keys stay mutually independent. Covers WPC-06 (config half), WPC-07, WPC-21. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… timeout mapping runPostCreateHook drives an injected HookShell with cwd set to the new worktree and the three PLAYGROUND_* context vars layered over process.env, then shapes the outcome: exit 0 is success, a non-zero exit, spawn failure (-1) and timeout kill (-1 + timedOut) all report ok:false with the evidence. Combined stdout+stderr is bounded to the last 4000 chars and is '' when the command is silent. Covers WPC-02, WPC-03 (payload), WPC-04, WPC-05 (mapping), WPC-09, WPC-11, WPC-20, WPC-23, WPC-24. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ated withPostCreateHook decorates a createWorktree-shaped function with an identical signature, so one instance wired in index.ts serves every call path and none can opt out. The hook runs iff the create reports ok with a path, which covers every no-worktree outcome (branch conflict, empty template, existing target, blocked refresh, git failure) without enumerating them, while the reuse and recreate successes do run it. A repo declaring no command comes back with no hook key at all. Covers WPC-01, WPC-03 (no-rollback), WPC-06 (no-hook-key), WPC-08, WPC-22. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
runHookShell spawns the repo's command through a shell so a checked-in .cmd works, capturing both streams and never throwing; spawn's own timeout kills it and the non-null close signal is what marks timedOut, since an exit code alone is ambiguous on Windows. One wrapped create is shared by the worktrees:create handler and the workflow ctx, so no call path can skip a repo's init command. Covers WPC-10 and WPC-05 (kill half). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Footer-region report for a failed post-create command, same geometry as BranchExistsChoice and the same evidence layout as the workflow failed footer, but amber rather than red: the create succeeded and the worktree is kept, so this is advisory. Shows the created path, the command, exit code or a timeout label, and the output tail (omitted entirely when empty). Covers WPC-12/13 (presentation) and WPC-14 (action). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both create dialogs hold a hookFailure state and swap the footer for the advisory when a created worktree's init command failed, mirroring how the branch-exists choice already takes over that region. Continue calls the same onCreated the happy path calls, so the worktree is never stranded behind an error, and because the footer is replaced the create button cannot re-submit. A successful or absent hook leaves today's behaviour untouched. Covers WPC-12, WPC-13, WPC-14, WPC-15, WPC-16. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…stall a create Resolving on `close` alone waits for every inherited pipe to reach EOF, and spawn's timeout kills only cmd.exe — a surviving grandchild holds those pipes open. Measured: `ping -n 12` with timeout 1500ms exits at 1665ms but closes only at 12969ms; with a `start /b` grandchild, 21000ms. For a genuinely hung script `close` never arrives, so worktrees:create never resolved and the dialog stuck on busy forever. Now whichever comes first wins: close (complete output, the normal path) or exit plus a short flush grace period (guaranteed progress). Extracted from index.ts into hook-shell.ts so the settle condition carries real-process tests — a fake shell cannot prove a settle condition. Reverting the fix makes the two timeout tests fail at 5018ms and 9244ms. Found by the independent Verifier; the previous comment overstated the guarantee. Covers WPC-05 (kill half). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…a failed hook WPC-11's 4000-char bound was only asserted as toHaveLength(HOOK_OUTPUT_MAX_CHARS), which is self-referential — a mutation of the constant survived. Now pinned to the spec's literal. WPC-03's "the worktree still exists on disk" rested on a structural argument (no deletion code exists) with no automated evidence. Added the spec's own Independent Test end to end over real git, a real .app/config.json and the real shell: the command runs inside the new worktree, a failing command leaves the worktree and its files in place, and a repo declaring nothing yields no hook key. Both gaps raised by the independent Verifier. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…sed by backdrop Clicking the backdrop while the advisory was up called onClose, skipping the refresh-and-select the Continue button performs, so a worktree that really existed didn't appear in the tree until the next refresh. Both dismissal paths now continue the post-create flow (WPC-14). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…rogress The grace timer was unref'd, so it could not keep the event loop alive on its own — "guaranteed progress" only held because close or the open pipes kept the process running. A scratch variant with the close handler removed exits with the promise unsettled, proving the two paths were not independent. The timer is now referenced and the doc comment no longer overstates the guarantee. Also closes the sensor gap on the real seam: stderr capture was asserted nowhere (the runner's stderr tests all drive a fake shell), and nothing pinned that a killed command still reports the output it produced before the kill, which is what WPC-05 specifies and what the grace period exists for. Adds a large-burst test (2000 lines) guarding against truncation on the exit path. Shortened the two lingering ping grandchildren from ~10s to ~4s with a tightened discrimination bound, so these tests stop pushing the pre-existing real-git suite over its 5000ms default timeout. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Validation report (Verifier rounds 1-2), execution record with the deviation that moved the shell seam into its own tested module, and the traceability table flipped to Verified for the 21 P1 requirements. WPC-12..16 are marked "Built" rather than Verified: the renderer has no unit tests by convention and no visual pass has been run. STATE.md handoff replaced (decisions log untouched) with the commit map, the accepted equivalent-mutant survivors, and the next step. README documents the new repo-local postCreateCommand, including the JSON-escaped ".\\" form and the repo-content trust trade-off. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…tion L-003 settle spawned-process promises on exit plus a flush grace, not close alone (the round-1 blocker). L-004 assert spec-defined bounds against their literal, not the constant implementing them (the surviving mutant). L-005 check existing suites for timeout headroom before adding real-process tests (the gate-reliability regression). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
A repo can declare one shell command that runs automatically in every newly created worktree, with
cwdset to that worktree:Motivating case:
m:\triade\source\CodeshipsSetupSkills.cmd, which creates the.claude\skills/.codex\skillsjunctions that let several coding agents share one skills source. Until now that had to be remembered and double-clicked in every new worktree — and worktrees created by a workflow for an agent never got it at all, which is the case that needs it most.Behaviour
ctx.worktree.create.git worktree addall skip it; the successfulreuse/recreatepaths do run it.git worktree addalready succeeded, so the create still returnsok: trueand the failure rides along in ahookpayload (exit code + last 4000 chars of combined output). Nothing in this feature deletes a worktree.hookkey at all — the pre-feature result shape is byte-identical.PLAYGROUND_WORKTREE_PATH,PLAYGROUND_REPO_PATH,PLAYGROUND_BRANCH. Killed after 120 s.Design
withPostCreateHook(create, deps)is a decorator with a signature identical tocreateWorktree, wired once inindex.tsand handed to both the IPC handler andctxDeps.worktree.create— so no call site can opt out, structurally rather than by convention. Chosen over a 7th positional param, a trailing options object, and a module-level setter because it is the only option that leavesworktree-manager.ts(and its ~40 real-git tests) andworkflow-ctx.tscompletely untouched, and it makes the run-iff-created rule unit-testable against a fake create with no git and no spawn.Rationale and the rejected alternatives are recorded as AD-013 in
.specs/STATE.md.Review notes
One accepted trade-off, stated up front: the command is repo content. Cloning an untrusted repo into a registered workspace means its
postCreateCommandruns on your next worktree create for that repo. No prompt, no allowlist in v1 — this was a deliberate decision (it's what makes the command travel with the repo), recorded in the spec's Assumptions table, not an oversight.An independent Verifier (author ≠ verifier) returned FAIL on the first pass, on a real blocker:
runHookShellresolved onclose, which waits for stdio EOF.spawn'stimeoutkills onlycmd.exe, so a surviving grandchild holds the inherited pipes — measuredexitat 1665 ms vscloseat 12969 ms, and 21000 ms with a detached grandchild. For a genuinely hung script the promise never settled:worktrees:createnever resolved and the dialog stuck onbusyforever.Fixed in
0291a70+663e2d3by settling on whichever comes first —close(complete output, normal path) orexitplus a 250 ms flush grace (guaranteed progress) — and extracted tosrc/main/hook-shell.tsso the settle condition carries real-process tests. Re-probed after the fix: pipe-holding child +119 ms, detached grandchild +467 ms,pause+152 ms, infinite loop +94 ms; output verified complete to a 1 MB single burst. Round 2 PASS, 4/4 findings closed. Full report:.specs/features/worktree-post-create-hook/validation.md.Two mutation survivors were accepted with reasons, not papered over:
HOOK_FLUSH_GRACE_MS 250→0and removing theclosehandler are equivalent mutants — queueddataevents drain before the timer callback either way, so only latency differs, and asserting sub-250 ms latency would be flaky. Verified empirically both directions.Gates
npm run typecheckclean ·npm run lint0 errors (18 pre-existing warnings inscripts/) · 533 tests / 39 files green (baseline 489) ·npm run buildandnpm run build:winclean.npm testwith the default worker count is unreliable on my machine — real-git tests intree.test.ts/worktree-manager.test.tsintermittently exceed their 5000 ms default timeout under load (observed 5.1 / 6.1 / 8.4 / 44 s), then cascade intoEPERMinafterEachbecause the timed-out git child still holds the temp dir. The failing subset differs per run and both files pass 71/71 in isolation; neither is modified by this PR.npx vitest run --maxWorkers=2runs the identical suite reliably and faster (81–125 s vs 300 s). AtestTimeout/maxWorkerssetting invitest.config.tswould fix it properly — deliberately not done here as it is outside this feature's scope, and worth its own change.Not verified
The dialog UI has not been visually confirmed. WPC-12..16 are marked
Built †in the spec, notVerified: implemented, typechecked, built and inspected, but the renderer carries no unit tests by project convention and these dialogs have never been rendered. The spec's Success Criteria (real junctions appearing in a real worktree form:\triade\source\Code, and the same via a workflow) are likewise unconfirmed on hardware.No linked issue — none existed for this feature, so there is no closing keyword.
Scope
21 P1 requirements delivered (WPC-01..16, WPC-20..24). WPC-17..19 — a hook detail box on the workflow run timeline — are deferred to P2: they need a new shared
StepDetailvariant plus aRunDetailbranch, andresult.hookis already reachable by a workflow author without them.🤖 Generated with Claude Code