@ai-dossier/worktree-pool 0.4.0 → 0.5.1

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.
@@ -33,6 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.readPoolFileConfig = readPoolFileConfig;
36
37
  exports.resolvePoolDir = resolvePoolDir;
37
38
  exports.replenish = replenish;
38
39
  exports.claim = claim;
@@ -40,15 +41,18 @@ exports.returnWorktree = returnWorktree;
40
41
  exports.refresh = refresh;
41
42
  exports.gc = gc;
42
43
  exports.status = status;
44
+ exports.detect = detect;
43
45
  exports.init = init;
44
46
  const node_child_process_1 = require("node:child_process");
45
47
  const fs = __importStar(require("node:fs"));
46
48
  const path = __importStar(require("node:path"));
47
49
  const readline = __importStar(require("node:readline"));
48
50
  const pool_state_1 = require("./pool-state");
51
+ const project_env_1 = require("./project-env");
49
52
  const types_1 = require("./types");
50
53
  const LOCK_TIMEOUT_MS = 10_000;
51
54
  const LOCK_RETRY_MS = 200;
55
+ const WARM_COMMAND_TIMEOUT_MS = 300_000;
52
56
  const POOL_CONFIG_FILE = '.worktree-pool.json';
53
57
  // --- Git helpers ---
54
58
  function findGitRoot() {
@@ -67,25 +71,49 @@ function git(args, opts) {
67
71
  function getConfigPath(gitRoot) {
68
72
  return path.join(gitRoot, POOL_CONFIG_FILE);
69
73
  }
70
- function readPoolDirConfig(gitRoot) {
74
+ /**
75
+ * Read `.worktree-pool.json` from the repo root. Missing or corrupt files fall
76
+ * back to defaults. `pool_dir`, when set, is resolved to an absolute path.
77
+ */
78
+ function readPoolFileConfig(gitRoot) {
71
79
  const configPath = getConfigPath(gitRoot);
72
80
  if (!fs.existsSync(configPath))
73
- return null;
81
+ return (0, pool_state_1.normalizePoolFileConfig)({});
82
+ let raw;
74
83
  try {
75
- const raw = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
76
- if (raw.pool_dir && typeof raw.pool_dir === 'string') {
77
- return { pool_dir: path.resolve(gitRoot, raw.pool_dir) };
78
- }
84
+ raw = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
79
85
  }
80
86
  catch {
81
87
  // corrupt config — treat as absent
88
+ return (0, pool_state_1.normalizePoolFileConfig)({});
89
+ }
90
+ const cfg = (0, pool_state_1.normalizePoolFileConfig)(raw);
91
+ if (cfg.pool_dir) {
92
+ cfg.pool_dir = path.resolve(gitRoot, cfg.pool_dir);
82
93
  }
83
- return null;
94
+ return cfg;
84
95
  }
96
+ function readPoolDirConfig(gitRoot) {
97
+ const cfg = readPoolFileConfig(gitRoot);
98
+ return cfg.pool_dir ? { pool_dir: cfg.pool_dir } : null;
99
+ }
100
+ /** Persist `pool_dir` without clobbering other keys in the config file. */
85
101
  function writePoolDirConfig(gitRoot, poolDir) {
86
102
  const configPath = getConfigPath(gitRoot);
87
- const relPoolDir = path.relative(gitRoot, poolDir);
88
- fs.writeFileSync(configPath, `${JSON.stringify({ pool_dir: relPoolDir }, null, 2)}\n`);
103
+ let existing = {};
104
+ if (fs.existsSync(configPath)) {
105
+ try {
106
+ const parsed = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
107
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
108
+ existing = parsed;
109
+ }
110
+ }
111
+ catch {
112
+ // corrupt config — overwrite it
113
+ }
114
+ }
115
+ existing.pool_dir = path.relative(gitRoot, poolDir);
116
+ fs.writeFileSync(configPath, `${JSON.stringify(existing, null, 2)}\n`);
89
117
  }
90
118
  function discoverPoolDirFromWorktrees(gitRoot) {
91
119
  try {
@@ -289,28 +317,176 @@ function copyEnvFiles(gitRoot, targetDir) {
289
317
  }
290
318
  walk(gitRoot, 0, '');
291
319
  }
320
+ // --- Warm-up commands ---
321
+ /**
322
+ * Run the project's warm-up commands (install + build) inside a worktree.
323
+ *
324
+ * Commands come from `warm_commands` in `.worktree-pool.json` when set,
325
+ * otherwise from package-manager detection. They run in
326
+ * `<worktree>/<project_subdir>` so nested monorepo package roots work.
327
+ */
328
+ function runWarmCommands(worktreeAbsPath, cfg) {
329
+ const projectDir = (0, project_env_1.resolveProjectDir)(worktreeAbsPath, cfg.project_subdir);
330
+ for (const cmd of (0, project_env_1.resolveWarmCommands)(projectDir, cfg)) {
331
+ const [bin, ...args] = cmd;
332
+ if (!bin)
333
+ continue;
334
+ (0, node_child_process_1.execFileSync)(bin, args, {
335
+ cwd: projectDir,
336
+ stdio: 'pipe',
337
+ timeout: WARM_COMMAND_TIMEOUT_MS,
338
+ });
339
+ }
340
+ }
341
+ /**
342
+ * Repo-root-relative lockfile path for a worktree, used to decide whether a
343
+ * range of upstream commits invalidates the installed dependencies.
344
+ */
345
+ function lockfilePathForWorktree(worktreeAbsPath, cfg) {
346
+ const projectDir = (0, project_env_1.resolveProjectDir)(worktreeAbsPath, cfg.project_subdir);
347
+ return (0, project_env_1.lockfilePathFor)((0, project_env_1.detectProjectEnv)(projectDir), cfg.project_subdir);
348
+ }
349
+ // --- Ownership / provenance ---
350
+ //
351
+ // The pool directory is routinely shared with worktrees a developer made by
352
+ // hand (imboard-ai/ai-dossier#438). Every path that deletes a directory or a
353
+ // branch goes through this layer first; nothing is removed on the strength of
354
+ // its location alone.
355
+ /** Resolve symlinks so paths from git and from config compare equal. */
356
+ function realpathOrSelf(p) {
357
+ try {
358
+ return fs.realpathSync(p);
359
+ }
360
+ catch {
361
+ return path.resolve(p);
362
+ }
363
+ }
364
+ /**
365
+ * Absolute worktree path -> checked-out branch, from `git worktree list`.
366
+ * A registered worktree with a detached HEAD maps to `null`.
367
+ */
368
+ function listWorktreeBranches(gitRoot) {
369
+ const map = new Map();
370
+ let output;
371
+ try {
372
+ output = git(['worktree', 'list', '--porcelain'], { cwd: gitRoot });
373
+ }
374
+ catch {
375
+ return map;
376
+ }
377
+ let current = null;
378
+ for (const raw of output.split('\n')) {
379
+ const line = raw.trimEnd();
380
+ if (line.startsWith('worktree ')) {
381
+ current = realpathOrSelf(line.slice('worktree '.length));
382
+ map.set(current, null);
383
+ }
384
+ else if (current !== null && line.startsWith('branch ')) {
385
+ map.set(current, line.slice('branch '.length).replace(/^refs\/heads\//, ''));
386
+ }
387
+ else if (line === '') {
388
+ current = null;
389
+ }
390
+ }
391
+ return map;
392
+ }
393
+ function buildOwnershipContext(gitRoot, poolDir, state) {
394
+ return {
395
+ gitRoot,
396
+ poolDir,
397
+ state: state ?? readState(poolDir),
398
+ branches: listWorktreeBranches(gitRoot),
399
+ };
400
+ }
401
+ /** Classify one absolute path against the pool's provenance rules. */
402
+ function classifyPath(ctx, absPath) {
403
+ const resolved = realpathOrSelf(absPath);
404
+ const name = path.basename(resolved);
405
+ const poolDirReal = realpathOrSelf(ctx.poolDir);
406
+ if (path.dirname(resolved) !== poolDirReal) {
407
+ return {
408
+ name,
409
+ path: resolved,
410
+ branch: ctx.branches.get(resolved) ?? null,
411
+ provenance: 'foreign',
412
+ owned: false,
413
+ reason: `outside the pool directory (${poolDirReal})`,
414
+ };
415
+ }
416
+ const registered = ctx.branches.has(resolved);
417
+ const classification = (0, pool_state_1.classifyPoolDirEntry)({
418
+ name,
419
+ branch: ctx.branches.get(resolved) ?? null,
420
+ registered,
421
+ existsOnDisk: fs.existsSync(resolved),
422
+ stateEntry: ctx.state.worktrees.find((w) => w.path === name) ?? null,
423
+ });
424
+ return {
425
+ name,
426
+ path: resolved,
427
+ branch: ctx.branches.get(resolved) ?? null,
428
+ ...classification,
429
+ };
430
+ }
431
+ /** Every directory inside the pool directory, classified. */
432
+ function scanPoolDir(ctx) {
433
+ if (!fs.existsSync(ctx.poolDir))
434
+ return [];
435
+ const reports = [];
436
+ for (const entry of fs.readdirSync(ctx.poolDir, { withFileTypes: true })) {
437
+ if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name === 'node_modules') {
438
+ continue;
439
+ }
440
+ reports.push(classifyPath(ctx, path.join(ctx.poolDir, entry.name)));
441
+ }
442
+ return reports;
443
+ }
444
+ /** Foreign worktrees found in the pool directory, for reporting. */
445
+ function foreignInPoolDir(gitRoot, poolDir, state) {
446
+ const ctx = buildOwnershipContext(gitRoot, poolDir, state);
447
+ return scanPoolDir(ctx).filter((e) => !e.owned);
448
+ }
292
449
  // --- Destroy helper ---
293
- function destroyWorktree(gitRoot, tempBranch, absPath) {
450
+ /**
451
+ * Remove a worktree the pool created. Refuses anything it cannot prove is the
452
+ * pool's own — the caller is expected to report the refusal, not swallow it.
453
+ */
454
+ function destroyWorktree(ctx, tempBranch, absPath) {
455
+ const classification = classifyPath(ctx, absPath);
456
+ if (!classification.owned) {
457
+ throw new Error(`Refusing to remove ${absPath}: ${classification.reason}. ` +
458
+ 'The worktree pool only removes worktrees it created.');
459
+ }
294
460
  try {
295
- git(['worktree', 'remove', absPath, '--force'], { cwd: gitRoot });
461
+ git(['worktree', 'remove', absPath, '--force'], { cwd: ctx.gitRoot });
296
462
  }
297
463
  catch {
298
464
  fs.rmSync(absPath, { recursive: true, force: true });
299
465
  }
466
+ deletePoolTempBranch(ctx.gitRoot, tempBranch);
467
+ git(['worktree', 'prune'], { cwd: ctx.gitRoot });
468
+ }
469
+ /**
470
+ * Delete a `pool/spare-*` branch. Any other ref is left alone — a corrupt or
471
+ * hand-edited `.pool-state.json` must not be able to delete a real branch.
472
+ */
473
+ function deletePoolTempBranch(gitRoot, branch) {
474
+ if (!(0, pool_state_1.isPoolTempBranch)(branch))
475
+ return;
300
476
  try {
301
- git(['branch', '-D', tempBranch], { cwd: gitRoot });
477
+ git(['branch', '-D', branch], { cwd: gitRoot });
302
478
  }
303
479
  catch {
304
480
  // branch may not exist
305
481
  }
306
- git(['worktree', 'prune'], { cwd: gitRoot });
307
482
  }
308
483
  // --- Public operations ---
309
484
  async function replenish(count, _parallel = false) {
310
485
  const gitRoot = findGitRoot();
486
+ const cfg = readPoolFileConfig(gitRoot);
311
487
  const poolDir = await resolvePoolDir(gitRoot, { interactive: true });
312
488
  fs.mkdirSync(poolDir, { recursive: true });
313
- git(['fetch', 'origin'], { cwd: gitRoot });
489
+ git(['fetch', (0, pool_state_1.remoteForBaseRef)(cfg.base_ref)], { cwd: gitRoot });
314
490
  let toCreate;
315
491
  if (count !== undefined) {
316
492
  toCreate = count;
@@ -329,7 +505,7 @@ async function replenish(count, _parallel = false) {
329
505
  const absWorktreePath = toAbs(poolDir, id);
330
506
  const tempBranch = `pool/spare-${id}`;
331
507
  const baseCommit = withLock(poolDir, (state) => {
332
- const sha = git(['rev-parse', 'origin/main'], { cwd: gitRoot });
508
+ const sha = git(['rev-parse', cfg.base_ref], { cwd: gitRoot });
333
509
  const wt = {
334
510
  id,
335
511
  path: id,
@@ -343,7 +519,7 @@ async function replenish(count, _parallel = false) {
343
519
  return { state: (0, pool_state_1.addWorktree)(state, wt), result: sha };
344
520
  });
345
521
  try {
346
- git(['branch', tempBranch, 'origin/main'], { cwd: gitRoot });
522
+ git(['branch', tempBranch, cfg.base_ref], { cwd: gitRoot });
347
523
  git(['worktree', 'add', absWorktreePath, tempBranch], {
348
524
  cwd: gitRoot,
349
525
  });
@@ -352,16 +528,7 @@ async function replenish(count, _parallel = false) {
352
528
  result: undefined,
353
529
  }));
354
530
  copyEnvFiles(gitRoot, absWorktreePath);
355
- (0, node_child_process_1.execFileSync)('npm', ['install'], {
356
- cwd: absWorktreePath,
357
- stdio: 'pipe',
358
- timeout: 300_000,
359
- });
360
- (0, node_child_process_1.execFileSync)('make', ['build-core', 'build-mcp', 'build-cli'], {
361
- cwd: absWorktreePath,
362
- stdio: 'pipe',
363
- timeout: 300_000,
364
- });
531
+ runWarmCommands(absWorktreePath, cfg);
365
532
  withLock(poolDir, (state) => ({
366
533
  state: (0, pool_state_1.updateWorktree)(state, id, {
367
534
  status: 'warm',
@@ -376,10 +543,12 @@ async function replenish(count, _parallel = false) {
376
543
  const msg = err instanceof Error ? err.message : String(err);
377
544
  errors.push(`Failed to create ${id}: ${msg}`);
378
545
  try {
379
- destroyWorktree(gitRoot, tempBranch, absWorktreePath);
546
+ destroyWorktree(buildOwnershipContext(gitRoot, poolDir), tempBranch, absWorktreePath);
380
547
  }
381
- catch {
382
- // best effort
548
+ catch (cleanupErr) {
549
+ // A refusal here means the path is not provably ours — say so rather
550
+ // than deleting it anyway.
551
+ errors.push(`Could not clean up ${id}: ${cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr)}`);
383
552
  }
384
553
  withLock(poolDir, (state) => ({
385
554
  state: (0, pool_state_1.removeWorktree)(state, id),
@@ -396,6 +565,7 @@ async function replenish(count, _parallel = false) {
396
565
  }
397
566
  function claim(issue, branch) {
398
567
  const gitRoot = findGitRoot();
568
+ const cfg = readPoolFileConfig(gitRoot);
399
569
  const poolDir = resolvePoolDirSync(gitRoot);
400
570
  const result = withLock(poolDir, (state) => {
401
571
  const claimed = (0, pool_state_1.claimWorktree)(state, issue, branch);
@@ -411,31 +581,23 @@ function claim(issue, branch) {
411
581
  const newAbsPath = toAbs(poolDir, branchDir);
412
582
  try {
413
583
  // Check freshness
414
- const currentMain = git(['rev-parse', 'origin/main'], { cwd: gitRoot });
415
- if (worktree.base_commit !== currentMain) {
584
+ const currentBase = git(['rev-parse', cfg.base_ref], { cwd: gitRoot });
585
+ if (worktree.base_commit !== currentBase) {
586
+ const lockfilePath = lockfilePathForWorktree(absPath, cfg);
416
587
  let lockChanged = false;
417
588
  try {
418
- const diff = git(['diff', '--name-only', `${worktree.base_commit}..origin/main`], {
589
+ const diff = git(['diff', '--name-only', `${worktree.base_commit}..${cfg.base_ref}`], {
419
590
  cwd: gitRoot,
420
591
  });
421
- lockChanged = diff.includes('package-lock.json');
592
+ lockChanged = (0, project_env_1.lockfileChangedInDiff)(diff, lockfilePath);
422
593
  }
423
594
  catch {
424
595
  lockChanged = true;
425
596
  }
426
- git(['fetch', 'origin'], { cwd: absPath });
427
- git(['reset', '--hard', 'origin/main'], { cwd: absPath });
597
+ git(['fetch', (0, pool_state_1.remoteForBaseRef)(cfg.base_ref)], { cwd: absPath });
598
+ git(['reset', '--hard', cfg.base_ref], { cwd: absPath });
428
599
  if (lockChanged) {
429
- (0, node_child_process_1.execFileSync)('npm', ['install'], {
430
- cwd: absPath,
431
- stdio: 'pipe',
432
- timeout: 300_000,
433
- });
434
- (0, node_child_process_1.execFileSync)('make', ['build-core', 'build-mcp', 'build-cli'], {
435
- cwd: absPath,
436
- stdio: 'pipe',
437
- timeout: 300_000,
438
- });
600
+ runWarmCommands(absPath, cfg);
439
601
  }
440
602
  }
441
603
  // Create issue branch
@@ -478,6 +640,7 @@ function claim(issue, branch) {
478
640
  }
479
641
  function returnWorktree(worktreePath) {
480
642
  const gitRoot = findGitRoot();
643
+ const cfg = readPoolFileConfig(gitRoot);
481
644
  const poolDir = resolvePoolDirSync(gitRoot);
482
645
  const absPath = path.resolve(worktreePath);
483
646
  const newId = (0, types_1.generateId)();
@@ -495,8 +658,8 @@ function returnWorktree(worktreePath) {
495
658
  };
496
659
  });
497
660
  try {
498
- git(['fetch', 'origin'], { cwd: absPath });
499
- git(['checkout', '-b', newTempBranch, 'origin/main'], { cwd: absPath });
661
+ git(['fetch', (0, pool_state_1.remoteForBaseRef)(cfg.base_ref)], { cwd: absPath });
662
+ git(['checkout', '-b', newTempBranch, cfg.base_ref], { cwd: absPath });
500
663
  git(['clean', '-fd'], { cwd: absPath });
501
664
  if (entry.assigned_branch) {
502
665
  try {
@@ -507,31 +670,26 @@ function returnWorktree(worktreePath) {
507
670
  }
508
671
  }
509
672
  if (absPath !== newAbsPath) {
673
+ if (fs.existsSync(newAbsPath)) {
674
+ throw new Error(`Recycle target already exists: ${newAbsPath}`);
675
+ }
510
676
  fs.renameSync(absPath, newAbsPath);
511
677
  git(['worktree', 'repair'], { cwd: gitRoot });
512
678
  }
513
- const currentMain = git(['rev-parse', 'origin/main'], { cwd: gitRoot });
679
+ const currentBase = git(['rev-parse', cfg.base_ref], { cwd: gitRoot });
680
+ const lockfilePath = lockfilePathForWorktree(newAbsPath, cfg);
514
681
  let lockChanged = false;
515
682
  try {
516
- const diff = git(['diff', '--name-only', `${entry.base_commit}..origin/main`], {
683
+ const diff = git(['diff', '--name-only', `${entry.base_commit}..${cfg.base_ref}`], {
517
684
  cwd: gitRoot,
518
685
  });
519
- lockChanged = diff.includes('package-lock.json');
686
+ lockChanged = (0, project_env_1.lockfileChangedInDiff)(diff, lockfilePath);
520
687
  }
521
688
  catch {
522
689
  lockChanged = true;
523
690
  }
524
691
  if (lockChanged) {
525
- (0, node_child_process_1.execFileSync)('npm', ['install'], {
526
- cwd: newAbsPath,
527
- stdio: 'pipe',
528
- timeout: 300_000,
529
- });
530
- (0, node_child_process_1.execFileSync)('make', ['build-core', 'build-mcp', 'build-cli'], {
531
- cwd: newAbsPath,
532
- stdio: 'pipe',
533
- timeout: 300_000,
534
- });
692
+ runWarmCommands(newAbsPath, cfg);
535
693
  }
536
694
  withLock(poolDir, (state) => ({
537
695
  state: (0, pool_state_1.updateWorktree)(state, entry.id, {
@@ -539,7 +697,7 @@ function returnWorktree(worktreePath) {
539
697
  path: newId,
540
698
  status: 'warm',
541
699
  temp_branch: newTempBranch,
542
- base_commit: currentMain,
700
+ base_commit: currentBase,
543
701
  warmed_at: new Date().toISOString(),
544
702
  assigned_to_issue: null,
545
703
  assigned_branch: null,
@@ -548,48 +706,56 @@ function returnWorktree(worktreePath) {
548
706
  }));
549
707
  }
550
708
  catch (err) {
551
- try {
552
- destroyWorktree(gitRoot, entry.temp_branch, absPath);
553
- }
554
- catch {
555
- // best effort
556
- }
557
- try {
558
- destroyWorktree(gitRoot, newTempBranch, newAbsPath);
559
- }
560
- catch {
561
- // best effort
709
+ // Cleanup is best-effort but never indiscriminate: `destroyWorktree`
710
+ // refuses any path it cannot prove the pool created, and those refusals
711
+ // are surfaced instead of being retried with `rm -rf`.
712
+ const skipped = [];
713
+ const ctx = buildOwnershipContext(gitRoot, poolDir);
714
+ for (const [branch, target] of [
715
+ [entry.temp_branch, absPath],
716
+ [newTempBranch, newAbsPath],
717
+ ]) {
718
+ if (!fs.existsSync(target))
719
+ continue;
720
+ try {
721
+ destroyWorktree(ctx, branch, target);
722
+ }
723
+ catch (cleanupErr) {
724
+ skipped.push(cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr));
725
+ }
562
726
  }
563
727
  withLock(poolDir, (state) => ({
564
728
  state: (0, pool_state_1.removeWorktree)(state, entry.id),
565
729
  result: undefined,
566
730
  }));
567
- throw new Error(`Recycle failed (destroyed worktree): ${err instanceof Error ? err.message : String(err)}`);
731
+ const suffix = skipped.length > 0 ? ` Left on disk: ${skipped.join('; ')}` : '';
732
+ throw new Error(`Recycle failed (destroyed worktree): ${err instanceof Error ? err.message : String(err)}.${suffix}`);
568
733
  }
569
734
  }
570
735
  function refresh() {
571
736
  const gitRoot = findGitRoot();
737
+ const cfg = readPoolFileConfig(gitRoot);
572
738
  const poolDir = resolvePoolDirSync(gitRoot);
573
- git(['fetch', 'origin'], { cwd: gitRoot });
739
+ git(['fetch', (0, pool_state_1.remoteForBaseRef)(cfg.base_ref)], { cwd: gitRoot });
574
740
  const state = readState(poolDir);
741
+ const ctx = buildOwnershipContext(gitRoot, poolDir, state);
575
742
  const warmWorktrees = state.worktrees.filter((w) => w.status === 'warm');
576
743
  let refreshed = 0;
744
+ const skipped = [];
577
745
  const errors = [];
578
746
  for (const wt of warmWorktrees) {
579
747
  const absPath = toAbs(poolDir, wt.path);
748
+ // `git reset --hard` destroys uncommitted work, so it is only ever pointed
749
+ // at a directory that still looks like the pool worktree state describes.
750
+ const classification = classifyPath(ctx, absPath);
751
+ if (!classification.owned) {
752
+ skipped.push(classification);
753
+ continue;
754
+ }
580
755
  try {
581
- git(['fetch', 'origin'], { cwd: absPath });
582
- git(['reset', '--hard', 'origin/main'], { cwd: absPath });
583
- (0, node_child_process_1.execFileSync)('npm', ['install'], {
584
- cwd: absPath,
585
- stdio: 'pipe',
586
- timeout: 300_000,
587
- });
588
- (0, node_child_process_1.execFileSync)('make', ['build-core', 'build-mcp', 'build-cli'], {
589
- cwd: absPath,
590
- stdio: 'pipe',
591
- timeout: 300_000,
592
- });
756
+ git(['fetch', (0, pool_state_1.remoteForBaseRef)(cfg.base_ref)], { cwd: absPath });
757
+ git(['reset', '--hard', cfg.base_ref], { cwd: absPath });
758
+ runWarmCommands(absPath, cfg);
593
759
  const newSha = git(['rev-parse', 'HEAD'], { cwd: absPath });
594
760
  withLock(poolDir, (state) => ({
595
761
  state: (0, pool_state_1.updateWorktree)(state, wt.id, {
@@ -604,88 +770,168 @@ function refresh() {
604
770
  errors.push(`Failed to refresh ${wt.id}: ${err instanceof Error ? err.message : String(err)}`);
605
771
  }
606
772
  }
607
- return { refreshed, errors };
773
+ return { refreshed, skipped, errors };
608
774
  }
609
- function gc() {
775
+ function describeGcPlan(candidates, foreign) {
776
+ if (candidates.length === 0) {
777
+ console.error('Nothing to remove.');
778
+ }
779
+ else {
780
+ console.error(`Will remove ${candidates.length} item(s):`);
781
+ for (const c of candidates) {
782
+ const target = c.path ?? '(state entry only)';
783
+ console.error(` [${c.kind}] ${target}`);
784
+ console.error(` ${c.reason}`);
785
+ }
786
+ }
787
+ if (foreign.length > 0) {
788
+ console.error(`\nForeign, skipped (${foreign.length}) — not created by the pool:`);
789
+ for (const f of foreign) {
790
+ console.error(` ${f.path}`);
791
+ console.error(` ${f.reason}`);
792
+ }
793
+ }
794
+ }
795
+ async function gc(opts = {}) {
610
796
  const gitRoot = findGitRoot();
611
797
  const poolDir = resolvePoolDirSync(gitRoot);
612
798
  const errors = [];
613
799
  const staleIds = [];
614
800
  const orphanIds = [];
615
801
  let removed = 0;
616
- // Get disk state — directory names inside poolDir
617
- const existingNames = new Set();
618
- if (fs.existsSync(poolDir)) {
619
- for (const entry of fs.readdirSync(poolDir, { withFileTypes: true })) {
620
- if (entry.isDirectory() && !entry.name.startsWith('.') && entry.name !== 'node_modules') {
621
- existingNames.add(entry.name);
622
- }
623
- }
624
- }
625
802
  const state = readState(poolDir);
626
- // Remove stale worktrees
627
- const stale = (0, pool_state_1.findStaleWorktrees)(state);
628
- for (const wt of stale) {
629
- try {
630
- destroyWorktree(gitRoot, wt.temp_branch, toAbs(poolDir, wt.path));
631
- withLock(poolDir, (s) => ({
632
- state: (0, pool_state_1.removeWorktree)(s, wt.id),
633
- result: undefined,
634
- }));
635
- staleIds.push(wt.id);
636
- removed++;
803
+ const ctx = buildOwnershipContext(gitRoot, poolDir, state);
804
+ const onDisk = scanPoolDir(ctx);
805
+ const foreign = onDisk.filter((e) => !e.owned);
806
+ const ownedNames = new Set(onDisk.filter((e) => e.owned).map((e) => e.name));
807
+ const candidates = [];
808
+ const claimed = new Set();
809
+ // Stale pool worktrees.
810
+ for (const wt of (0, pool_state_1.findStaleWorktrees)(state)) {
811
+ claimed.add(wt.id);
812
+ const classification = classifyPath(ctx, toAbs(poolDir, wt.path));
813
+ if (classification.owned) {
814
+ candidates.push({
815
+ kind: 'stale',
816
+ id: wt.id,
817
+ path: toAbs(poolDir, wt.path),
818
+ tempBranch: wt.temp_branch,
819
+ reason: `stale past ${state.config.stale_after_hours}h (warmed ${wt.warmed_at}); ${classification.reason}`,
820
+ });
637
821
  }
638
- catch (err) {
639
- errors.push(`Failed to remove stale ${wt.id}: ${err instanceof Error ? err.message : String(err)}`);
822
+ else {
823
+ // The directory this row points at is no longer ours. Drop the row so the
824
+ // pool is not wedged, but leave the directory alone.
825
+ candidates.push({
826
+ kind: 'orphan-state',
827
+ id: wt.id,
828
+ path: null,
829
+ tempBranch: wt.temp_branch,
830
+ reason: `stale state entry for ${wt.path}, which is ${classification.reason} — directory left on disk`,
831
+ });
640
832
  }
641
833
  }
642
- // Reconcile orphans — compare relative path names
834
+ // State rows whose directory is gone (or was never ours).
835
+ for (const wt of state.worktrees) {
836
+ if (claimed.has(wt.id))
837
+ continue;
838
+ if (ownedNames.has(wt.path))
839
+ continue;
840
+ claimed.add(wt.id);
841
+ const classification = classifyPath(ctx, toAbs(poolDir, wt.path));
842
+ candidates.push({
843
+ kind: 'orphan-state',
844
+ id: wt.id,
845
+ path: null,
846
+ tempBranch: wt.temp_branch,
847
+ reason: `state entry for ${wt.path}: ${classification.reason}`,
848
+ });
849
+ }
850
+ // Pool-created directories with no state row.
643
851
  const stateNames = new Set(state.worktrees.map((w) => w.path));
644
- const inStateNotOnDisk = state.worktrees.filter((w) => !existingNames.has(w.path));
645
- const onDiskNotInState = [...existingNames].filter((name) => !stateNames.has(name));
646
- for (const wt of inStateNotOnDisk) {
647
- try {
648
- withLock(poolDir, (s) => ({
649
- state: (0, pool_state_1.removeWorktree)(s, wt.id),
650
- result: undefined,
651
- }));
652
- try {
653
- git(['branch', '-D', wt.temp_branch], { cwd: gitRoot });
654
- }
655
- catch {
656
- // branch may not exist
657
- }
658
- orphanIds.push(wt.id);
659
- removed++;
852
+ for (const entry of onDisk) {
853
+ if (!entry.owned || stateNames.has(entry.name))
854
+ continue;
855
+ candidates.push({
856
+ kind: 'orphan-disk',
857
+ id: entry.name,
858
+ path: entry.path,
859
+ tempBranch: entry.branch,
860
+ reason: entry.reason,
861
+ });
862
+ }
863
+ describeGcPlan(candidates, foreign);
864
+ const base = { staleIds, orphanIds, foreign, candidates, errors };
865
+ if (opts.dryRun) {
866
+ console.error('\nDry run — nothing was removed.');
867
+ return { ...base, removed: 0, dryRun: true, aborted: false };
868
+ }
869
+ if (candidates.length > 0 && !opts.yes) {
870
+ const interactive = opts.interactive ?? Boolean(process.stdin.isTTY);
871
+ if (!interactive) {
872
+ console.error('\nRefusing to remove without confirmation. Re-run with --yes (or --dry-run).');
873
+ return { ...base, removed: 0, dryRun: false, aborted: true };
660
874
  }
661
- catch (err) {
662
- errors.push(`Failed to clean orphan state ${wt.id}: ${err instanceof Error ? err.message : String(err)}`);
875
+ const answer = await promptUser(`\nRemove ${candidates.length} item(s)? [y/N]: `);
876
+ if (!/^y(es)?$/i.test(answer)) {
877
+ console.error('Aborted — nothing was removed.');
878
+ return { ...base, removed: 0, dryRun: false, aborted: true };
663
879
  }
664
880
  }
665
- for (const dirName of onDiskNotInState) {
881
+ for (const c of candidates) {
666
882
  try {
667
- const absPath = toAbs(poolDir, dirName);
668
- try {
669
- git(['worktree', 'remove', absPath, '--force'], { cwd: gitRoot });
883
+ if (c.path !== null) {
884
+ destroyWorktree(ctx, c.tempBranch, c.path);
670
885
  }
671
- catch {
672
- fs.rmSync(absPath, { recursive: true, force: true });
886
+ else {
887
+ deletePoolTempBranch(gitRoot, c.tempBranch);
888
+ }
889
+ if (c.kind !== 'orphan-disk') {
890
+ withLock(poolDir, (s) => ({
891
+ state: (0, pool_state_1.removeWorktree)(s, c.id),
892
+ result: undefined,
893
+ }));
673
894
  }
674
- orphanIds.push(dirName);
895
+ if (c.kind === 'stale')
896
+ staleIds.push(c.id);
897
+ else
898
+ orphanIds.push(c.id);
675
899
  removed++;
676
900
  }
677
901
  catch (err) {
678
- errors.push(`Failed to clean orphan disk ${dirName}: ${err instanceof Error ? err.message : String(err)}`);
902
+ errors.push(`Failed to remove ${c.path ?? c.id}: ${err instanceof Error ? err.message : String(err)}`);
679
903
  }
680
904
  }
681
905
  git(['worktree', 'prune'], { cwd: gitRoot });
682
- return { removed, staleIds, orphanIds, errors };
906
+ return { ...base, removed, dryRun: false, aborted: false };
683
907
  }
684
908
  function status() {
685
909
  const gitRoot = findGitRoot();
686
910
  const poolDir = resolvePoolDirSync(gitRoot);
687
911
  const state = readState(poolDir);
688
- return { ...(0, pool_state_1.getPoolStatus)(state), pool_dir: poolDir };
912
+ return {
913
+ ...(0, pool_state_1.getPoolStatus)(state),
914
+ pool_dir: poolDir,
915
+ foreign: foreignInPoolDir(gitRoot, poolDir, state),
916
+ };
917
+ }
918
+ /**
919
+ * Detect the project environment for `dir`, or — when omitted — for this
920
+ * repo's package root (git root plus `project_subdir` from the pool config).
921
+ */
922
+ function detect(dir) {
923
+ if (dir) {
924
+ return (0, project_env_1.detectProjectEnv)(path.resolve(dir));
925
+ }
926
+ let gitRoot;
927
+ try {
928
+ gitRoot = findGitRoot();
929
+ }
930
+ catch {
931
+ return (0, project_env_1.detectProjectEnv)(process.cwd());
932
+ }
933
+ const cfg = readPoolFileConfig(gitRoot);
934
+ return (0, project_env_1.detectProjectEnv)((0, project_env_1.resolveProjectDir)(gitRoot, cfg.project_subdir));
689
935
  }
690
936
  async function init() {
691
937
  const gitRoot = findGitRoot();