dsh-plugin-worktrees 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,673 @@
1
+ /**
2
+ * WorktreeService — worktree-mode CRUD (DESIGN §5.2-§5.5, §5.8).
3
+ *
4
+ * Owns the create / list / status / cleanup lifecycle of task worktrees on
5
+ * top of two injected collaborators:
6
+ * - `git` : a GitPort (lib/git-port.js) — the ONLY git entry point;
7
+ * - `store` : a StateStore (lib/state-store.js) — the ONLY persistence.
8
+ *
9
+ * Ported semantics from task-weaver
10
+ * `packages/workspaces/src/workspace-service.ts`:
11
+ * - createWorktree flow (L244-360): validate name → duplicate check →
12
+ * resolve base → create worktree → persist record;
13
+ * - the mutable-HEAD prohibition (L268-284 "saved Run base ... is
14
+ * required; current HEAD fallback is forbidden") — the stored
15
+ * `baseCommit` is ALWAYS the concrete oid resolved by
16
+ * `git.resolveRef`, never a symbolic ref;
17
+ * - the physical path layout (L650-661) — re-expressed through
18
+ * lib/naming.js `worktreePath` (`<root>/<repoKey>/<session>/<slug>`).
19
+ *
20
+ * Error shape: every failure throws `Error('<code> — <detail>')` where code
21
+ * is the DESIGN §5.3/§5.8 error family (`invalid_task_slug`,
22
+ * `invalid_integration_branch`, `branch_exists`, `max_worktrees_reached`,
23
+ * `invalid_base_ref`, `worktree_creation_failed`, `worktree_not_found`,
24
+ * `cleanup_protected`).
25
+ * The tool layer (T07) prefixes the tool name; the codes stay machine
26
+ * matchable. Git stderr embedded into a detail is truncated to 600 chars.
27
+ */
28
+
29
+ import { randomBytes } from 'node:crypto'
30
+ import { existsSync, mkdirSync, realpathSync } from 'node:fs'
31
+ import path from 'node:path'
32
+ import {
33
+ integrationBranch as defaultIntegrationBranch,
34
+ repoIdFromRoot,
35
+ sanitizeBranch,
36
+ sessionShortOf,
37
+ taskBranch,
38
+ worktreePath,
39
+ } from './naming.js'
40
+ import { ACTIVE_JOB_STATES } from './state-store.js'
41
+
42
+ /** Truncation bound for git stderr / error details in thrown messages. */
43
+ const DETAIL_MAX = 600
44
+
45
+ /**
46
+ * Worktree record states that end the record's lifecycle (DESIGN §5.2.2).
47
+ * A NEW record with the same branch may be created once the old record is
48
+ * terminal; terminal records also stop counting against maxWorktrees.
49
+ */
50
+ const TERMINAL_WORKTREE_STATES = new Set(['merged', 'abandoned', 'vanished'])
51
+
52
+ /**
53
+ * States counted against `config.maxWorktrees` (DESIGN §6.1: the limit is a
54
+ * GLOBAL count across repos). `conflicted` worktrees are retained scenes,
55
+ * not occupied isolation slots, so they do not count.
56
+ */
57
+ const COUNTED_WORKTREE_STATES = new Set(['active', 'merging'])
58
+
59
+ /** Fail loud with a machine-matchable code prefix. */
60
+ function fail(code, detail) {
61
+ throw new Error(`${code} — ${detail}`)
62
+ }
63
+
64
+ /** Clamp a detail string to DETAIL_MAX chars with an explicit marker. */
65
+ function clampDetail(text, max = DETAIL_MAX) {
66
+ const value = String(text ?? '')
67
+ if (value.length <= max) return value
68
+ return `${value.slice(0, max)}…[truncated ${value.length - max} chars]`
69
+ }
70
+
71
+ /** realpath() that yields null instead of throwing (missing paths probe). */
72
+ function realPathBestEffort(target) {
73
+ try {
74
+ return realpathSync(target)
75
+ } catch {
76
+ return null
77
+ }
78
+ }
79
+
80
+ /** Fresh worktree record id: `wt_` + 8 hex chars (crypto.randomBytes). */
81
+ function newWorktreeId() {
82
+ return `wt_${randomBytes(4).toString('hex')}`
83
+ }
84
+
85
+ /**
86
+ * Create a WorktreeService.
87
+ *
88
+ * @param {{
89
+ * git: import('./git-port.js').NodeGitPort,
90
+ * store: ReturnType<import('./state-store.js').createStateStore>,
91
+ * config: { worktreeRoot: string, maxWorktrees?: number, defaultBaseRef?: string },
92
+ * }} deps
93
+ * `git` and `store` are injected (tests / future host seams); `config`
94
+ * carries the RESOLVED final values — defaults are applied here only as
95
+ * defensive fallbacks (DESIGN §9 defaults: maxWorktrees 16,
96
+ * defaultBaseRef 'HEAD').
97
+ * @returns {{
98
+ * create: (params: {
99
+ * task: string, repoRoot: string, repoKey?: string,
100
+ * baseRef?: string, integrationBranch?: string,
101
+ * note?: string, sessionId: string,
102
+ * origin?: 'tool'|'dag', correlationId?: string,
103
+ * }) => Promise<import('./state-store.js').WorktreeRecord>,
104
+ * list: (params?: { repoRoot?: string }) => Promise<{
105
+ * worktrees: Array<Record<string, unknown>>,
106
+ * queueSummary: { activeJobs: Array<Record<string, unknown>>, queuedCount: number },
107
+ * }>,
108
+ * status: (idOrPath: string) => Promise<Record<string, unknown>>,
109
+ * cleanup: (params: {
110
+ * idOrPath: string, force?: boolean, acknowledge?: boolean, keepBranch?: boolean,
111
+ * }) => Promise<{ id: string, removedWorktree: boolean, removedBranch: boolean, note?: string }>,
112
+ * findActiveByTask: (repoRoot: string, task: string) => Promise<import('./state-store.js').WorktreeRecord|null>,
113
+ * }}
114
+ */
115
+ export function createWorktreeService({ git, store, config } = {}) {
116
+ if (!git || typeof git.createWorktree !== 'function') {
117
+ throw new Error('worktree-service: a GitPort must be injected (missing createWorktree)')
118
+ }
119
+ if (!store || typeof store.upsertWorktree !== 'function') {
120
+ throw new Error('worktree-service: a StateStore must be injected (missing upsertWorktree)')
121
+ }
122
+ if (!config || typeof config.worktreeRoot !== 'string' || config.worktreeRoot.length === 0) {
123
+ throw new Error('worktree-service: config.worktreeRoot (resolved) is required')
124
+ }
125
+ const maxWorktrees =
126
+ Number.isFinite(config.maxWorktrees) && config.maxWorktrees > 0
127
+ ? Math.floor(config.maxWorktrees)
128
+ : 16
129
+ const defaultBaseRef =
130
+ typeof config.defaultBaseRef === 'string' && config.defaultBaseRef.length > 0
131
+ ? config.defaultBaseRef
132
+ : 'HEAD'
133
+
134
+ // -------------------------------------------------------------------------
135
+ // Internal helpers
136
+ // -------------------------------------------------------------------------
137
+
138
+ /** Find a record by exact id OR exact stored path (DESIGN §5.5/§5.8 lookups). */
139
+ function findRecord(idOrPath) {
140
+ if (typeof idOrPath !== 'string' || idOrPath === '') return undefined
141
+ const byId = store.worktrees[idOrPath]
142
+ if (byId !== undefined) return byId
143
+ return Object.values(store.worktrees).find((record) => record.path === idOrPath)
144
+ }
145
+
146
+ /**
147
+ * Live worktree paths for one repo root, as a Set (git reports realpaths).
148
+ * `null` = the probe itself failed (repo gone / git error) — callers must
149
+ * NOT interpret a probe failure as orphaning.
150
+ */
151
+ async function livePathsFor(repoRoot) {
152
+ try {
153
+ const entries = await git.listWorktrees(repoRoot)
154
+ return new Set(entries.map((entry) => entry.path))
155
+ } catch {
156
+ return null
157
+ }
158
+ }
159
+
160
+ /** True when a stored path matches the live set (raw OR realpath form). */
161
+ function pathInLiveSet(storedPath, liveSet) {
162
+ if (liveSet.has(storedPath)) return true
163
+ const real = realPathBestEffort(storedPath)
164
+ return real !== null && liveSet.has(real)
165
+ }
166
+
167
+ /**
168
+ * A record is LIVE when its directory exists on disk AND git still lists
169
+ * it as a worktree. Anything else is vanished/orphaned (crash or manual
170
+ * removal) — cleanup prunes the record without touching git.
171
+ */
172
+ async function recordIsLive(record) {
173
+ if (!existsSync(record.path)) return false
174
+ const liveSet = await livePathsFor(record.repoRoot)
175
+ if (liveSet === null) return true // probe failed — trust the disk
176
+ return pathInLiveSet(record.path, liveSet)
177
+ }
178
+
179
+ /**
180
+ * Minimal queue summary projection for list() (DESIGN §5.4 queue_summary).
181
+ * T08 (MergeQueue) owns the real semantics; this projection reads the
182
+ * same store, so it stays correct by construction.
183
+ */
184
+ function queueSummary() {
185
+ const jobs = Object.values(store.jobs)
186
+ const activeJobs = jobs
187
+ .filter((job) => ACTIVE_JOB_STATES.has(job.state))
188
+ .map((job) => ({
189
+ jobId: job.id,
190
+ repoRoot: store.repos[job.repoKey]?.root ?? null,
191
+ integrationBranch: job.integrationBranch,
192
+ state: job.state,
193
+ }))
194
+ const queuedCount = jobs.filter((job) => job.state === 'queued').length
195
+ return { activeJobs, queuedCount }
196
+ }
197
+
198
+ /**
199
+ * Advisory slug suggestion for a branch_exists collision: `<slug>-2`,
200
+ * `<slug>-3`, … skipping branches already recorded (non-terminal) for
201
+ * this repo, falling back to a random suffix.
202
+ */
203
+ function suggestSlug(baseSlug, sessionShort, takenBranches) {
204
+ for (let n = 2; n <= 32; n += 1) {
205
+ const candidate = `${baseSlug}-${n}`
206
+ if (!takenBranches.has(taskBranch(sessionShort, candidate))) return candidate
207
+ }
208
+ return `${baseSlug}-${randomBytes(2).toString('hex')}`
209
+ }
210
+
211
+ // -------------------------------------------------------------------------
212
+ // create (DESIGN §5.3)
213
+ // -------------------------------------------------------------------------
214
+
215
+ /**
216
+ * Create a task worktree: validate the slug → duplicate check →
217
+ * maxWorktrees gate → resolve base to a CONCRETE oid → `git worktree
218
+ * add` → persist the record + register the repo.
219
+ *
220
+ * `repoKey` may be omitted by engine callers (the DAG seam): it is then
221
+ * derived from the canonical repoRoot via `repoIdFromRoot` — identical to
222
+ * what the repo gate computes, so tool- and engine-created records share
223
+ * one key. `origin` ('tool'|'dag') and `correlationId` are PERSISTED on
224
+ * the record (DESIGN §10 seam; the DAG reuse-ownership gate reads the
225
+ * record's correlationId).
226
+ */
227
+ async function create(params = {}) {
228
+ const { task, repoRoot, baseRef, note, sessionId } = params
229
+ const explicitIntegration = params.integrationBranch
230
+ if (typeof repoRoot !== 'string' || repoRoot.length === 0) {
231
+ throw new Error(
232
+ 'worktree-service: create requires repoRoot (resolve it via repo-gate first)',
233
+ )
234
+ }
235
+ // Engine callers (the DAG seam) may omit repoKey; derive it the same
236
+ // way the repo gate does so both entry points agree on one key.
237
+ const repoKey =
238
+ typeof params.repoKey === 'string' && params.repoKey.length > 0
239
+ ? params.repoKey
240
+ : repoIdFromRoot(realpathSync(repoRoot))
241
+ // Normalise the PERSISTED repoRoot to its realpath — the canonical form
242
+ // the tool path already stores (it routes through repo-gate, which
243
+ // canonicalises). A symlinked/`..` engine caller would otherwise store a
244
+ // RAW root whose exact-string comparison fails against the canonical
245
+ // findActiveByTask argument — the repoRoot asymmetry (audit finding).
246
+ // realpath failure falls back to the raw value, preserving the old
247
+ // "store verbatim" behaviour.
248
+ const canonicalRoot = realPathBestEffort(repoRoot) ?? repoRoot
249
+
250
+ // 1. Slug: empty or alnum-free sanitisation is unusable as a branch token.
251
+ // `sanitizeBranch` maps illegal chars to `_`, so an all-illegal task
252
+ // yields a pure `_` slug — meaningless and indistinguishable between
253
+ // different tasks; reject it alongside the empty case.
254
+ const slug = sanitizeBranch(task)
255
+ if (slug.length === 0 || !/[a-zA-Z0-9]/.test(slug)) {
256
+ fail(
257
+ 'invalid_task_slug',
258
+ `task ${JSON.stringify(task)} sanitizes to an unusable branch token ${JSON.stringify(slug)}; ` +
259
+ 'the task slug needs at least one [a-zA-Z0-9_.-] character',
260
+ )
261
+ }
262
+ const sessionShort = sessionShortOf(sessionId) // TypeError on bad input = caller contract
263
+ const branch = taskBranch(sessionShort, task)
264
+
265
+ // 2. git-level name validation. validateBranch returns a boolean (the
266
+ // port seam does not surface stderr), so the message names the
267
+ // offending branch instead of quoting git output.
268
+ if (!(await git.validateBranch(repoRoot, branch))) {
269
+ fail(
270
+ 'invalid_task_slug',
271
+ `branch name "${branch}" (from task ${JSON.stringify(task)}) is rejected by ` +
272
+ 'git check-ref-format --branch (e.g. "..", leading "-", trailing ".lock", or a lockfile-colliding name)',
273
+ )
274
+ }
275
+
276
+ // 2b. An EXPLICIT integration branch gets the SAME check-ref-format
277
+ // vetting (audit P1-B): the value flows unvalidated into
278
+ // `git branch <name> <startPoint>` (ensureBranch) and `mergeNoFf`
279
+ // at first merge — a flag-shaped name (`-m`, `--force`) lands in
280
+ // flag position there. Only the explicit form is checked: the
281
+ // derived default (`dsh-wt/integration/<sessionShort>`) is
282
+ // constructed from a sanitized session segment and needs no probe.
283
+ if (
284
+ typeof explicitIntegration === 'string'
285
+ && explicitIntegration.length > 0
286
+ && !(await git.validateBranch(repoRoot, explicitIntegration))
287
+ ) {
288
+ fail(
289
+ 'invalid_integration_branch',
290
+ `integration branch name "${explicitIntegration}" is rejected by ` +
291
+ 'git check-ref-format --branch (legal branch names: no leading "-" or "-", no "..", ' +
292
+ 'no ASCII control characters or spaces, no trailing ".lock", no "@{"/"@"-only, ' +
293
+ 'no ref-component starting with "."; slashes are allowed, e.g. "dsh-wt/integration/x")',
294
+ )
295
+ }
296
+
297
+ // 3. Duplicate check — store side (non-terminal record, same repo) and
298
+ // git side (the branch already resolves).
299
+ const takenBranches = new Set(
300
+ Object.values(store.worktrees)
301
+ .filter((record) => record.repoKey === repoKey && !TERMINAL_WORKTREE_STATES.has(record.state))
302
+ .map((record) => record.branch),
303
+ )
304
+ const storeDuplicate = takenBranches.has(branch)
305
+ let gitHasBranch = false
306
+ try {
307
+ await git.resolveRef(repoRoot, branch)
308
+ gitHasBranch = true
309
+ } catch {
310
+ // Branch does not resolve — the normal, expected case.
311
+ }
312
+ if (storeDuplicate || gitHasBranch) {
313
+ const suggestion = suggestSlug(slug, sessionShort, new Set([...takenBranches, branch]))
314
+ fail(
315
+ 'branch_exists',
316
+ `branch ${branch} already exists for repo ${repoKey}` +
317
+ `${storeDuplicate ? ' (active worktree record)' : ' (in git)'}` +
318
+ `; try task slug "${suggestion}" instead`,
319
+ )
320
+ }
321
+
322
+ // 4. maxWorktrees — global count of active/merging records (DESIGN §6.1).
323
+ const activeCount = Object.values(store.worktrees).filter((record) =>
324
+ COUNTED_WORKTREE_STATES.has(record.state),
325
+ ).length
326
+ if (activeCount >= maxWorktrees) {
327
+ fail(
328
+ 'max_worktrees_reached',
329
+ `${activeCount} active/merging worktrees already recorded (limit ${maxWorktrees}); ` +
330
+ 'run worktree_cleanup on finished worktrees first (merged records need no force; ' +
331
+ 'unmerged ones require BOTH force:true AND acknowledge:true)',
332
+ )
333
+ }
334
+
335
+ // 5. Base — ALWAYS resolved to a concrete oid before it reaches the
336
+ // record. Mutable-HEAD fallback is forbidden (task-weaver L268-284):
337
+ // a symbolic base would silently drift when the repo HEAD moves.
338
+ // `resolveRef` returns the peeled commit oid, which satisfies the
339
+ // invariant by construction.
340
+ const effectiveBaseRef = baseRef ?? defaultBaseRef
341
+ let baseOid
342
+ try {
343
+ baseOid = await git.resolveRef(repoRoot, effectiveBaseRef)
344
+ } catch (error) {
345
+ fail(
346
+ 'invalid_base_ref',
347
+ `cannot resolve base ref "${effectiveBaseRef}" in ${repoRoot}: ${clampDetail(error.message)}`,
348
+ )
349
+ }
350
+
351
+ const integration =
352
+ typeof explicitIntegration === 'string' && explicitIntegration.length > 0
353
+ ? explicitIntegration
354
+ : defaultIntegrationBranch(sessionShort)
355
+
356
+ // 6. Physical layout `<worktreeRoot>/<repoKey>/<sessionShort>/<slug>`
357
+ // (naming.worktreePath). The PARENT is pre-created; git requires the
358
+ // leaf to not exist and creates it itself (source L633-635).
359
+ const wtPath = worktreePath(config.worktreeRoot, repoKey, sessionShort, slug)
360
+ mkdirSync(path.dirname(wtPath), { recursive: true })
361
+ try {
362
+ await git.createWorktree(repoRoot, wtPath, branch, baseOid)
363
+ } catch (error) {
364
+ fail(
365
+ 'worktree_creation_failed',
366
+ `git worktree add for branch ${branch} at ${wtPath} failed: ${clampDetail(error.message)}`,
367
+ )
368
+ }
369
+
370
+ // 7. Persist. The repos registration is the crash-reconcile enumeration
371
+ // source (§5.2.2); StateStore has no upsertRepos, so the live map is
372
+ // written directly — persist() re-sanitizes a deep copy on flush.
373
+ // origin/correlationId (DESIGN §10 seam): 'tool' by default, 'dag'
374
+ // when the engine seam created it; correlationId is the DAG
375
+ // attempt id (the reuse-ownership gate's evidence).
376
+ const now = Date.now()
377
+ const origin = params.origin === 'dag' ? 'dag' : 'tool'
378
+ const { correlationId } = params
379
+ const record = store.upsertWorktree({
380
+ id: newWorktreeId(),
381
+ repoKey,
382
+ repoRoot: canonicalRoot,
383
+ branch,
384
+ path: wtPath,
385
+ baseCommit: baseOid,
386
+ integrationBranch: integration,
387
+ sessionId,
388
+ task,
389
+ state: 'active',
390
+ ...(note !== undefined ? { note } : {}),
391
+ origin,
392
+ ...(correlationId !== undefined && correlationId !== null ? { correlationId } : {}),
393
+ createdAt: now,
394
+ updatedAt: now,
395
+ })
396
+ store.repos[repoKey] = { root: canonicalRoot, lastSeenAt: Date.now() }
397
+ store.persist()
398
+ return record
399
+ }
400
+
401
+ // -------------------------------------------------------------------------
402
+ // list (DESIGN §5.4)
403
+ // -------------------------------------------------------------------------
404
+
405
+ /**
406
+ * List worktree records (optionally filtered by exact repoRoot), each
407
+ * reconciled against `git worktree list`: a record whose path is absent
408
+ * from the live listing gets `orphaned: true` (state kept verbatim).
409
+ */
410
+ async function list(params = {}) {
411
+ const { repoRoot } = params ?? {}
412
+ const records = Object.values(store.worktrees).filter((record) =>
413
+ repoRoot === undefined ? true : record.repoRoot === repoRoot,
414
+ )
415
+ const liveByRoot = new Map()
416
+ const out = []
417
+ for (const record of records) {
418
+ let liveSet = liveByRoot.get(record.repoRoot)
419
+ if (liveSet === undefined) {
420
+ liveSet = await livePathsFor(record.repoRoot)
421
+ liveByRoot.set(record.repoRoot, liveSet)
422
+ }
423
+ const orphaned = liveSet !== null && !pathInLiveSet(record.path, liveSet)
424
+ out.push(orphaned ? { ...record, orphaned: true } : { ...record })
425
+ }
426
+ return { worktrees: out, queueSummary: queueSummary() }
427
+ }
428
+
429
+ // -------------------------------------------------------------------------
430
+ // status (DESIGN §5.5)
431
+ // -------------------------------------------------------------------------
432
+
433
+ /**
434
+ * Live status of one worktree (by id or exact path): HEAD, dirty flag,
435
+ * porcelain changes, and whether HEAD moved past baseCommit.
436
+ */
437
+ async function status(idOrPath) {
438
+ const record = findRecord(idOrPath)
439
+ if (record === undefined) {
440
+ fail('worktree_not_found', `no worktree record matches id or path "${idOrPath}"`)
441
+ }
442
+
443
+ // Best-effort probes: a worktree that vanished (dir deleted, disk gone)
444
+ // returns the record with `vanished: true` instead of failing.
445
+ if (!existsSync(record.path)) {
446
+ return { ...record, vanished: true }
447
+ }
448
+ let changes
449
+ let head
450
+ try {
451
+ const entries = await git.status(record.path)
452
+ changes = entries.map((entry) => ({ path: entry.path, status: entry.status }))
453
+ head = (await git.resolveHead(record.path)).commit
454
+ } catch {
455
+ return { ...record, vanished: true }
456
+ }
457
+
458
+ // DESIGN §5.5 specifies `ahead_of_base: n` (commit count via
459
+ // rev-list --count). The GitPort face has no rev-list count method, so
460
+ // this implementation reports the BOOLEAN projection
461
+ // `ahead_of_base = isAncestor(baseCommit, head) && head !== baseCommit`
462
+ // — deliberately NOT adding countAhead to the port (DESIGN §5.8 keeps
463
+ // the count "留给后置"; the tool layer can extend later).
464
+ let aheadOfBase = false
465
+ if (head && head !== record.baseCommit) {
466
+ try {
467
+ aheadOfBase = await git.isAncestor(record.repoRoot, record.baseCommit, head)
468
+ } catch {
469
+ aheadOfBase = false // probe failure is not "ahead"
470
+ }
471
+ }
472
+ return { ...record, head, dirty: changes.length > 0, changes, ahead_of_base: aheadOfBase }
473
+ }
474
+
475
+ // -------------------------------------------------------------------------
476
+ // findActiveByTask (DESIGN §10 seam — the DAG reuse probe)
477
+ // -------------------------------------------------------------------------
478
+
479
+ /**
480
+ * The ACTIVE worktree record for one (repoRoot, task slug), or null.
481
+ *
482
+ * Consumer contract (dsh-dag-orchestrator lib/worktrees-seam.js /
483
+ * executor.js resolveWorktreeCwd): the dispatch-time reuse probe consults
484
+ * this BEFORE creating — a same-slug active record means a re-dispatched
485
+ * task resumes in its existing worktree instead of stacking a second
486
+ * one. The returned record carries its `correlationId` (the create-time
487
+ * DAG attempt id): the DAG's OWNERSHIP gate reuses the path only when
488
+ * that id belongs to the dispatching task's own attempt history — this
489
+ * side just reports the facts.
490
+ *
491
+ * Lookup key: the RAW task string recorded at create time (records store
492
+ * the pre-sanitisation slug verbatim).
493
+ *
494
+ * @param {string} repoRoot canonical repo root (compared to record.repoRoot)
495
+ * @param {string} task task slug as passed to create()
496
+ * @returns {Promise<import('./state-store.js').WorktreeRecord|null>}
497
+ */
498
+ async function findActiveByTask(repoRoot, task) {
499
+ if (typeof repoRoot !== 'string' || typeof task !== 'string') return null
500
+ for (const record of Object.values(store.worktrees)) {
501
+ if (record.repoRoot !== repoRoot) continue
502
+ if (record.task !== task) continue
503
+ if (record.state !== 'active') continue
504
+ return record
505
+ }
506
+ return null
507
+ }
508
+
509
+ // -------------------------------------------------------------------------
510
+ // cleanup (DESIGN §5.8)
511
+ // -------------------------------------------------------------------------
512
+
513
+ /**
514
+ * Remove one worktree (+ its branch unless keepBranch) after the
515
+ * unmerged-work protection gate, or prune a vanished record.
516
+ */
517
+ async function cleanup(params = {}) {
518
+ const { idOrPath, force = false, acknowledge = false, keepBranch = false } = params ?? {}
519
+ const record = findRecord(idOrPath)
520
+ if (record === undefined) {
521
+ fail('worktree_not_found', `no worktree record matches id or path "${idOrPath}"`)
522
+ }
523
+
524
+ // Vanished/orphaned record: the worktree directory is already gone, so
525
+ // there is nothing to protect (no working state) and nothing to remove.
526
+ // Prune the record ONLY — no git side effects, branch left untouched
527
+ // (deleting branches of vanished worktrees is an explicit operator
528
+ // decision, not cleanup's default).
529
+ if (!(await recordIsLive(record))) {
530
+ delete store.worktrees[record.id]
531
+ store.persist()
532
+ return {
533
+ id: record.id,
534
+ removedWorktree: false,
535
+ removedBranch: false,
536
+ note: 'record pruned; path already absent from git worktree list',
537
+ }
538
+ }
539
+
540
+ // Protection (safety red line 4): a non-merged record whose branch is
541
+ // NOT integrated into its integration branch may only be removed with
542
+ // the DOUBLE confirmation force && acknowledge (two independent
543
+ // booleans — a single force:true never passes).
544
+ //
545
+ // Two evidence arms decide "unmerged":
546
+ // - integration branch RESOLVES → the classic probe: sourceHead must
547
+ // be an ancestor of the integration head;
548
+ // - integration branch does NOT resolve (never bootstrapped — the
549
+ // pre-first-merge window, audit P1-C) → the worktree's own head is
550
+ // compared against the recorded baseCommit: any commit past base is
551
+ // work that exists nowhere else, so it is unmerged BY DEFINITION
552
+ // and the same double-confirmation gate fires.
553
+ // A worktree whose branch is gone AND whose head sits at base (or
554
+ // cannot move past base) has no unmerged work — clearable bare.
555
+ if (record.state !== 'merged' && !(force && acknowledge)) {
556
+ let sourceHead = null
557
+ let integrationHead = null
558
+ try {
559
+ sourceHead = await git.resolveRef(record.repoRoot, record.branch)
560
+ } catch {
561
+ // Branch gone — the head probe below still guards the worktree dir.
562
+ }
563
+ try {
564
+ integrationHead = await git.resolveRef(record.repoRoot, record.integrationBranch)
565
+ } catch {
566
+ // Integration branch never bootstrapped — the P1-C arm decides.
567
+ }
568
+ if (sourceHead !== null && integrationHead !== null) {
569
+ let integrated
570
+ try {
571
+ integrated = await git.isAncestor(record.repoRoot, sourceHead, integrationHead)
572
+ } catch (error) {
573
+ // Cannot PROVE integration → fail closed under the same code.
574
+ fail(
575
+ 'cleanup_protected',
576
+ `integration probe failed for worktree ${record.id}: ${clampDetail(error.message)}`,
577
+ )
578
+ }
579
+ if (!integrated) {
580
+ fail(
581
+ 'cleanup_protected',
582
+ `worktree ${record.id} (task ${JSON.stringify(record.task)}) branch ${record.branch} ` +
583
+ `is NOT integrated: sourceHead ${sourceHead} is not an ancestor of ` +
584
+ `${record.integrationBranch} head ${integrationHead}; to delete unmerged work ` +
585
+ 'pass BOTH force:true AND acknowledge:true (single force is not enough), ' +
586
+ 'or integrate first via worktree_merge',
587
+ )
588
+ }
589
+ } else {
590
+ // P1-C arm: the integration branch does not resolve (or the task
591
+ // branch is gone while the worktree still holds commits). Unmerged
592
+ // work exists when the worktree holds EITHER commits past its
593
+ // recorded baseCommit OR uncommitted changes in its probe — both are
594
+ // work that exists nowhere else — so either triggers protection.
595
+ let worktreeHead = null
596
+ try {
597
+ worktreeHead = (await git.resolveHead(record.path)).commit
598
+ } catch {
599
+ // Unresolvable head (a live worktree per the probe above should
600
+ // always resolve) — cannot PROVE absence of work: fail closed.
601
+ fail(
602
+ 'cleanup_protected',
603
+ `worktree ${record.id} (task ${JSON.stringify(record.task)}) head is unresolvable ` +
604
+ `at ${record.path}; cannot PROVE it holds no unmerged work, so bare cleanup is ` +
605
+ 'refused — explicitly pass BOTH force:true AND acknowledge:true to delete, ' +
606
+ 'or integrate first via worktree_merge',
607
+ )
608
+ }
609
+ // Uncommitted changes are unmerged work even when HEAD sits at base
610
+ // (README: "no commits past base AND no uncommitted work in the
611
+ // branch probe" is the clearable bare bar). A porcelain flake here
612
+ // cannot PROVE the tree is clean → fail closed (protect).
613
+ let dirty = true
614
+ try {
615
+ const entries = await git.status(record.path)
616
+ dirty = Array.isArray(entries) && entries.length > 0
617
+ } catch {
618
+ // status probe failure → keep the fail-closed default (dirty=true).
619
+ }
620
+ if (dirty) {
621
+ fail(
622
+ 'cleanup_protected',
623
+ `worktree ${record.id} (task ${JSON.stringify(record.task)}) holds UNMERGED work: ` +
624
+ `integration branch ${record.integrationBranch} does not exist yet (no merge has ` +
625
+ 'run) and the worktree probe has uncommitted changes present; ' +
626
+ 'to delete unmerged work pass BOTH force:true AND acknowledge:true ' +
627
+ '(single force is not enough), or integrate first via worktree_merge',
628
+ )
629
+ }
630
+ // Ahead of base: committed work past base that exists nowhere else.
631
+ // head being a STRICT ANCESTOR of base (head behind base) is the ONLY
632
+ // safe case — a divergent/unrelated head holds unique commits, so it
633
+ // is unmerged. probe failure → assume ahead (fail closed).
634
+ const ahead =
635
+ worktreeHead !== record.baseCommit
636
+ && !(await git
637
+ .isAncestor(record.repoRoot, worktreeHead, record.baseCommit)
638
+ .catch(() => false)) // failure → "not a strict ancestor" → ahead
639
+ if (ahead) {
640
+ fail(
641
+ 'cleanup_protected',
642
+ `worktree ${record.id} (task ${JSON.stringify(record.task)}) holds UNMERGED work: ` +
643
+ `integration branch ${record.integrationBranch} does not exist yet (no merge has ` +
644
+ `run) while the worktree head ${worktreeHead} is ahead of its base ${record.baseCommit}; ` +
645
+ 'to delete unmerged work pass BOTH force:true AND acknowledge:true ' +
646
+ '(single force is not enough), or integrate first via worktree_merge',
647
+ )
648
+ }
649
+ }
650
+ }
651
+
652
+ // Remove the worktree (idempotent) and — unless keepBranch — the branch.
653
+ await git.removeWorktree(record.repoRoot, record.path)
654
+ let removedBranch = false
655
+ if (keepBranch !== true) {
656
+ try {
657
+ await git.deleteBranch(record.repoRoot, record.branch)
658
+ removedBranch = true
659
+ } catch (error) {
660
+ // `error: branch 'x' not found` = already gone (idempotent prune);
661
+ // any other failure (e.g. branch checked out elsewhere) surfaces.
662
+ if (!/not found/i.test(String(error?.message ?? ''))) throw error
663
+ removedBranch = false
664
+ }
665
+ }
666
+
667
+ delete store.worktrees[record.id]
668
+ store.persist()
669
+ return { id: record.id, removedWorktree: true, removedBranch }
670
+ }
671
+
672
+ return { create, list, status, cleanup, findActiveByTask }
673
+ }