@ran-sh/dsh-crew 1.10.0 → 1.10.2

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.
@@ -11,10 +11,10 @@
11
11
  // lock blocks cleanup.
12
12
 
13
13
  import { execFile } from 'node:child_process';
14
- import { existsSync, mkdirSync, rmSync } from 'node:fs';
14
+ import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmdirSync, rmSync, writeFileSync } from 'node:fs';
15
15
  import { readFile, lstat } from 'node:fs/promises';
16
16
  import { tmpdir } from 'node:os';
17
- import { join, resolve, basename } from 'node:path';
17
+ import { join, resolve, basename, dirname } from 'node:path';
18
18
  import { randomBytes, createHash } from 'node:crypto';
19
19
  import { promisify } from 'node:util';
20
20
  import { isSensitivePath, parseChanges, DIFF_LIMIT, GIT_TIMEOUT_MS } from './workspace-audit.mjs';
@@ -31,17 +31,22 @@ export const CANDIDATE_CAPTURE_FAILED = 'CANDIDATE_CAPTURE_FAILED';
31
31
  export const MAX_PARALLEL_CAP = 16;
32
32
  export const DEFAULT_MAX_PARALLEL = 3;
33
33
  // Worktree names read `Crew_YYYYMMDD_HHMMSS_<purpose>` so an operator can tell
34
- // from the directory alone when a job ran and what it was for. The legacy
35
- // prefix stays recognised as ours, so worktrees created by an earlier release
36
- // are still adopted and cleaned up rather than orphaned.
34
+ // from the directory alone when a job ran and what it was for. Worktrees from
35
+ // an earlier release used `dsh-crew-<job>-<hex>` and stay recognised, so old
36
+ // trees are still adopted and cleaned up rather than orphaned.
37
+ //
38
+ // The shapes are matched exactly rather than by prefix, because the prefix
39
+ // alone is not proof of ownership: cleanup `--force`-deletes what it adopts,
40
+ // and a directory a user happened to name Crew_manual-testing or
41
+ // dsh-crew-backup would go with its uncommitted contents.
37
42
  const WORKTREE_PREFIX = 'Crew_';
