@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.
- package/README.md +110 -9
- package/dist/cli.js +39 -10
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/pool-actions.d.ts +62 -5
- package/dist/pool-actions.d.ts.map +1 -1
- package/dist/pool-actions.js +385 -139
- package/dist/pool-actions.js.map +1 -1
- package/dist/pool-state.d.ts +57 -1
- package/dist/pool-state.d.ts.map +1 -1
- package/dist/pool-state.js +144 -0
- package/dist/pool-state.js.map +1 -1
- package/dist/project-env.d.ts +31 -0
- package/dist/project-env.d.ts.map +1 -0
- package/dist/project-env.js +197 -0
- package/dist/project-env.js.map +1 -0
- package/dist/types.d.ts +28 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +2 -1
- package/dist/types.js.map +1 -1
- package/package.json +1 -1
package/dist/pool-actions.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
81
|
+
return (0, pool_state_1.normalizePoolFileConfig)({});
|
|
82
|
+
let raw;
|
|
74
83
|
try {
|
|
75
|
-
|
|
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
|
|
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
|
-
|
|
88
|
-
fs.
|
|
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
|
-
|
|
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',
|
|
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',
|
|
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',
|
|
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,
|
|
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
|
-
(
|
|
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
|
-
//
|
|
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
|
|
415
|
-
if (worktree.base_commit !==
|
|
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}
|
|
589
|
+
const diff = git(['diff', '--name-only', `${worktree.base_commit}..${cfg.base_ref}`], {
|
|
419
590
|
cwd: gitRoot,
|
|
420
591
|
});
|
|
421
|
-
lockChanged =
|
|
592
|
+
lockChanged = (0, project_env_1.lockfileChangedInDiff)(diff, lockfilePath);
|
|
422
593
|
}
|
|
423
594
|
catch {
|
|
424
595
|
lockChanged = true;
|
|
425
596
|
}
|
|
426
|
-
git(['fetch',
|
|
427
|
-
git(['reset', '--hard',
|
|
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
|
-
(
|
|
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',
|
|
499
|
-
git(['checkout', '-b', newTempBranch,
|
|
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
|
|
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}
|
|
683
|
+
const diff = git(['diff', '--name-only', `${entry.base_commit}..${cfg.base_ref}`], {
|
|
517
684
|
cwd: gitRoot,
|
|
518
685
|
});
|
|
519
|
-
lockChanged =
|
|
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
|
-
(
|
|
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:
|
|
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
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
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
|
-
|
|
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',
|
|
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',
|
|
582
|
-
git(['reset', '--hard',
|
|
583
|
-
(
|
|
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
|
|
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
|
-
|
|
627
|
-
const
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
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
|
-
|
|
639
|
-
|
|
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
|
-
//
|
|
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
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
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
|
-
|
|
662
|
-
|
|
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
|
|
881
|
+
for (const c of candidates) {
|
|
666
882
|
try {
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
git(['worktree', 'remove', absPath, '--force'], { cwd: gitRoot });
|
|
883
|
+
if (c.path !== null) {
|
|
884
|
+
destroyWorktree(ctx, c.tempBranch, c.path);
|
|
670
885
|
}
|
|
671
|
-
|
|
672
|
-
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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 {
|
|
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();
|