Skip to content

feat(worktree): run a repo-declared command after worktree creation - #71

Merged
obogoni merged 13 commits into
mainfrom
feature/worktree-post-create-hook
Jul 30, 2026
Merged

feat(worktree): run a repo-declared command after worktree creation#71
obogoni merged 13 commits into
mainfrom
feature/worktree-post-create-hook

Conversation

@obogoni

@obogoni obogoni commented Jul 30, 2026

Copy link
Copy Markdown
Owner

What

A repo can declare one shell command that runs automatically in every newly created worktree, with cwd set to that worktree:

// <repo>/.app/config.json — checked in, so it travels with the repo
{ "postCreateCommand": ".\SetupSkills.cmd" }

Motivating case: m:\triade\source\Code ships SetupSkills.cmd, which creates the .claude\skills / .codex\skills junctions 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

  • Runs on all three create paths — New Worktree, Start Work, and workflow ctx.worktree.create.
  • Runs iff a worktree was actually produced. A branch-exists conflict, an empty rendered template, an existing target, a blocked base refresh or a failed git worktree add all skip it; the successful reuse/recreate paths do run it.
  • A failing command never costs you the worktree. git worktree add already succeeded, so the create still returns ok: true and the failure rides along in a hook payload (exit code + last 4000 chars of combined output). Nothing in this feature deletes a worktree.
  • The dialogs show an amber advisory (amber, not red — the create succeeded) with the created path, the command, the exit code or a timeout label, and the output tail. Continue runs the same post-create flow as the happy path, so the worktree is never stranded.
  • A repo that declares nothing gets no hook key at all — the pre-feature result shape is byte-identical.
  • Command context: inherited env plus PLAYGROUND_WORKTREE_PATH, PLAYGROUND_REPO_PATH, PLAYGROUND_BRANCH. Killed after 120 s.

Design

withPostCreateHook(create, deps) is a decorator with a signature identical to createWorktree, wired once in index.ts and handed to both the IPC handler and ctxDeps.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 leaves worktree-manager.ts (and its ~40 real-git tests) and workflow-ctx.ts completely 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 postCreateCommand runs 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: runHookShell resolved on close, which waits for stdio EOF. spawn's timeout kills only cmd.exe, so a surviving grandchild holds the inherited pipes — measured exit at 1665 ms vs close at 12969 ms, and 21000 ms with a detached grandchild. For a genuinely hung script the promise never settled: worktrees:create never resolved and the dialog stuck on busy forever.

Fixed in 0291a70 + 663e2d3 by settling on whichever comes first — close (complete output, normal path) or exit plus a 250 ms flush grace (guaranteed progress) — and extracted to src/main/hook-shell.ts so 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→0 and removing the close handler are equivalent mutants — queued data events 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 typecheck clean · npm run lint 0 errors (18 pre-existing warnings in scripts/) · 533 tests / 39 files green (baseline 489) · npm run build and npm run build:win clean.

⚠️ npm test with the default worker count is unreliable on my machine — real-git tests in tree.test.ts / worktree-manager.test.ts intermittently exceed their 5000 ms default timeout under load (observed 5.1 / 6.1 / 8.4 / 44 s), then cascade into EPERM in afterEach because 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=2 runs the identical suite reliably and faster (81–125 s vs 300 s). A testTimeout/maxWorkers setting in vitest.config.ts would 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, not Verified: 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 for m:\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 StepDetail variant plus a RunDetail branch, and result.hook is already reachable by a workflow author without them.

🤖 Generated with Claude Code

obogoni and others added 13 commits July 29, 2026 17:09
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>
@obogoni
obogoni merged commit 54cf725 into main Jul 30, 2026
2 checks passed
@obogoni
obogoni deleted the feature/worktree-post-create-hook branch July 30, 2026 13:27
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.

1 participant