38
- const LEGACY_WORKTREE_PREFIXES = Object.freeze(['dsh-crew-']);
43
+ const WORKTREE_NAME_RE = /^Crew_\d{8}_\d{6}_[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*(?:-\d+)?$/;
44
+ const LEGACY_WORKTREE_RE = /^dsh-crew-[A-Za-z0-9._-]+-[0-9a-f]{8}$/;
39
45
 
40
46
  /** Whether a directory name is a worktree Crew created. */
41
47
  export function isCrewWorktreeName(name) {
42
48
  const value = String(name ?? '');
43
- return value.startsWith(WORKTREE_PREFIX)
44
- || LEGACY_WORKTREE_PREFIXES.some((prefix) => value.startsWith(prefix));
49
+ return WORKTREE_NAME_RE.test(value) || LEGACY_WORKTREE_RE.test(value);
45
50
  }
46
51
 
47
52
  async function defaultRunner(args, { cwd }) {
@@ -80,23 +85,27 @@ function stamp(at) {
80
85
  + `_${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
81
86
  }
82
87
 
83
- /**
84
- * `Crew_<date>_<time>_<purpose>`, with a numeric suffix only when that name is
85
- * already taken. Parallel jobs can start inside the same second, so the suffix is
86
- * what keeps the name unique without putting random noise in every name.
87
- */
88
88
  /**
89
89
  * `Crew_<date>_<time>_<purpose>`, reserved by creating the directory.
90
90
  *
91
91
  * The reservation is a mkdir, not a lookup: two jobs starting in the same second
92
92
  * both probe before either has created anything, so a check-then-act name would
93
93
  * hand them the same path and one `git worktree add` would fail. mkdir
94
- * fails on EEXIST, which makes the suffix loop race-free.
94
+ * fails on EEXIST, which makes the suffix loop race-free, and a numeric suffix
95
+ * is added only when the name is already taken.
95
96
  */
96
- function reserveWorktreeDir({ root, purpose, at }) {
97
- mkdirSync(root, { recursive: true });
97
+ export function reserveWorktreeDir({ root, purpose, at }) {
98
+ try {
99
+ mkdirSync(root, { recursive: true });
100
+ } catch (error) {
101
+ return { ok: false, error: `cannot create worktree root: ${error?.message ?? error}` };
102
+ }
103
+ // Truncate before trimming. Trimming first lets a 32-character slice end on
104
+ // the separator it just created, and the resulting name fails
105
+ // `isCrewWorktreeName` — so Crew would create a worktree it no longer
106
+ // recognises as its own, and stale pruning would never adopt it again.
98
107
  const safe = String(purpose ?? 'job').replace(/[^A-Za-z0-9]+/g, '-')
99
- .replace(/^-+|-+$/g, '').slice(0, PURPOSE_MAX) || 'job';
108
+ .slice(0, PURPOSE_MAX).replace(/^-+|-+$/g, '') || 'job';
100
109
  const base = `${WORKTREE_PREFIX}${stamp(at)}_${safe}`;
101
110
  for (let n = 1; n <= 100; n += 1) {
102
111
  const name = n === 1 ? base : `${base}-${n}`;
@@ -114,6 +123,138 @@ export function defaultWorktreeRoot() {
114
123
  return join(tmpdir(), 'dsh-crew-worktrees');
115
124
  }
116
125
 
126
+ // ---------- ownership record ----------
127
+ //
128
+ // A name is not proof that Crew made a worktree. `dsh-crew-backup-deadbeef`
129
+ // satisfies the legacy grammar, and `detached` describes HEAD rather than who
130
+ // created the tree, so both can be true of a worktree a user made — and cleanup
131
+ // `--force` deletes what it adopts. Crew therefore records what it creates,
132
+ // beside the worktrees it creates it in, and only ever adopts what a valid
133
+ // record claims. Anything else that looks like a leftover is reported for a
134
+ // human instead of removed.
135
+ //
136
+ // The record lives under the worktree root rather than in the Crew home so it
137
+ // travels with the trees it describes: if the root is wiped, the trees are gone
138
+ // with it and there is nothing left to adopt.
139
+ //
140
+ // A record is evidence only when it can be read and matches: its shape is
141
+ // validated, its name has to be this worktree's name, and its repository has to
142
+ // be the repository being scanned. An unreadable, truncated, malformed or
143
+ // mismatched record reads as "not owned", which withholds deletion rather than
144
+ // granting it — the one direction where being wrong is survivable.
145
+
146
+ const OWNED_DIRNAME = '.crew-owned';
147
+ const OWNED_SCHEMA = 2;
148
+ // Stored inside the worktree's git administrative directory, which git destroys
149
+ // with the worktree.
150
+ const INCARNATION_FILE = 'crew-owned';
151
+
152
+ function ownedMarkerPath(worktreePath) {
153
+ const abs = resolve(worktreePath);
154
+ return join(dirname(abs), OWNED_DIRNAME, `${basename(abs)}.json`);
155
+ }
156
+
157
+ /**
158
+ * Prove which *incarnation* of a worktree this is.
159
+ *
160
+ * A path can be reused. Git removes a worktree and later another one is created
161
+ * in the same place — same name, same repository — so a record keyed on those
162
+ * alone can be inherited by a successor that Crew never created, and inherited
163
+ * again after Crew's own cleanup if releasing the record failed. The worktree's
164
+ * git administrative directory does not have that problem: git deletes it along
165
+ * with the worktree, so a value kept there cannot outlive the tree it describes.
166
+ */
167
+ async function gitDirOf(run, cwd) {
168
+ const abs = await runGit(run, ['rev-parse', '--path-format=absolute', '--git-dir'], { cwd });
169
+ if (abs.ok && abs.stdout.trim()) return resolve(abs.stdout.trim());
170
+ const rel = await runGit(run, ['rev-parse', '--git-dir'], { cwd });
171
+ if (!rel.ok || !rel.stdout.trim()) return null;
172
+ return resolve(cwd, rel.stdout.trim());
173
+ }
174
+
175
+ /** The nonce this worktree incarnation was stamped with, if any. */
176
+ async function incarnationOf(run, worktreePath) {
177
+ const gitDir = await gitDirOf(run, worktreePath);
178
+ if (!gitDir) return null;
179
+ let nonce;
180
+ try { nonce = readFileSync(join(gitDir, INCARNATION_FILE), 'utf8').trim(); } catch { return null; }
181
+ return nonce ? { gitDir, nonce } : null;
182
+ }
183
+
184
+ /** Stamp a freshly created worktree so its record cannot be inherited later. */
185
+ async function claimIncarnation(run, worktreePath) {
186
+ const gitDir = await gitDirOf(run, worktreePath);
187
+ if (!gitDir) return null;
188
+ const nonce = randomBytes(16).toString('hex');
189
+ try { writeFileSync(join(gitDir, INCARNATION_FILE), `${nonce}\n`); } catch { return null; }
190
+ return { gitDir, nonce };
191
+ }
192
+
193
+ function recordOwnership({ worktreePath, repo, incarnation, head, purpose, at }) {
194
+ const marker = ownedMarkerPath(worktreePath);
195
+ const record = {
196
+ schemaVersion: OWNED_SCHEMA,
197
+ name: basename(resolve(worktreePath)),
198
+ worktree: pathIdentity(worktreePath),
199
+ // Binding the record to a repository is what stops a stale record from
200
+ // licensing a delete for a worktree of some other repository that later
201
+ // occupies the same path. The identity is the git *common* directory, not
202
+ // `--show-toplevel`: that returns the linked worktree's own path when run
203
+ // inside one, so it names different directories for the same repository
204
+ // depending on where it is asked.
205
+ repo: pathIdentity(repo),
206
+ git_dir: pathIdentity(incarnation.gitDir),
207
+ nonce: incarnation.nonce,
208
+ // The revision Crew left the worktree at. An operator who commits or checks
209
+ // out something else has taken the worktree over, and moving HEAD is how
210
+ // that shows up: being on a branch is only one way to do it.
211
+ head,
212
+ purpose: purpose ?? null,
213
+ created_at: at ?? Date.now(),
214
+ };
215
+ try {
216
+ mkdirSync(dirname(marker), { recursive: true });
217
+ const pending = `${marker}.${process.pid}.tmp`;
218
+ writeFileSync(pending, JSON.stringify(record) + '\n');
219
+ renameSync(pending, marker);
220
+ return true;
221
+ } catch {
222
+ return false;
223
+ }
224
+ }
225
+
226
+ function releaseOwnership({ worktreePath }) {
227
+ try { rmSync(ownedMarkerPath(worktreePath), { force: true }); return true; } catch { return false; }
228
+ }
229
+
230
+ /**
231
+ * The validated ownership record for `worktreePath`, or null when there is no
232
+ * usable evidence that Crew created *this* worktree in `repo`.
233
+ *
234
+ * This answers identity only — was it Crew's worktree? — and deliberately not
235
+ * state, which is a question for the caller: `head` and `detached` describe what
236
+ * the worktree looks like now, and a worktree whose operator has moved HEAD is
237
+ * still Crew's worktree, just no longer a disposable one. All of shape, name,
238
+ * path, repository and incarnation have to match; every failure reads as "not
239
+ * owned", which withholds deletion rather than granting it.
240
+ */
241
+ function readOwnership({ worktreePath, repo, incarnation }) {
242
+ let raw;
243
+ try { raw = readFileSync(ownedMarkerPath(worktreePath), 'utf8'); } catch { return null; }
244
+ let record;
245
+ try { record = JSON.parse(raw); } catch { return null; }
246
+ if (!record || typeof record !== 'object' || record.schemaVersion !== OWNED_SCHEMA) return null;
247
+ if (typeof record.name !== 'string' || record.name !== basename(resolve(worktreePath))) return null;
248
+ if (typeof record.worktree !== 'string' || record.worktree !== pathIdentity(worktreePath)) return null;
249
+ // An unknown identity on either side cannot be matched, so it cannot be
250
+ // adopted: an unverifiable record is not evidence.
251
+ if (!repo || typeof record.repo !== 'string' || record.repo !== pathIdentity(repo)) return null;
252
+ if (!incarnation) return null;
253
+ if (typeof record.nonce !== 'string' || record.nonce !== incarnation.nonce) return null;
254
+ if (typeof record.git_dir !== 'string' || record.git_dir !== pathIdentity(incarnation.gitDir)) return null;
255
+ return record;
256
+ }
257
+
117
258
  /**
118
259
  * Resolve repository root + HEAD. Dirty detection is advisory: inability to
119
260
  * read `git status` must not turn an otherwise valid repository into a hard
@@ -140,6 +281,10 @@ export async function inspectRepository({ cwd, git, runner } = {}) {
140
281
 
141
282
  export async function createIsolatedWorkspace({ cwd, jobId, purpose, baseRevision, at, root = defaultWorktreeRoot(), git } = {}) {
142
283
  const run = git ?? defaultRunner;
284
+ // A relative root would be resolved against this process's cwd for the
285
+ // mkdir reservation but against the repo root for the git invocation, so the
286
+ // two could land in different places. Anchor it once.
287
+ root = resolve(root);
143
288
  const repo = await inspectRepository({ cwd, git: run });
144
289
  if (!repo.ok) return { ok: false, reason: repo.reason, error: repo.error };
145
290
  const rev = baseRevision ?? repo.baseRevision;
@@ -148,11 +293,44 @@ export async function createIsolatedWorkspace({ cwd, jobId, purpose, baseRevisio
148
293
  const { dir, name } = reserved;
149
294
  const res = await runGit(run, ['worktree', 'add', '--detach', dir, rev], { cwd: repo.repoRoot });
150
295
  if (!res.ok) {
151
- // Release the name so a retry is not blocked by an empty directory.
152
- try { rmSync(dir, { recursive: true, force: true }); } catch {}
153
- return { ok: false, reason: res.reason, error: res.error };
296
+ // A nonzero exit does not mean git did nothing: a failing post-checkout hook
297
+ // leaves the worktree registered and populated, and a transient Windows lock
298
+ // can defeat the unregister. Go through the same verified, retrying cleanup
299
+ // the normal path uses, and report a blocked reservation rather than
300
+ // pretending the name was released — a stranded registration makes every
301
+ // later attempt on this name fail with no way for the caller to tell why.
302
+ const released = await cleanupIsolatedWorkspace({ worktreePath: dir, repoRoot: repo.repoRoot, git: run, reservation: true });
303
+ return {
304
+ ok: false,
305
+ reason: res.reason,
306
+ error: res.error,
307
+ ...(released.ok ? {} : { cleanupBlocked: true, cleanupError: released.error }),
308
+ };
154
309
  }
155
- return { ok: true, worktreePath: dir, baseRevision: rev, repoRoot: repo.repoRoot, name };
310
+ // Recorded only after git succeeded, so a failed creation never leaves a claim
311
+ // on a tree that does not exist. A record that cannot be written costs the
312
+ // automatic prune of this one worktree; it never costs the worktree itself.
313
+ //
314
+ // `owned` is only true when a record that `readOwnership` would actually
315
+ // accept was written: a record missing its repository or incarnation identity
316
+ // can never authorize cleanup, and reporting it as owned would be a leak
317
+ // dressed up as success.
318
+ // The revision Git actually left the worktree at, not the string we asked for:
319
+ // a caller may pass a branch or tag, and recording that name would never match
320
+ // the commit OID `worktree list` reports, so the worktree could never be
321
+ // recognized as untouched.
322
+ const headRes = await runGit(run, ['rev-parse', 'HEAD'], { cwd: dir });
323
+ const created = headRes.ok ? headRes.stdout.trim() : '';
324
+
325
+ const commonDir = await commonDirOf(run, repo.repoRoot);
326
+ const incarnation = commonDir ? await claimIncarnation(run, dir) : null;
327
+ // All four identities are required, and an unresolvable HEAD is not a fallback
328
+ // to the requested string: recording a symbolic name would produce a record
329
+ // that can never match, which is a worktree that can never be cleaned up
330
+ // reported as owned.
331
+ const owned = Boolean(commonDir && incarnation && created)
332
+ && recordOwnership({ worktreePath: dir, repo: commonDir, incarnation, head: created, purpose, at });
333
+ return { ok: true, worktreePath: dir, baseRevision: rev, repoRoot: repo.repoRoot, name, owned };
156
334
  }
157
335
 
158
336
  function splitFirstTab(line) {
@@ -328,30 +506,114 @@ function trackedIn(nameStatus, untracked) {
328
506
  return out;
329
507
  }
330
508
 
509
+ /**
510
+ * Parse `git worktree list --porcelain` into records. Git documents that the
511
+ * main working tree is the first record; `detached` marks the ones Crew creates,
512
+ * since every Crew worktree is added with `--detach`.
513
+ */
514
+ function parseWorktrees(stdout) {
515
+ const out = [];
516
+ for (const block of String(stdout ?? '').split('\n\n')) {
517
+ const lines = block.split('\n');
518
+ const path = lines.find((line) => line.startsWith('worktree '))?.slice('worktree '.length)?.trim();
519
+ if (!path) continue;
520
+ out.push({
521
+ path: resolve(path),
522
+ detached: lines.includes('detached'),
523
+ head: lines.find((line) => line.startsWith('HEAD '))?.slice('HEAD '.length)?.trim() ?? null,
524
+ });
525
+ }
526
+ return out;
527
+ }
528
+
331
529
  async function mainRepoRoot(run, worktreePath) {
332
- const common = await runGit(run, ['rev-parse', '--git-common-dir'], { cwd: worktreePath });
333
- if (!common.ok) return null;
334
- const dir = String(common.stdout ?? '').trim();
335
- if (!dir) return null;
336
- return resolve(dir, '..');
530
+ // The main working tree is the first record of `git worktree list`; deriving
531
+ // it from `--git-common-dir` breaks for a bare main repository, where that
532
+ // path is the bare repository itself and its parent is not a git directory at
533
+ // all. The first record is the same ordering guarantee the stale-list scan
534
+ // already relies on.
535
+ const list = await runGit(run, ['worktree', 'list', '--porcelain'], { cwd: worktreePath });
536
+ if (!list.ok) return null;
537
+ return parseWorktrees(list.stdout)[0]?.path ?? null;
337
538
  }
338
539
 
339
540
  const sleep = (ms) => new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
340
541
 
542
+ /**
543
+ * A path identity safe to compare for equality.
544
+ *
545
+ * `realpathSync` returns the on-disk spelling, so two aliases of one directory —
546
+ * a symlinked repo root, a mapped drive, a differently cased path — collapse to
547
+ * the same string without guessing. Lower-casing on Windows used to stand in for
548
+ * this, but Windows supports per-directory case sensitivity, where `Foo` and
549
+ * `foo` really are two directories and folding them together would license a
550
+ * delete against the wrong repository.
551
+ */
341
552
  const pathIdentity = (value) => {
342
553
  const resolved = resolve(String(value));
343
- return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
554
+ try { return realpathSync.native ? realpathSync.native(resolved) : realpathSync(resolved); }
555
+ catch { return resolved; }
344
556
  };
345
557
 
346
558
  async function worktreeRegistered({ worktreePath, git, cwd }) {
347
559
  const res = await runGit(git, ['worktree', 'list', '--porcelain'], { cwd });
348
560
  if (!res.ok) return null;
349
561
  const target = pathIdentity(worktreePath);
350
- for (const block of String(res.stdout).split('\n\n')) {
351
- const path = block.split('\n').find((line) => line.startsWith('worktree '))?.slice('worktree '.length)?.trim();
352
- if (path && pathIdentity(path) === target) return true;
353
- }
354
- return false;
562
+ return parseWorktrees(res.stdout).some((record) => pathIdentity(record.path) === target);
563
+ }
564
+
565
+ /**
566
+ * The repository's common git directory as seen from `cwd`, or null when git
567
+ * cannot say. `--path-format=absolute` needs git 2.31+, so the relative form is
568
+ * resolved against `cwd` for older installations.
569
+ */
570
+ async function commonDirOf(run, cwd) {
571
+ const abs = await runGit(run, ['rev-parse', '--path-format=absolute', '--git-common-dir'], { cwd });
572
+ if (abs.ok && abs.stdout.trim()) return resolve(abs.stdout.trim());
573
+ const rel = await runGit(run, ['rev-parse', '--git-common-dir'], { cwd });
574
+ if (!rel.ok || !rel.stdout.trim()) return null;
575
+ return resolve(cwd, rel.stdout.trim());
576
+ }
577
+
578
+ /**
579
+ * Whether `worktreePath` carries git metadata for the same repository as `root`.
580
+ *
581
+ * A directory name is not proof of ownership, and the filesystem fallback below
582
+ * force-deletes what it adopts — so the last resort asks git which repository the
583
+ * directory belongs to and refuses when the answer is unavailable or different.
584
+ * An unrelated directory that merely happens to be named like a Crew worktree
585
+ * therefore survives.
586
+ */
587
+ async function sameRepository(run, worktreePath, root) {
588
+ const [a, b] = await Promise.all([commonDirOf(run, worktreePath), commonDirOf(run, root)]);
589
+ return Boolean(a && b && pathIdentity(a) === pathIdentity(b));
590
+ }
591
+
592
+ function emptyDirectory(path) {
593
+ try { return readdirSync(path).length === 0; } catch { return false; }
594
+ }
595
+
596
+ /**
597
+ * Why `worktreePath` is no longer the worktree the caller validated, or null when
598
+ * it still is.
599
+ *
600
+ * Checking ownership and then deleting by pathname is check-then-act: whatever
601
+ * occupies the path when the delete runs is what gets deleted. Re-reading the
602
+ * incarnation, registration, HEAD and detached state immediately before the
603
+ * removal narrows that window to the removal itself.
604
+ */
605
+ async function ownershipDrift(run, worktreePath, expect) {
606
+ const incarnation = await incarnationOf(run, worktreePath);
607
+ if (!incarnation) return 'its incarnation stamp is gone';
608
+ if (incarnation.nonce !== expect.incarnation?.nonce) return 'its incarnation nonce changed';
609
+ if (pathIdentity(incarnation.gitDir) !== pathIdentity(expect.incarnation?.gitDir)) return 'its git directory changed';
610
+ const listed = await runGit(run, ['worktree', 'list', '--porcelain'], { cwd: worktreePath });
611
+ if (!listed.ok) return 'it is no longer registered';
612
+ const record = parseWorktrees(listed.stdout).find((r) => pathIdentity(r.path) === pathIdentity(worktreePath));
613
+ if (!record) return 'it is no longer registered';
614
+ if (!record.detached) return 'it is no longer detached';
615
+ if (expect.head && record.head !== expect.head) return `its HEAD moved from ${expect.head} to ${record.head}`;
616
+ return null;
355
617
  }
356
618
 
357
619
  /**
@@ -372,36 +634,140 @@ export async function cleanupIsolatedWorkspace({
372
634
  git,
373
635
  retries = WORKTREE_CLEANUP_RETRIES,
374
636
  backoffMs = WORKTREE_CLEANUP_BACKOFF_MS,
637
+ reservation = false,
638
+ // Callers that own the worktree (the Hub allocated it, this process holds the
639
+ // job) may force it away. Callers that merely recognised it — background
640
+ // pruning — must not: forcing discards any changes the tree now holds, which is
641
+ // precisely what a takeover looks like.
642
+ force = true,
643
+ // What the caller validated before deciding to delete. Re-checked here, because
644
+ // deciding on one observation and acting on a pathname is a different object.
645
+ expect = null,
375
646
  } = {}) {
376
647
  const run = git ?? defaultRunner;
377
648
  if (!worktreePath) return { ok: false, reason: NOT_GIT_REPOSITORY, error: 'worktree path required' };
378
649
  const root = repoRoot ?? (await mainRepoRoot(run, worktreePath));
379
650
  const owned = isCrewWorktreeName(basename(resolve(worktreePath)));
380
651
 
652
+ if (expect) {
653
+ const drift = await ownershipDrift(run, worktreePath, expect);
654
+ if (drift) {
655
+ return { ok: false, reason: WORKTREE_LOCKED, error: `refusing to remove ${worktreePath}: ${drift}`, cleanupBlocked: true };
656
+ }
657
+ }
658
+
659
+ if (!force) {
660
+ // Measured, not assumed: git refuses modified, staged and untracked files
661
+ // without `--force`, but it removes a worktree whose only remaining content
662
+ // is *ignored* — build output, a log, local config — and takes that content
663
+ // with it. Ask about ignored files directly, and treat an unanswerable
664
+ // question as a reason not to delete.
665
+ const ignored = await runGit(run, ['ls-files', '--others', '--ignored', '--exclude-standard'], { cwd: worktreePath });
666
+ if (!ignored.ok || ignored.stdout.trim() !== '') {
667
+ return {
668
+ ok: false,
669
+ reason: WORKTREE_LOCKED,
670
+ error: `refusing to remove ${worktreePath} without --force: it holds files that are not Crew's, or its contents could not be read`,
671
+ cleanupBlocked: true,
672
+ };
673
+ }
674
+ // And the index flags, which are the same class of hole one level down: a
675
+ // tracked file with `assume-unchanged` or `skip-worktree` set is invisible to
676
+ // `status`, to the untracked query and to the ignored query alike, and git
677
+ // removes the worktree without complaint — taking the operator's edit with
678
+ // it. Measured on git 2.47.3. In `ls-files -v` a lowercase letter means
679
+ // assume-unchanged and `S` means skip-worktree; the ordinary state is `H`.
680
+ const flags = await runGit(run, ['ls-files', '-v'], { cwd: worktreePath });
681
+ const hidden = flags.ok
682
+ ? flags.stdout.split('\n').find((line) => /^([a-z]|S)/.test(line)) ?? null
683
+ : 'unknown';
684
+ if (!flags.ok || hidden) {
685
+ return {
686
+ ok: false,
687
+ reason: WORKTREE_LOCKED,
688
+ error: `refusing to remove ${worktreePath} without --force: its index carries assume-unchanged or skip-worktree entries, which hide a modification from every status query`,
689
+ cleanupBlocked: true,
690
+ };
691
+ }
692
+ }
693
+
381
694
  if (root) {
382
- // Preferred path: `git worktree remove --force` (removes registration and
383
- // directory atomically). Retry bounded times to recover transient locks.
695
+ // Preferred path: `git worktree remove` (removes registration and directory
696
+ // atomically). Retry bounded times to recover transient locks.
697
+ //
698
+ // The ownership re-check above narrows the window between deciding and
699
+ // deleting, but it does not close it: git has no "remove only if HEAD and
700
+ // incarnation still equal X" primitive, so a worktree that changes in the
701
+ // moments between the two is removed anyway. That residual is accepted and
702
+ // stated rather than papered over; closing it needs a lease on the worktree,
703
+ // not another read.
704
+ const args = force
705
+ ? ['worktree', 'remove', '--force', worktreePath]
706
+ : ['worktree', 'remove', worktreePath];
384
707
  for (let attempt = 0; attempt < retries; attempt += 1) {
385
- const res = await runGit(run, ['worktree', 'remove', '--force', worktreePath], { cwd: root });
386
- if (res.ok) return { ok: true, removed: true, actions: [`removed worktree ${worktreePath}`] };
708
+ const res = await runGit(run, args, { cwd: root });
709
+ if (res.ok) {
710
+ const released = releaseOwnership({ worktreePath });
711
+ return {
712
+ ok: true,
713
+ removed: true,
714
+ actions: [`removed worktree ${worktreePath}`],
715
+ // The worktree is gone but its record is not: worth saying, because the
716
+ // record is what would have authorized a later cleanup.
717
+ ...(released ? {} : { ownershipRecordStale: true }),
718
+ };
719
+ }
387
720
  if (attempt < retries - 1) await sleep(backoffMs);
388
721
  }
722
+ if (!force) {
723
+ // Non-forced removal failed: git is refusing, or the tree is locked. The
724
+ // filesystem fallback deletes unconditionally, so it must not run here.
725
+ return { ok: false, reason: WORKTREE_LOCKED, error: `git declined to remove ${worktreePath} without --force`, cleanupBlocked: true };
726
+ }
389
727
  }
