@vibecompass/vibecompass 0.10.8 → 0.11.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,676 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { realpath, rm, stat } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { promisify } from 'node:util';
5
+ import { assertMarkerTargetDisjoint, findEnclosingGitDir, readLaneMarker } from './lane-marker.js';
6
+ import { isSimpleYamlKeySafe } from './simple-yaml.js';
7
+
8
+ const execFileAsync = promisify(execFile);
9
+
10
+ // Cheap read-only probes (rev-parse, check-ref-format, worktree list) keep the
11
+ // docs-update timeout; worktree add/remove perform full checkouts and get a
12
+ // generous budget — a 5s SIGTERM mid-checkout is exactly the half-created
13
+ // state the rollback ordering exists to avoid.
14
+ const QUICK_GIT_TIMEOUT_MS = 5000;
15
+ const WORKTREE_GIT_TIMEOUT_MS = 120000;
16
+ const GIT_OUTPUT_MAX_BUFFER = 1024 * 1024;
17
+
18
+ /**
19
+ * Repo working copies live as siblings of the memory root: the workspace is
20
+ * `dirname(rootDir)` and a declared `repo.path` (else the repo id) resolves
21
+ * against it. This is the same convention docs-update uses for status scans.
22
+ */
23
+ export function resolveRepoSourceDir(workspaceDir, repoId, projectRepos) {
24
+ const declared = (projectRepos ?? []).find((repo) => repo && typeof repo === 'object' && repo.id === repoId);
25
+ const relative = typeof declared?.path === 'string' && declared.path.trim() !== '' ? declared.path : repoId;
26
+ return path.resolve(workspaceDir, relative);
27
+ }
28
+
29
+ /**
30
+ * D-281: capture base_revisions for every claimed repo at lane start. A
31
+ * non-git directory is skipped silently — git is never mandatory for lanes
32
+ * (D-265/D-279). A git repo with no commits and a repo id that cannot be a
33
+ * session.yaml map key produce warnings instead of blocking the start.
34
+ */
35
+ export async function captureBaseRevisions(options) {
36
+ const baseRevisions = {};
37
+ const warnings = [];
38
+
39
+ for (const repoId of options.repoIds ?? []) {
40
+ if (!isSimpleYamlKeySafe(repoId)) {
41
+ warnings.push(
42
+ `base_revisions skipped for repo "${repoId}": the id is not a safe session.yaml map key (letter or underscore first, then letters, digits, hyphens, underscores).`,
43
+ );
44
+ continue;
45
+ }
46
+
47
+ const repoDir = resolveRepoSourceDir(options.workspaceDir, repoId, options.projectRepos);
48
+ // Only a repository ROOT records a base — `git -C` from a plain
49
+ // subdirectory of some enclosing repo would silently record the outer
50
+ // repo's HEAD as this repo's base.
51
+ const toplevel = await readGitToplevel(repoDir);
52
+ if (!toplevel || !(await isSameDirectoryReal(toplevel, repoDir))) {
53
+ continue;
54
+ }
55
+ const head = await readGitHeadRevision(repoDir);
56
+ if (head.status === 'ok') {
57
+ baseRevisions[repoId] = head.revision;
58
+ } else if (head.status === 'unborn') {
59
+ warnings.push(`base_revisions skipped for repo "${repoId}": ${repoDir} is a git repository with no commits yet.`);
60
+ }
61
+ }
62
+
63
+ return { baseRevisions, warnings };
64
+ }
65
+
66
+ /**
67
+ * S3-4 base-revision staleness probe: the current HEAD of a repo source
68
+ * checkout, with the same repository-root-only semantics as
69
+ * captureBaseRevisions (a nested directory would report the outer repo's
70
+ * HEAD). Returns null for missing/non-git/nested/unborn sources — the
71
+ * caller names unreadable heads for repos that recorded a base (unevaluable
72
+ * staleness pieces are named, not skipped).
73
+ */
74
+ export async function readCurrentRepoHead(repoDir) {
75
+ const toplevel = await readGitToplevel(repoDir);
76
+ if (!toplevel || !(await isSameDirectoryReal(toplevel, repoDir))) {
77
+ return null;
78
+ }
79
+
80
+ const head = await readGitHeadRevision(repoDir);
81
+ return head.status === 'ok' ? head.revision : null;
82
+ }
83
+
84
+ /**
85
+ * `--verify --quiet` exits 1 silently on an unborn HEAD, which also lands in
86
+ * the catch alongside "not a repository" — the git-dir probe separates the
87
+ * two so only real repos without commits warn.
88
+ */
89
+ async function readGitHeadRevision(repoDir) {
90
+ try {
91
+ const { stdout } = await execFileAsync(
92
+ 'git',
93
+ ['-C', repoDir, 'rev-parse', '--verify', '--quiet', 'HEAD'],
94
+ { timeout: QUICK_GIT_TIMEOUT_MS, maxBuffer: GIT_OUTPUT_MAX_BUFFER },
95
+ );
96
+ const revision = stdout.trim();
97
+ if (revision !== '') {
98
+ return { status: 'ok', revision };
99
+ }
100
+
101
+ return { status: 'unborn' };
102
+ } catch {
103
+ return (await isGitRepository(repoDir)) ? { status: 'unborn' } : { status: 'not-a-repo' };
104
+ }
105
+ }
106
+
107
+ async function isGitRepository(repoDir) {
108
+ try {
109
+ await execFileAsync(
110
+ 'git',
111
+ ['-C', repoDir, 'rev-parse', '--git-dir'],
112
+ { timeout: QUICK_GIT_TIMEOUT_MS, maxBuffer: GIT_OUTPUT_MAX_BUFFER },
113
+ );
114
+ return true;
115
+ } catch {
116
+ return false;
117
+ }
118
+ }
119
+
120
+ /**
121
+ * D-281: validate and normalize a branch name with git itself. check-ref-format
122
+ * --branch expands shorthand like `@{-1}` — the normalized stdout is what gets
123
+ * recorded and used, and names containing `@{` are rejected outright so the
124
+ * recorded branch can never diverge from the real one.
125
+ */
126
+ export async function normalizeBranchName(branch) {
127
+ const trimmed = typeof branch === 'string' ? branch.trim() : '';
128
+ if (trimmed === '') {
129
+ throw new Error('--branch requires a non-empty branch name.');
130
+ }
131
+ if (trimmed.includes('@{')) {
132
+ throw new Error(`Branch name "${trimmed}" contains "@{", which git expands as a ref shortcut; use a literal branch name.`);
133
+ }
134
+
135
+ let stdout;
136
+ try {
137
+ ({ stdout } = await execFileAsync(
138
+ 'git',
139
+ ['check-ref-format', '--branch', trimmed],
140
+ { timeout: QUICK_GIT_TIMEOUT_MS, maxBuffer: GIT_OUTPUT_MAX_BUFFER },
141
+ ));
142
+ } catch {
143
+ throw new Error(`Branch name "${trimmed}" is not a valid git branch name (git check-ref-format --branch refused it).`);
144
+ }
145
+
146
+ const normalized = stdout.trim();
147
+ if (normalized === '') {
148
+ throw new Error(`Branch name "${trimmed}" did not normalize to a usable git branch name.`);
149
+ }
150
+
151
+ return normalized;
152
+ }
153
+
154
+ /**
155
+ * D-281/D-266/D-279 git-readiness preflight. Runs before any filesystem write
156
+ * so a refused binding leaves nothing behind. Returns per-repo plans (mode,
157
+ * source dir, base revision, worktree target) plus non-fatal warnings.
158
+ */
159
+ export async function preflightGitBinding(options) {
160
+ const warnings = [];
161
+ const repoIds = options.repoIds ?? [];
162
+ if (repoIds.length === 0) {
163
+ throw new Error('--branch requires at least one --repo so the lane names which repositories it binds.');
164
+ }
165
+
166
+ if (options.worktree) {
167
+ // Placement guard: dirname(root) workspace inference is only sound when
168
+ // the workspace is not itself a git work tree — otherwise the container
169
+ // and its marker would land inside a repository (D-278/D-279).
170
+ const enclosingGitDir = await findEnclosingGitDir(options.workspaceDir);
171
+ if (enclosingGitDir) {
172
+ throw new Error(
173
+ `Cannot provision worktrees: the inferred workspace ${options.workspaceDir} sits inside the git work tree at ${enclosingGitDir}. ` +
174
+ 'The <workspace>/worktrees container must live outside every repository (D-279/D-281). Run without --worktree, or pass --root pointing at a shared memory root whose parent is not a repository.',
175
+ );
176
+ }
177
+
178
+ await assertMarkerTargetDisjoint(options.containerDir, options.rootDir);
179
+ if (await pathExists(options.containerDir)) {
180
+ throw new Error(
181
+ `Cannot provision worktrees: ${options.containerDir} already exists. Remove it or close the lane that owns it first (D-279: provisioning never adopts existing paths).`,
182
+ );
183
+ }
184
+ }
185
+
186
+ const declaredIds = new Set(
187
+ (options.projectRepos ?? [])
188
+ .map((repo) => (repo && typeof repo === 'object' ? repo.id : null))
189
+ .filter(Boolean),
190
+ );
191
+
192
+ const repoPlans = [];
193
+ for (const repoId of repoIds) {
194
+ if (!declaredIds.has(repoId)) {
195
+ throw new Error(`--branch cannot bind repo "${repoId}": it is not declared in project.yaml repos.`);
196
+ }
197
+ if (!isSimpleYamlKeySafe(repoId)) {
198
+ throw new Error(
199
+ `--branch cannot bind repo "${repoId}": the id is not a safe session.yaml map key (letter or underscore first, then letters, digits, hyphens, underscores). Rename the repo id or run without --branch.`,
200
+ );
201
+ }
202
+
203
+ const sourceDir = resolveRepoSourceDir(options.workspaceDir, repoId, options.projectRepos);
204
+ const toplevel = await readGitToplevel(sourceDir);
205
+ if (!toplevel) {
206
+ throw new Error(`--branch requires repo "${repoId}" to be a git repository at ${sourceDir}, but git could not resolve a work tree there. Clone or init it first, or run without --branch.`);
207
+ }
208
+ if (!(await isSameDirectoryReal(toplevel, sourceDir))) {
209
+ throw new Error(`Repo "${repoId}" at ${sourceDir} sits inside the git repository rooted at ${toplevel}; git binding needs the repository root, not a subdirectory.`);
210
+ }
211
+
212
+ // D-266 memory-fork guard, both containment directions. Conservative: an
213
+ // untracked .compass inside the repo is also refused — worktree adds would
214
+ // not fork it, but binding a lane there still invites divergent memory.
215
+ const realRoot = await canonicalizePathReal(options.rootDir);
216
+ const realTop = await canonicalizePathReal(sourceDir);
217
+ if (isPathEqualOrInsideReal(realRoot, realTop)) {
218
+ throw new Error(
219
+ `Cannot git-bind repo "${repoId}": the project-memory root ${options.rootDir} sits inside its work tree at ${sourceDir}, so a per-lane worktree would fork shared memory (D-266). ` +
220
+ 'Run without --branch/--worktree, or pass --root pointing at this project\'s initialized shared memory root outside the repository. ' +
221
+ '(An untracked .compass inside the repository is refused conservatively as well.)',
222
+ );
223
+ }
224
+ if (isPathEqualOrInsideReal(realTop, realRoot)) {
225
+ throw new Error(
226
+ `Cannot git-bind repo "${repoId}": its work tree ${sourceDir} sits inside the project-memory root ${options.rootDir} — code never lives in package memory (D-278).`,
227
+ );
228
+ }
229
+
230
+ const head = await readGitHeadRevision(sourceDir);
231
+ if (head.status !== 'ok') {
232
+ throw new Error(`--branch requires repo "${repoId}" to have at least one commit at ${sourceDir}; commit first so the lane has a base revision to start from.`);
233
+ }
234
+
235
+ // Two probes (reviewer-verified): worktree list only shows checked-out
236
+ // branches, so ref existence needs its own probe — whose stdout is also
237
+ // the reuse-mode base revision.
238
+ const branchTip = await readBranchTip(sourceDir, options.branch);
239
+ const checkedOutAt = await findBranchCheckout(sourceDir, options.branch);
240
+ if (checkedOutAt) {
241
+ if (options.worktree) {
242
+ throw new Error(
243
+ `Branch "${options.branch}" is already checked out at ${checkedOutAt} in repo "${repoId}". Pick another branch or detach that checkout; if nothing is visibly checked out there, a stale registration is blocking it — run \`git -C ${sourceDir} worktree prune\`.`,
244
+ );
245
+ }
246
+ warnings.push(
247
+ `Branch "${options.branch}" is already checked out at ${checkedOutAt} in repo "${repoId}"; branch-only binding records it without touching that checkout.`,
248
+ );
249
+ }
250
+
251
+ if (await isSourceTreeDirty(sourceDir)) {
252
+ warnings.push(`Repo "${repoId}" has uncommitted changes at ${sourceDir}; they will not be part of the lane's base revision.`);
253
+ }
254
+
255
+ const mode = branchTip ? 'reuse' : 'create';
256
+ if (mode === 'reuse' && !checkedOutAt) {
257
+ warnings.push(`Branch "${options.branch}" already exists in repo "${repoId}"; reusing it (base ${branchTip.slice(0, 12)}).`);
258
+ }
259
+
260
+ repoPlans.push({
261
+ repoId,
262
+ sourceDir,
263
+ mode,
264
+ // Reuse mode bases the lane on what actually gets checked out — the
265
+ // existing branch tip — not the source HEAD.
266
+ baseRevision: branchTip ?? head.revision,
267
+ startPoint: head.revision,
268
+ worktreePath: options.worktree ? path.join(options.containerDir, repoId) : null,
269
+ });
270
+ }
271
+
272
+ for (const lane of options.existingLanes ?? []) {
273
+ if (!lane?.branch) {
274
+ continue;
275
+ }
276
+
277
+ const sharedRepos = (lane.repos ?? []).filter((repoId) => repoIds.includes(repoId));
278
+ if (sharedRepos.length === 0) {
279
+ continue;
280
+ }
281
+
282
+ if (lane.branch === options.branch) {
283
+ throw new Error(
284
+ `Active lane "${lane.id}" already binds branch "${options.branch}" in shared repo(s) ${sharedRepos.join(', ')}; two lanes cannot share a branch. Pick a different --branch.`,
285
+ );
286
+ }
287
+
288
+ warnings.push(
289
+ `Active lane "${lane.id}" binds branch "${lane.branch}" in shared repo(s) ${sharedRepos.join(', ')}; diverging branches on the same repo reconcile through normal git merge (D-266).`,
290
+ );
291
+ }
292
+
293
+ return { repoPlans, warnings };
294
+ }
295
+
296
+ /**
297
+ * Executes the git side of a binding. Ref creation is always its own command
298
+ * (`git branch <name> <start-point>`) and is recorded as owned only AFTER it
299
+ * succeeds: a branch that appeared externally in the preflight→provision race
300
+ * window — even at the same tip — makes the create fail with no ownership
301
+ * record, so rollback can never delete a ref this call did not create
302
+ * (D-281). Worktree checkouts then always use the reuse form
303
+ * `worktree add <path> <branch>` (the -b form would conflate ref creation
304
+ * with checkout, and the plain-commit form would produce a detached HEAD).
305
+ * Worktree PATHS are still recorded before their command runs — the
306
+ * container is exclusively in-call (preflight refused an existing one), so
307
+ * path ownership is unambiguous and a mid-checkout kill must stay removable.
308
+ */
309
+ export async function provisionGitBinding(options, progress) {
310
+ for (const plan of options.repoPlans) {
311
+ if (plan.mode === 'create') {
312
+ await runProvisioningGit(
313
+ ['-C', plan.sourceDir, 'branch', options.branch, plan.startPoint],
314
+ `create branch "${options.branch}" in repo "${plan.repoId}"`,
315
+ );
316
+ progress.createdBranches.push({ sourceDir: plan.sourceDir, startPoint: plan.startPoint });
317
+ }
318
+ if (plan.worktreePath) {
319
+ progress.worktrees.push({ sourceDir: plan.sourceDir, worktreePath: plan.worktreePath });
320
+ await runProvisioningGit(
321
+ ['-C', plan.sourceDir, 'worktree', 'add', plan.worktreePath, options.branch],
322
+ `provision worktree ${plan.worktreePath} for repo "${plan.repoId}"`,
323
+ );
324
+ }
325
+ }
326
+ }
327
+
328
+ /**
329
+ * D-281 rollback ordering (reviewer-verified): a live worktree registration
330
+ * blocks both `branch -D` and any retry of the same path, so each worktree is
331
+ * removed (force is safe — nothing user-authored can exist yet), falling back
332
+ * to rm + `worktree prune --expire now` when the registration is broken, and
333
+ * only then are branches created in this call deleted. Reused branches are
334
+ * never in `createdBranches`, so user branches survive every rollback.
335
+ */
336
+ export async function rollbackGitBinding(progress, branch) {
337
+ const notes = [];
338
+
339
+ for (const worktree of [...progress.worktrees].reverse()) {
340
+ try {
341
+ await execFileAsync(
342
+ 'git',
343
+ ['-C', worktree.sourceDir, 'worktree', 'remove', '--force', worktree.worktreePath],
344
+ { timeout: WORKTREE_GIT_TIMEOUT_MS, maxBuffer: GIT_OUTPUT_MAX_BUFFER },
345
+ );
346
+ } catch {
347
+ await rm(worktree.worktreePath, { recursive: true, force: true }).catch(() => {});
348
+ try {
349
+ await execFileAsync(
350
+ 'git',
351
+ ['-C', worktree.sourceDir, 'worktree', 'prune', '--expire', 'now'],
352
+ { timeout: QUICK_GIT_TIMEOUT_MS, maxBuffer: GIT_OUTPUT_MAX_BUFFER },
353
+ );
354
+ } catch (error) {
355
+ notes.push(`could not prune worktree registration in ${worktree.sourceDir}: ${describeGitError(error)}`);
356
+ }
357
+ }
358
+ }
359
+
360
+ for (const created of [...progress.createdBranches].reverse()) {
361
+ // Ownership was established at record time: the ref-create command
362
+ // succeeded in this call (a same-tip external branch fails the create
363
+ // and is never recorded). The tip check is defense-in-depth — if
364
+ // something moved OUR branch between the create and this rollback,
365
+ // deleting it would destroy work that is no longer in-call state, so it
366
+ // is preserved with a note instead (D-281: delete only in-call refs).
367
+ const tip = await readBranchTip(created.sourceDir, branch);
368
+ if (tip === null) {
369
+ continue;
370
+ }
371
+ if (created.startPoint && tip !== created.startPoint) {
372
+ notes.push(
373
+ `branch "${branch}" in ${created.sourceDir} was left in place: its tip ${tip.slice(0, 12)} does not match the revision this call would have created (${created.startPoint.slice(0, 12)}).`,
374
+ );
375
+ continue;
376
+ }
377
+ try {
378
+ await execFileAsync(
379
+ 'git',
380
+ ['-C', created.sourceDir, 'branch', '-D', branch],
381
+ { timeout: QUICK_GIT_TIMEOUT_MS, maxBuffer: GIT_OUTPUT_MAX_BUFFER },
382
+ );
383
+ } catch (error) {
384
+ notes.push(`could not delete branch "${branch}" in ${created.sourceDir}: ${describeGitError(error)}`);
385
+ }
386
+ }
387
+
388
+ return notes;
389
+ }
390
+
391
+ /**
392
+ * D-281 close-side guarded removal (S3-3). Recorded clean worktrees are
393
+ * removed by default; everything else survives with actionable guidance.
394
+ * The destructive path is strictly narrower than the creative one:
395
+ * - only paths recorded in session.yaml AND contained in the lane's recorded
396
+ * container AND covered by a token-matched container marker are removable
397
+ * (D-279/D-280 guards — arbitrary path removal is refused)
398
+ * - removal is never forced; a status failure counts as unknown, not clean
399
+ * - removal is skipped with guidance when the process cwd sits inside the
400
+ * target
401
+ * - branches are never deleted at close
402
+ * Recorded-but-missing worktrees are benign crash residue and never block or
403
+ * warn. Returns { attempted, removed, surviving, warnings }; the caller keeps
404
+ * the container marker while any recorded worktree survives.
405
+ */
406
+ export async function removeLaneWorktreesAtClose(options) {
407
+ const removed = [];
408
+ const surviving = [];
409
+ const warnings = [];
410
+ const recordedEntries = Object.entries(options.worktrees ?? {});
411
+ if (recordedEntries.length === 0) {
412
+ return { attempted: false, removed, surviving, warnings };
413
+ }
414
+
415
+ const manualCommand = ({ repoId, worktreePath }) => {
416
+ const sourceDir = options.worktreeSources?.[repoId];
417
+ return sourceDir
418
+ ? `git -C ${sourceDir} worktree remove ${worktreePath}`
419
+ : `git worktree remove ${worktreePath} (run from repo "${repoId}"'s source checkout)`;
420
+ };
421
+ const keep = (entry, reason, detail) => {
422
+ surviving.push({ repoId: entry.repoId, worktreePath: entry.worktreePath, reason });
423
+ warnings.push(
424
+ `Worktree ${entry.worktreePath} for repo "${entry.repoId}" was left in place: ${detail} Remove it manually: ${manualCommand(entry)}`,
425
+ );
426
+ };
427
+
428
+ const existingEntries = [];
429
+ for (const [repoId, worktreePath] of recordedEntries) {
430
+ // A relative recorded path is refused before any probe: the guards here
431
+ // resolve it against the process cwd while `git -C <source>` would
432
+ // resolve it against the source repo, so the validated path and the
433
+ // removed path could differ (D-279: arbitrary path removal is refused).
434
+ if (!path.isAbsolute(worktreePath)) {
435
+ keep({ repoId, worktreePath }, 'not-absolute', 'its recorded path is not absolute, so the guards and the removal command could resolve to different locations (D-279: arbitrary path removal is refused).');
436
+ continue;
437
+ }
438
+ if (await pathExists(worktreePath)) {
439
+ existingEntries.push({ repoId, worktreePath });
440
+ }
441
+ }
442
+ if (existingEntries.length === 0) {
443
+ return { attempted: true, removed, surviving, warnings };
444
+ }
445
+
446
+ // Container-level guard, checked once: removal is licensed by the recorded
447
+ // container plus a token-matched container marker (D-280). Any failure here
448
+ // keeps every surviving worktree — an unverifiable container may belong to
449
+ // someone else.
450
+ const containerGuardFailure = await describeContainerGuardFailure(options);
451
+ if (containerGuardFailure) {
452
+ for (const entry of existingEntries) {
453
+ keep(entry, 'container-unverified', `${containerGuardFailure} (D-280/D-281: only marker-verified container contents are removable).`);
454
+ }
455
+ return { attempted: true, removed, surviving, warnings };
456
+ }
457
+
458
+ const realContainer = await canonicalizePathReal(options.worktreeContainer);
459
+ const realCwd = await canonicalizePathReal(options.cwd ?? process.cwd());
460
+ for (const entry of existingEntries) {
461
+ const realWorktree = await canonicalizePathReal(entry.worktreePath);
462
+ if (realWorktree === realContainer || !isPathEqualOrInsideReal(realWorktree, realContainer)) {
463
+ keep(entry, 'outside-container', `its recorded path sits outside the lane's worktree container ${options.worktreeContainer} (D-279: arbitrary path removal is refused).`);
464
+ continue;
465
+ }
466
+ if (isPathEqualOrInsideReal(realCwd, realWorktree)) {
467
+ keep(entry, 'cwd-inside', 'the current working directory is inside it (D-281). cd out of the worktree first.');
468
+ continue;
469
+ }
470
+
471
+ const cleanliness = await readWorktreeCleanliness(entry.worktreePath);
472
+ if (cleanliness === 'dirty') {
473
+ keep(entry, 'dirty', 'it has uncommitted changes and close-session never forces removal (D-281). Commit or discard them first.');
474
+ continue;
475
+ }
476
+ if (cleanliness === 'unknown') {
477
+ keep(entry, 'status-unknown', 'its cleanliness could not be determined — a status failure counts as unknown, not clean (D-281). Inspect it first.');
478
+ continue;
479
+ }
480
+
481
+ const sourceDir = options.worktreeSources?.[entry.repoId];
482
+ if (!sourceDir) {
483
+ keep(entry, 'no-source-recorded', 'the lane recorded no source repository for it, so the removal command cannot be constructed safely.');
484
+ continue;
485
+ }
486
+ try {
487
+ await execFileAsync(
488
+ 'git',
489
+ ['-C', sourceDir, 'worktree', 'remove', entry.worktreePath],
490
+ { timeout: WORKTREE_GIT_TIMEOUT_MS, maxBuffer: GIT_OUTPUT_MAX_BUFFER },
491
+ );
492
+ removed.push({ repoId: entry.repoId, worktreePath: entry.worktreePath, sourceDir });
493
+ } catch (error) {
494
+ keep(entry, 'git-refused', `git refused the removal: ${describeGitError(error)}. Resolve that first.`);
495
+ }
496
+ }
497
+
498
+ return { attempted: true, removed, surviving, warnings };
499
+ }
500
+
501
+ /**
502
+ * Returns a human-readable reason when the D-280 container guard cannot
503
+ * license removal, or null when the container marker verifies. The marker
504
+ * must live directly in the recorded container and match the lane's recorded
505
+ * token and lane id — a mismatch means the container may not be ours.
506
+ */
507
+ async function describeContainerGuardFailure(options) {
508
+ if (!options.worktreeContainer) {
509
+ return 'the lane recorded no worktree container';
510
+ }
511
+ if (!options.recordedMarker?.path || !options.recordedMarker?.token) {
512
+ return 'the lane recorded no container marker to verify ownership against';
513
+ }
514
+
515
+ const realContainer = await canonicalizePathReal(options.worktreeContainer);
516
+ const recordedMarkerDir = await canonicalizePathReal(path.dirname(path.resolve(options.recordedMarker.path)));
517
+ if (recordedMarkerDir !== realContainer) {
518
+ return `the recorded marker path ${options.recordedMarker.path} does not sit in the recorded container ${options.worktreeContainer}`;
519
+ }
520
+
521
+ let marker;
522
+ try {
523
+ marker = await readLaneMarker(options.recordedMarker.path);
524
+ } catch (error) {
525
+ return `the container marker could not be verified: ${error instanceof Error ? error.message : String(error)}`;
526
+ }
527
+ if (marker.token !== options.recordedMarker.token) {
528
+ return `the container marker at ${options.recordedMarker.path} does not match the lane's recorded token`;
529
+ }
530
+ if (options.laneId && marker.laneId !== options.laneId) {
531
+ return `the container marker at ${options.recordedMarker.path} belongs to lane "${marker.laneId}", not "${options.laneId}"`;
532
+ }
533
+
534
+ return null;
535
+ }
536
+
537
+ /**
538
+ * Close-side cleanliness probe. Unlike the start-side dirty warning (which
539
+ * fails open — a probe failure only skips a warning), removal must fail
540
+ * closed: an unreadable status is 'unknown' and the worktree survives.
541
+ * Gitignored files intentionally do NOT count as dirty, matching git's own
542
+ * unforced `worktree remove` semantics — ignored local files (.env, caches)
543
+ * are deleted with the worktree, and treating them as dirty would make
544
+ * every real worktree (node_modules etc.) survive by default.
545
+ */
546
+ async function readWorktreeCleanliness(worktreePath) {
547
+ try {
548
+ const { stdout } = await execFileAsync(
549
+ 'git',
550
+ ['-C', worktreePath, 'status', '--porcelain=v1', '--untracked-files=all'],
551
+ { timeout: QUICK_GIT_TIMEOUT_MS, maxBuffer: GIT_OUTPUT_MAX_BUFFER },
552
+ );
553
+ return stdout.trim() === '' ? 'clean' : 'dirty';
554
+ } catch {
555
+ return 'unknown';
556
+ }
557
+ }
558
+
559
+ async function runProvisioningGit(args, description) {
560
+ try {
561
+ await execFileAsync('git', args, { timeout: WORKTREE_GIT_TIMEOUT_MS, maxBuffer: GIT_OUTPUT_MAX_BUFFER });
562
+ } catch (error) {
563
+ throw new Error(`Failed to ${description}: ${describeGitError(error)}`);
564
+ }
565
+ }
566
+
567
+ function describeGitError(error) {
568
+ if (error && typeof error === 'object') {
569
+ if (error.killed || error.signal) {
570
+ return `git was terminated (${error.signal ?? 'timeout'}) — likely the ${WORKTREE_GIT_TIMEOUT_MS / 1000}s provisioning timeout on a large checkout.`;
571
+ }
572
+ if (typeof error.stderr === 'string' && error.stderr.trim() !== '') {
573
+ return error.stderr.trim();
574
+ }
575
+ }
576
+
577
+ return error instanceof Error ? error.message : String(error);
578
+ }
579
+
580
+ async function readGitToplevel(repoDir) {
581
+ try {
582
+ const { stdout } = await execFileAsync(
583
+ 'git',
584
+ ['-C', repoDir, 'rev-parse', '--show-toplevel'],
585
+ { timeout: QUICK_GIT_TIMEOUT_MS, maxBuffer: GIT_OUTPUT_MAX_BUFFER },
586
+ );
587
+ const toplevel = stdout.trim();
588
+ return toplevel === '' ? null : toplevel;
589
+ } catch {
590
+ return null;
591
+ }
592
+ }
593
+
594
+ async function readBranchTip(repoDir, branch) {
595
+ try {
596
+ const { stdout } = await execFileAsync(
597
+ 'git',
598
+ ['-C', repoDir, 'rev-parse', '--verify', '--quiet', `refs/heads/${branch}`],
599
+ { timeout: QUICK_GIT_TIMEOUT_MS, maxBuffer: GIT_OUTPUT_MAX_BUFFER },
600
+ );
601
+ const tip = stdout.trim();
602
+ return tip === '' ? null : tip;
603
+ } catch {
604
+ return null;
605
+ }
606
+ }
607
+
608
+ /**
609
+ * Where (if anywhere) a branch is checked out: the porcelain listing's first
610
+ * entry is the main checkout; `bare` and `detached` entries carry no branch
611
+ * line and never match. Matches the full `branch refs/heads/<name>` line —
612
+ * stale registrations still list their branch and still block worktree add.
613
+ */
614
+ async function findBranchCheckout(repoDir, branch) {
615
+ let stdout;
616
+ try {
617
+ ({ stdout } = await execFileAsync(
618
+ 'git',
619
+ ['-C', repoDir, 'worktree', 'list', '--porcelain'],
620
+ { timeout: QUICK_GIT_TIMEOUT_MS, maxBuffer: GIT_OUTPUT_MAX_BUFFER },
621
+ ));
622
+ } catch (error) {
623
+ throw new Error(`Could not list worktrees for ${repoDir}: ${describeGitError(error)}`);
624
+ }
625
+
626
+ let currentPath = null;
627
+ for (const line of stdout.split(/\r?\n/)) {
628
+ if (line.startsWith('worktree ')) {
629
+ currentPath = line.slice('worktree '.length).trim();
630
+ } else if (line === `branch refs/heads/${branch}`) {
631
+ return currentPath;
632
+ }
633
+ }
634
+
635
+ return null;
636
+ }
637
+
638
+ async function isSourceTreeDirty(repoDir) {
639
+ try {
640
+ const { stdout } = await execFileAsync(
641
+ 'git',
642
+ ['-C', repoDir, 'status', '--porcelain=v1', '--untracked-files=all'],
643
+ { timeout: QUICK_GIT_TIMEOUT_MS, maxBuffer: GIT_OUTPUT_MAX_BUFFER },
644
+ );
645
+ return stdout.trim() !== '';
646
+ } catch {
647
+ return false;
648
+ }
649
+ }
650
+
651
+ /** Realpath-based directory equality — mkdtemp fixtures on macOS live under a /var → /private/var symlink. */
652
+ async function isSameDirectoryReal(left, right) {
653
+ return (await canonicalizePathReal(left)) === (await canonicalizePathReal(right));
654
+ }
655
+
656
+ async function canonicalizePathReal(value) {
657
+ try {
658
+ return await realpath(value);
659
+ } catch {
660
+ return path.resolve(value);
661
+ }
662
+ }
663
+
664
+ function isPathEqualOrInsideReal(candidate, parentDir) {
665
+ const relative = path.relative(parentDir, candidate);
666
+ return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
667
+ }
668
+
669
+ async function pathExists(target) {
670
+ try {
671
+ await stat(target);
672
+ return true;
673
+ } catch {
674
+ return false;
675
+ }
676
+ }