390
728
 
391
729
  // Last resort, only for Crew-owned disposable paths: remove the directory and
392
730
  // verify the git registration actually went away before claiming success.
731
+ //
732
+ // Ownership here is not the name alone. An earlier revision adopted anything
733
+ // whose basename matched, so a directory the user had named (or a worktree they
734
+ // had made) could be force-deleted for resembling Crew's.
393
735
  if (owned && root && pathIdentity(worktreePath) !== pathIdentity(root)) {
736
+ // Ask git BEFORE deleting anything. The reverse order — delete, then check —
737
+ // destroys the files of a worktree git still tracks and then reports the
738
+ // registration stranded, which is the outcome the caller was avoiding.
739
+ const registered = await worktreeRegistered({ worktreePath, git: run, cwd: root });
740
+ if (registered === true) {
741
+ return { ok: false, reason: WORKTREE_LOCKED, error: `worktree still registered after git refused to remove it: ${worktreePath}`, cleanupBlocked: true };
742
+ }
743
+ // The empty directory this call itself reserved is the one path that needs no
744
+ // repository provenance; anything else must belong to this repository.
745
+ const ownEmptyReservation = reservation && emptyDirectory(worktreePath);
746
+ const adoptable = ownEmptyReservation || (registered === false && await sameRepository(run, worktreePath, root));
747
+ if (!adoptable) {
748
+ return {
749
+ ok: false,
750
+ reason: WORKTREE_LOCKED,
751
+ error: `refusing to delete ${worktreePath}: not a worktree of ${root} (remove it manually if it is disposable)`,
752
+ cleanupBlocked: true,
753
+ };
754
+ }
394
755
  try {
395
- rmSync(worktreePath, { recursive: true, force: true });
756
+ // A reservation is empty by construction, so remove it non-recursively: if
757
+ // something appeared in it, failing is better than deleting that too.
758
+ if (ownEmptyReservation) rmdirSync(worktreePath);
759
+ else rmSync(worktreePath, { recursive: true, force: true });
396
760
  } catch (err) {
397
761
  return { ok: false, reason: WORKTREE_LOCKED, error: `worktree cleanup failed: ${err?.message ?? String(err)}`, cleanupBlocked: true };
398
762
  }
399
- const registered = root ? await worktreeRegistered({ worktreePath, git: run, cwd: root }) : null;
400
- if (registered === false && !existsSync(worktreePath)) {
401
- return { ok: true, removed: true, actions: [`cleaned worktree files ${worktreePath}`] };
402
- }
403
- if (registered === true) {
404
- return { ok: false, reason: WORKTREE_LOCKED, error: `worktree still registered after cleanup: ${worktreePath}`, cleanupBlocked: true };
763
+ if (!existsSync(worktreePath)) {
764
+ const released = releaseOwnership({ worktreePath });
765
+ return {
766
+ ok: true,
767
+ removed: true,
768
+ actions: [`cleaned worktree files ${worktreePath}`],
769
+ ...(released ? {} : { ownershipRecordStale: true }),
770
+ };
405
771
  }
406
772
  return { ok: false, reason: WORKTREE_LOCKED, error: `could not verify worktree removal for ${worktreePath}`, cleanupBlocked: true };
407
773
  }
@@ -409,10 +775,28 @@ export async function cleanupIsolatedWorkspace({
409
775
  return { ok: false, reason: WORKTREE_LOCKED, error: `worktree cleanup failed while ${worktreePath} remains (${root ? 'not a Crew-owned disposable path' : 'main repository root unresolvable'})`, cleanupBlocked: true };
410
776
  }
411
777
 
412
- export async function staleWorktrees({ git, allowed = [] } = {}) {
778
+ /**
779
+ * Split the linked worktrees of this repository into:
780
+ *
781
+ * - `stale`: Crew recorded creating it and it is still in the disposable state
782
+ * Crew leaves behind, so it may be removed automatically;
783
+ * - `retained`: Crew recorded creating it, but an operator has since attached a
784
+ * branch or checked something out, so it is no longer Crew's to discard;
785
+ * - `unowned`: Crew cannot show it created it at all.
786
+ *
787
+ * Only the first is ever deleted. The other two are reported so a human decides.
788
+ * Returned paths are canonical: git reports the spelling it resolved when the
789
+ * worktree was added, which on Windows can expand an 8.3 temp name (`RUNNER~1`)
790
+ * into its long form, so a caller comparing its own path string against the
791
+ * answer would miss — and a miss here decides whether a live worktree looks
792
+ * like a leftover.
793
+ */
794
+ async function worktreeCandidates({ git, allowed = [] } = {}) {
413
795
  const run = git ?? defaultRunner;
414
- const set = new Set(allowed.map((p) => resolve(p)));
796
+ const set = new Set(allowed.map((p) => pathIdentity(p)));
415
797
  const stale = [];
798
+ const retained = [];
799
+ const unowned = [];
416
800
  try {
417
801
  const root = await inspectRepository({ cwd: allowed[0] ?? process.cwd(), git: run });
418
802
  if (root.ok) {
@@ -422,31 +806,130 @@ export async function staleWorktrees({ git, allowed = [] } = {}) {
422
806
  // inside a linked worktree reports that worktree as the top level, so the
423
807
  // repo root cannot be matched by path — the position in the list is what
424
808
  // identifies the main tree.
425
- const blocks = String(res.stdout).split('\n\n');
426
- for (let index = 1; index < blocks.length; index += 1) {
427
- const path = blocks[index].split('\n').find((l) => l.startsWith('worktree '))?.slice('worktree '.length)?.trim();
428
- if (!path) continue;
429
- const abs = resolve(path);
809
+ const records = parseWorktrees(res.stdout);
810
+ const repo = await commonDirOf(run, root.repoRoot);
811
+ for (let index = 1; index < records.length; index += 1) {
812
+ const { path: abs, detached, head } = records[index];
430
813
  // A repo whose directory happens to start with a Crew prefix (say, a
431
814
  // checkout named dsh-crew-something) is not a disposable worktree, and
432
815
  // treating it as stale would have the cleanup path fighting over it.
433
- if (isCrewWorktreeName(basename(abs)) && !set.has(abs)) stale.push(abs);
816
+ if (!isCrewWorktreeName(basename(abs)) || set.has(pathIdentity(abs))) continue;
817
+ const incarnation = await incarnationOf(run, abs);
818
+ const owned = readOwnership({ worktreePath: abs, repo, incarnation });
819
+ if (!owned) {
820
+ // The name is not proof: `dsh-crew-backup-deadbeef` is a name a user
821
+ // could plausibly pick. No valid record, no deletion.
822
+ unowned.push(pathIdentity(abs));
823
+ } else if (detached && owned.head === head) {
824
+ // Crew's worktree, untouched since Crew left it there. The incarnation
825
+ // and HEAD travel with it so the removal can re-check them rather
826
+ // than trusting that nothing changed since this scan.
827
+ stale.push({ path: pathIdentity(abs), incarnation, head });
828
+ } else {
829
+ // Crew's worktree, but no longer a disposable one. Being on a branch
830
+ // is only one way an operator takes a worktree over; committing on a
831
+ // detached HEAD, or checking out something else, moves it just as
832
+ // far out of Crew's hands.
833
+ retained.push(pathIdentity(abs));
834
+ }
434
835
  }
435
836
  }
436
837
  }
437
838
  } catch {}
438
- return stale;
839
+ return { stale, retained, unowned };
439
840
  }
440
841
 
441
- export async function pruneWorktrees({ git, allowed = [] } = {}) {
842
+ /** Crew-created worktrees that are still disposable and belong to no active job. */
843
+ export async function staleWorktrees({ git, allowed = [] } = {}) {
844
+ return (await worktreeCandidates({ git, allowed })).stale.map((entry) => entry.path);
845
+ }
846
+
847
+ /** Linked worktrees that resemble Crew's but carry no usable record of it. */
848
+ export async function unownedWorktrees({ git, allowed = [] } = {}) {
849
+ return (await worktreeCandidates({ git, allowed })).unowned;
850
+ }
851
+
852
+ /** Crew-created worktrees an operator has since taken over; never removed. */
853
+ export async function retainedWorktrees({ git, allowed = [] } = {}) {
854
+ return (await worktreeCandidates({ git, allowed })).retained;
855
+ }
856
+
857
+ /**
858
+ * Report what Crew could clean up, and only delete when explicitly asked.
859
+ *
860
+ * `remove` defaults to false, and the default is the point. Deleting from a
861
+ * background scan has been shown insufficient three separate ways: git removes a
862
+ * worktree whose only content is ignored; it removes one whose tracked file
863
+ * carries `assume-unchanged` or `skip-worktree`; and between validating a
864
+ * worktree and deleting it, the worktree can change. Each was measured against
865
+ * real git rather than reasoned about, and each ends with an operator's work
866
+ * gone. Nothing in Crew calls this path, so the automatic capability buys nothing
867
+ * and risks the one thing Crew must not lose.
868
+ *
869
+ * A caller that has decided to remove a specific worktree should pass
870
+ * `remove: true`, or better, use `cleanupIsolatedWorkspace` for a worktree it
871
+ * directly owns. Unattended deletion would need a lease on the worktree, not
872
+ * another pre-delete observation.
873
+ */
874
+ export async function pruneWorktrees({ git, allowed = [], remove = false } = {}) {
442
875
  const run = git ?? defaultRunner;
443
- const stale = await staleWorktrees({ git: run, allowed });
876
+ const { stale, retained, unowned } = await worktreeCandidates({ git: run, allowed });
444
877
  const actions = [];
445
- for (const w of stale) {
446
- const r = await cleanupIsolatedWorkspace({ worktreePath: w, git: run });
447
- actions.push(...(r.actions ?? [r.error ?? `stale worktree ${w}`]));
878
+
879
+ if (!remove) {
880
+ for (const entry of stale) actions.push(`stale Crew worktree, remove explicitly if it is finished with: ${entry.path}`);
881
+ for (const w of unowned) actions.push(`not Crew-owned, left in place: ${w}`);
882
+ for (const w of retained) actions.push(`taken over by an operator, left in place: ${w}`);
883
+ return {
884
+ ok: true,
885
+ reportOnly: true,
886
+ removed: 0,
887
+ candidates: stale.map((entry) => entry.path),
888
+ failed: [],
889
+ unowned,
890
+ retained,
891
+ staleRecords: [],
892
+ actions,
893
+ };
448
894
  }
449
- return { ok: true, removed: stale.length, actions };
895
+
896
+ const failed = [];
897
+ const staleRecords = [];
898
+ let removed = 0;
899
+ for (const entry of stale) {
900
+ // Not forced, and re-checked against what was scanned: background pruning
901
+ // recognised this worktree from a name and a record, and neither survives the
902
+ // tree itself being taken over. `--force` would discard whatever an operator
903
+ // had put there; ownershipDrift would not notice if they had already.
904
+ const r = await cleanupIsolatedWorkspace({
905
+ worktreePath: entry.path,
906
+ git: run,
907
+ force: false,
908
+ expect: { incarnation: entry.incarnation, head: entry.head },
909
+ });
910
+ // A locked worktree is left on disk, so counting it as removed would tell an
911
+ // operator the machine is clean when it is not.
912
+ if (r.ok && r.removed) {
913
+ removed += 1;
914
+ actions.push(...(r.actions ?? []));
915
+ if (r.ownershipRecordStale) staleRecords.push(entry.path);
916
+ } else {
917
+ failed.push(entry.path);
918
+ actions.push(`failed to remove ${entry.path}: ${r.error ?? 'unknown reason'}`);
919
+ }
920
+ }
921
+ for (const w of unowned) actions.push(`not Crew-owned, left in place: ${w}`);
922
+ for (const w of retained) actions.push(`taken over by an operator, left in place: ${w}`);
923
+ return {
924
+ ok: failed.length === 0,
925
+ removed,
926
+ failed,
927
+ unowned,
928
+ retained,
929
+ staleRecords,
930
+ actions,
931
+ ...(failed.length ? { cleanupBlocked: true } : {}),
932
+ };
450
933
  }
451
934
 
452
935
  export function clampMaxParallel(raw) {