@ai-dossier/worktree-pool 0.5.2 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +107 -6
- package/dist/cli.js +95 -8
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/pool-actions.d.ts +86 -5
- package/dist/pool-actions.d.ts.map +1 -1
- package/dist/pool-actions.js +402 -86
- package/dist/pool-actions.js.map +1 -1
- package/dist/pool-state.d.ts +31 -1
- package/dist/pool-state.d.ts.map +1 -1
- package/dist/pool-state.js +49 -7
- package/dist/pool-state.js.map +1 -1
- package/dist/types.d.ts +36 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +1 -1
package/dist/pool-actions.js
CHANGED
|
@@ -33,8 +33,10 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.ReturnFailure = void 0;
|
|
36
37
|
exports.readPoolFileConfig = readPoolFileConfig;
|
|
37
38
|
exports.resolvePoolDir = resolvePoolDir;
|
|
39
|
+
exports.findBrokenEntries = findBrokenEntries;
|
|
38
40
|
exports.replenish = replenish;
|
|
39
41
|
exports.claim = claim;
|
|
40
42
|
exports.returnWorktree = returnWorktree;
|
|
@@ -346,6 +348,53 @@ function lockfilePathForWorktree(worktreeAbsPath, cfg) {
|
|
|
346
348
|
const projectDir = (0, project_env_1.resolveProjectDir)(worktreeAbsPath, cfg.project_subdir);
|
|
347
349
|
return (0, project_env_1.lockfilePathFor)((0, project_env_1.detectProjectEnv)(projectDir), cfg.project_subdir);
|
|
348
350
|
}
|
|
351
|
+
/**
|
|
352
|
+
* Did the project lockfile change between `baseCommit` and the configured base
|
|
353
|
+
* ref? An unreadable diff counts as "changed" — re-warming is cheap, a stale
|
|
354
|
+
* `node_modules` is not.
|
|
355
|
+
*/
|
|
356
|
+
function lockfileChangedSince(gitRoot, baseCommit, worktreeAbsPath, cfg) {
|
|
357
|
+
try {
|
|
358
|
+
const diff = git(['diff', '--name-only', `${baseCommit}..${cfg.base_ref}`], { cwd: gitRoot });
|
|
359
|
+
return (0, project_env_1.lockfileChangedInDiff)(diff, lockfilePathForWorktree(worktreeAbsPath, cfg));
|
|
360
|
+
}
|
|
361
|
+
catch {
|
|
362
|
+
return true;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
/** Message of a thrown value, whatever it is. */
|
|
366
|
+
function messageOf(err) {
|
|
367
|
+
return err instanceof Error ? err.message : String(err);
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* Make git's error text safe to persist and re-print. `.pool-state.json` is a
|
|
371
|
+
* plain file that `status` echoes back, and git quotes remote URLs verbatim in
|
|
372
|
+
* its errors — including any `https://user:token@host` credentials — so an
|
|
373
|
+
* unfiltered message would write a secret to disk and replay terminal escape
|
|
374
|
+
* sequences from a remote on every later `status`.
|
|
375
|
+
*/
|
|
376
|
+
function sanitizeReason(text) {
|
|
377
|
+
return (text
|
|
378
|
+
.replace(/([a-z][a-z0-9+.-]*:\/\/)[^/\s@]+@/gi, '$1<redacted>@')
|
|
379
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: stripping terminal escapes is the point
|
|
380
|
+
.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, '')
|
|
381
|
+
.slice(0, 2000));
|
|
382
|
+
}
|
|
383
|
+
/**
|
|
384
|
+
* The value to record as an entry's `path`. State paths are contractually bare
|
|
385
|
+
* directory names directly inside the pool directory (`classifyPath` matches on
|
|
386
|
+
* `basename`), so anything that escapes — from a hand-edited or corrupted state
|
|
387
|
+
* file — keeps the previously recorded name rather than being written back.
|
|
388
|
+
*/
|
|
389
|
+
function relativePoolPath(poolDir, onDiskPath, fallback) {
|
|
390
|
+
const rel = path.relative(poolDir, onDiskPath);
|
|
391
|
+
const isBareSegment = rel.length > 0 &&
|
|
392
|
+
!path.isAbsolute(rel) &&
|
|
393
|
+
!rel.startsWith('..') &&
|
|
394
|
+
!rel.includes(path.sep) &&
|
|
395
|
+
rel !== '.';
|
|
396
|
+
return isBareSegment ? rel : fallback;
|
|
397
|
+
}
|
|
349
398
|
// --- Ownership / provenance ---
|
|
350
399
|
//
|
|
351
400
|
// The pool directory is routinely shared with worktrees a developer made by
|
|
@@ -361,6 +410,52 @@ function realpathOrSelf(p) {
|
|
|
361
410
|
return path.resolve(p);
|
|
362
411
|
}
|
|
363
412
|
}
|
|
413
|
+
/**
|
|
414
|
+
* Absolute path of the git admin dir a linked worktree points at, or `null`
|
|
415
|
+
* when the directory carries no readable `.git` link. A directory holding a
|
|
416
|
+
* real `.git` directory (a whole repo, not a linked worktree) reports itself.
|
|
417
|
+
*/
|
|
418
|
+
function resolveWorktreeAdminDir(absPath) {
|
|
419
|
+
const dotGit = path.join(absPath, '.git');
|
|
420
|
+
let stat;
|
|
421
|
+
try {
|
|
422
|
+
stat = fs.statSync(dotGit);
|
|
423
|
+
}
|
|
424
|
+
catch {
|
|
425
|
+
return null;
|
|
426
|
+
}
|
|
427
|
+
if (stat.isDirectory())
|
|
428
|
+
return dotGit;
|
|
429
|
+
let raw;
|
|
430
|
+
try {
|
|
431
|
+
raw = fs.readFileSync(dotGit, 'utf-8');
|
|
432
|
+
}
|
|
433
|
+
catch {
|
|
434
|
+
return null;
|
|
435
|
+
}
|
|
436
|
+
const match = /^gitdir:\s*(.+)$/m.exec(raw);
|
|
437
|
+
if (!match)
|
|
438
|
+
return null;
|
|
439
|
+
const target = match[1].trim();
|
|
440
|
+
if (target.length === 0)
|
|
441
|
+
return null;
|
|
442
|
+
return path.isAbsolute(target) ? target : path.resolve(absPath, target);
|
|
443
|
+
}
|
|
444
|
+
/**
|
|
445
|
+
* True when a directory that is on disk has lost the git admin dir behind it.
|
|
446
|
+
*
|
|
447
|
+
* This is the #443 corruption: a rename repaired without its new path leaves a
|
|
448
|
+
* dangling `gitdir` forward link, and the next `git worktree prune` deletes
|
|
449
|
+
* `.git/worktrees/<id>`. The directory survives with a `.git` file pointing at
|
|
450
|
+
* nothing, so every git command inside it fails with
|
|
451
|
+
* `fatal: not a git repository`.
|
|
452
|
+
*/
|
|
453
|
+
function isWorktreeAdminDirMissing(absPath) {
|
|
454
|
+
if (!fs.existsSync(absPath))
|
|
455
|
+
return false;
|
|
456
|
+
const adminDir = resolveWorktreeAdminDir(absPath);
|
|
457
|
+
return adminDir === null || !fs.existsSync(adminDir);
|
|
458
|
+
}
|
|
364
459
|
/**
|
|
365
460
|
* Absolute worktree path -> checked-out branch, from `git worktree list`.
|
|
366
461
|
* A registered worktree with a detached HEAD maps to `null`.
|
|
@@ -410,6 +505,7 @@ function classifyPath(ctx, absPath) {
|
|
|
410
505
|
branch: ctx.branches.get(resolved) ?? null,
|
|
411
506
|
provenance: 'foreign',
|
|
412
507
|
owned: false,
|
|
508
|
+
broken: false,
|
|
413
509
|
reason: `outside the pool directory (${poolDirReal})`,
|
|
414
510
|
};
|
|
415
511
|
}
|
|
@@ -420,6 +516,7 @@ function classifyPath(ctx, absPath) {
|
|
|
420
516
|
registered,
|
|
421
517
|
existsOnDisk: fs.existsSync(resolved),
|
|
422
518
|
stateEntry: ctx.state.worktrees.find((w) => w.path === name) ?? null,
|
|
519
|
+
adminDirMissing: registered ? false : isWorktreeAdminDirMissing(resolved),
|
|
423
520
|
});
|
|
424
521
|
return {
|
|
425
522
|
name,
|
|
@@ -441,10 +538,31 @@ function scanPoolDir(ctx) {
|
|
|
441
538
|
}
|
|
442
539
|
return reports;
|
|
443
540
|
}
|
|
444
|
-
/**
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
541
|
+
/**
|
|
542
|
+
* Split the pool directory into the two things worth reporting: worktrees the
|
|
543
|
+
* pool did not create, and entries corrupted on disk (#443).
|
|
544
|
+
*
|
|
545
|
+
* A corrupted entry is listed only under `broken` — reporting it as foreign as
|
|
546
|
+
* well would read as two separate problems with one directory.
|
|
547
|
+
*/
|
|
548
|
+
function reportPoolDirEntries(gitRoot, poolDir, state) {
|
|
549
|
+
const entries = scanPoolDir(buildOwnershipContext(gitRoot, poolDir, state));
|
|
550
|
+
const broken = entries.filter((e) => e.broken);
|
|
551
|
+
const brokenPaths = new Set(broken.map((e) => e.path));
|
|
552
|
+
return {
|
|
553
|
+
foreign: entries.filter((e) => !e.owned && !brokenPaths.has(e.path)),
|
|
554
|
+
broken,
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
/**
|
|
558
|
+
* Corrupted pool directories: still on disk, but git has no admin dir for them
|
|
559
|
+
* (#443). Reported so a broken pool is visible rather than surfacing as a raw
|
|
560
|
+
* `fatal: not a git repository` from whichever command touched it first.
|
|
561
|
+
*/
|
|
562
|
+
function findBrokenEntries() {
|
|
563
|
+
const gitRoot = findGitRoot();
|
|
564
|
+
const poolDir = resolvePoolDirSync(gitRoot);
|
|
565
|
+
return reportPoolDirEntries(gitRoot, poolDir).broken;
|
|
448
566
|
}
|
|
449
567
|
// --- Destroy helper ---
|
|
450
568
|
/**
|
|
@@ -567,8 +685,19 @@ function claim(issue, branch) {
|
|
|
567
685
|
const gitRoot = findGitRoot();
|
|
568
686
|
const cfg = readPoolFileConfig(gitRoot);
|
|
569
687
|
const poolDir = resolvePoolDirSync(gitRoot);
|
|
688
|
+
// A warm entry whose directory lost its git admin dir would fail every git
|
|
689
|
+
// command with a raw `fatal: not a git repository`. Skip it and take the
|
|
690
|
+
// next warm spare instead, reporting what was passed over.
|
|
691
|
+
const brokenSkipped = [];
|
|
570
692
|
const result = withLock(poolDir, (state) => {
|
|
571
|
-
const
|
|
693
|
+
const ctx = buildOwnershipContext(gitRoot, poolDir, state);
|
|
694
|
+
const claimed = (0, pool_state_1.claimWorktree)(state, issue, branch, (candidate) => {
|
|
695
|
+
const report = classifyPath(ctx, toAbs(poolDir, candidate.path));
|
|
696
|
+
if (!report.broken)
|
|
697
|
+
return true;
|
|
698
|
+
brokenSkipped.push(report);
|
|
699
|
+
return false;
|
|
700
|
+
});
|
|
572
701
|
if (!claimed)
|
|
573
702
|
return { state, result: null };
|
|
574
703
|
return { state: claimed.state, result: claimed.worktree };
|
|
@@ -583,17 +712,7 @@ function claim(issue, branch) {
|
|
|
583
712
|
// Check freshness
|
|
584
713
|
const currentBase = git(['rev-parse', cfg.base_ref], { cwd: gitRoot });
|
|
585
714
|
if (worktree.base_commit !== currentBase) {
|
|
586
|
-
const
|
|
587
|
-
let lockChanged = false;
|
|
588
|
-
try {
|
|
589
|
-
const diff = git(['diff', '--name-only', `${worktree.base_commit}..${cfg.base_ref}`], {
|
|
590
|
-
cwd: gitRoot,
|
|
591
|
-
});
|
|
592
|
-
lockChanged = (0, project_env_1.lockfileChangedInDiff)(diff, lockfilePath);
|
|
593
|
-
}
|
|
594
|
-
catch {
|
|
595
|
-
lockChanged = true;
|
|
596
|
-
}
|
|
715
|
+
const lockChanged = lockfileChangedSince(gitRoot, worktree.base_commit, absPath, cfg);
|
|
597
716
|
git(['fetch', (0, pool_state_1.remoteForBaseRef)(cfg.base_ref)], { cwd: absPath });
|
|
598
717
|
git(['reset', '--hard', cfg.base_ref], { cwd: absPath });
|
|
599
718
|
if (lockChanged) {
|
|
@@ -602,10 +721,15 @@ function claim(issue, branch) {
|
|
|
602
721
|
}
|
|
603
722
|
// Create issue branch
|
|
604
723
|
git(['checkout', '-b', branch], { cwd: absPath });
|
|
605
|
-
// Rename directory
|
|
724
|
+
// Rename directory.
|
|
725
|
+
// `git worktree repair` must be given the *new* path: a pathless repair
|
|
726
|
+
// only fixes the worktree->repo back-link, leaving the repo-side
|
|
727
|
+
// `.git/worktrees/<id>/gitdir` forward link pointing at the old location.
|
|
728
|
+
// The next `git worktree prune` then sees a dangling gitdir, deletes the
|
|
729
|
+
// admin dir, and the renamed worktree is corrupted (#443).
|
|
606
730
|
if (absPath !== newAbsPath && !fs.existsSync(newAbsPath)) {
|
|
607
731
|
fs.renameSync(absPath, newAbsPath);
|
|
608
|
-
git(['worktree', 'repair'], { cwd: gitRoot });
|
|
732
|
+
git(['worktree', 'repair', newAbsPath], { cwd: gitRoot });
|
|
609
733
|
}
|
|
610
734
|
// Delete temp branch
|
|
611
735
|
try {
|
|
@@ -623,7 +747,7 @@ function claim(issue, branch) {
|
|
|
623
747
|
}),
|
|
624
748
|
result: undefined,
|
|
625
749
|
}));
|
|
626
|
-
return { path: newAbsPath };
|
|
750
|
+
return { path: newAbsPath, broken: brokenSkipped };
|
|
627
751
|
}
|
|
628
752
|
catch (err) {
|
|
629
753
|
// Revert claim on failure
|
|
@@ -638,6 +762,139 @@ function claim(issue, branch) {
|
|
|
638
762
|
throw err;
|
|
639
763
|
}
|
|
640
764
|
}
|
|
765
|
+
/**
|
|
766
|
+
* A `return` that failed, tagged with the step that failed (#453 AC1). The
|
|
767
|
+
* pool entry has been left in `status: 'broken'` — not `assigned`, not `warm`
|
|
768
|
+
* — and the worktree directory is deliberately NOT destroyed, so the failure
|
|
769
|
+
* is inspectable and `gc` can clear it deliberately.
|
|
770
|
+
*
|
|
771
|
+
* `markError` is the one case where that promise could not be kept: the
|
|
772
|
+
* broken-marking write itself failed. Callers must report it rather than
|
|
773
|
+
* assume the entry is marked, or they reproduce the very bug #453 is about.
|
|
774
|
+
*/
|
|
775
|
+
class ReturnFailure extends Error {
|
|
776
|
+
step;
|
|
777
|
+
entryId;
|
|
778
|
+
worktreePath;
|
|
779
|
+
/** Why the entry could NOT be marked broken, or `null` when it was marked. */
|
|
780
|
+
markError;
|
|
781
|
+
constructor(step, worktreePath, entryId, cause, markError = null) {
|
|
782
|
+
super(`return failed at step '${step}': ${messageOf(cause)}`, { cause });
|
|
783
|
+
this.name = 'ReturnFailure';
|
|
784
|
+
this.step = step;
|
|
785
|
+
this.entryId = entryId;
|
|
786
|
+
this.worktreePath = worktreePath;
|
|
787
|
+
this.markError = markError;
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
exports.ReturnFailure = ReturnFailure;
|
|
791
|
+
/** Run `fn`, tagging any throw with the step it happened in. */
|
|
792
|
+
function step(name, fn) {
|
|
793
|
+
try {
|
|
794
|
+
return fn();
|
|
795
|
+
}
|
|
796
|
+
catch (err) {
|
|
797
|
+
// Kept so that if a step is ever nested inside another, the innermost
|
|
798
|
+
// attribution wins rather than being overwritten by its caller.
|
|
799
|
+
if (err instanceof StepError)
|
|
800
|
+
throw err;
|
|
801
|
+
throw new StepError(name, err);
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
/** Internal carrier so `step` can attribute a failure without unwinding twice. */
|
|
805
|
+
class StepError extends Error {
|
|
806
|
+
step;
|
|
807
|
+
constructor(stepName, cause) {
|
|
808
|
+
super(messageOf(cause), { cause });
|
|
809
|
+
this.name = 'StepError';
|
|
810
|
+
this.step = stepName;
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
/** Branch checked out at `absPath`, or `null` when it cannot be determined. */
|
|
814
|
+
function currentBranchOf(absPath) {
|
|
815
|
+
try {
|
|
816
|
+
const branch = git(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: absPath });
|
|
817
|
+
return branch === 'HEAD' ? null : branch;
|
|
818
|
+
}
|
|
819
|
+
catch {
|
|
820
|
+
return null;
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
/**
|
|
824
|
+
* Tracked-file cleanliness of `absPath`. A git failure is reported as an error
|
|
825
|
+
* rather than as "dirty" — telling an operator to look for uncommitted changes
|
|
826
|
+
* that do not exist is the wrong place to send them.
|
|
827
|
+
*/
|
|
828
|
+
function inspectWorktreeCleanliness(absPath) {
|
|
829
|
+
let output;
|
|
830
|
+
try {
|
|
831
|
+
output = git(['status', '--porcelain', '--untracked-files=no'], { cwd: absPath });
|
|
832
|
+
}
|
|
833
|
+
catch (err) {
|
|
834
|
+
return { clean: false, entries: [`git status failed: ${messageOf(err)}`] };
|
|
835
|
+
}
|
|
836
|
+
const entries = output.length === 0 ? [] : output.split('\n');
|
|
837
|
+
return { clean: entries.length === 0, entries };
|
|
838
|
+
}
|
|
839
|
+
/**
|
|
840
|
+
* Verify a finished recycle against reality (#453 AC2). Separate from
|
|
841
|
+
* `returnWorktree` so the rules are readable and testable on their own.
|
|
842
|
+
*/
|
|
843
|
+
function verifyRecycled(poolDir, newId, newAbsPath, newTempBranch) {
|
|
844
|
+
const committed = readState(poolDir).worktrees.find((w) => w.id === newId);
|
|
845
|
+
const cleanliness = inspectWorktreeCleanliness(newAbsPath);
|
|
846
|
+
const result = {
|
|
847
|
+
entry_status: committed?.status ?? 'missing',
|
|
848
|
+
directory_clean: cleanliness.clean,
|
|
849
|
+
dirty_entries: cleanliness.entries,
|
|
850
|
+
checked_out_branch: currentBranchOf(newAbsPath),
|
|
851
|
+
expected_branch: newTempBranch,
|
|
852
|
+
};
|
|
853
|
+
const problems = [];
|
|
854
|
+
if (result.entry_status !== 'warm') {
|
|
855
|
+
problems.push(`entry status is '${result.entry_status}', expected 'warm'`);
|
|
856
|
+
}
|
|
857
|
+
if (!result.directory_clean) {
|
|
858
|
+
problems.push(`directory ${newAbsPath} is not clean: ${result.dirty_entries.slice(0, 5).join(', ')}`);
|
|
859
|
+
}
|
|
860
|
+
if (result.checked_out_branch !== newTempBranch) {
|
|
861
|
+
problems.push(`checked-out branch is '${result.checked_out_branch ?? 'detached HEAD'}', ` +
|
|
862
|
+
`expected '${newTempBranch}'`);
|
|
863
|
+
}
|
|
864
|
+
if (problems.length > 0) {
|
|
865
|
+
throw new Error(`post-return self-check failed: ${problems.join('; ')}`);
|
|
866
|
+
}
|
|
867
|
+
return result;
|
|
868
|
+
}
|
|
869
|
+
/**
|
|
870
|
+
* Mark a pool entry `broken` after a failed recycle. Returns `null` on
|
|
871
|
+
* success, or the reason the mark itself failed — never throws, because the
|
|
872
|
+
* caller is already reporting a failure and must not lose it, but never
|
|
873
|
+
* silently either: an unmarked entry is the #453 bug.
|
|
874
|
+
*/
|
|
875
|
+
function markEntryBroken(poolDir, entryId, updates) {
|
|
876
|
+
try {
|
|
877
|
+
withLock(poolDir, (state) => ({
|
|
878
|
+
state: (0, pool_state_1.updateWorktree)(state, entryId, updates),
|
|
879
|
+
result: undefined,
|
|
880
|
+
}));
|
|
881
|
+
return null;
|
|
882
|
+
}
|
|
883
|
+
catch (err) {
|
|
884
|
+
return messageOf(err);
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
/**
|
|
888
|
+
* Recycle an assigned worktree back to `warm`.
|
|
889
|
+
*
|
|
890
|
+
* Transactional in outcome (#453): it either finishes and returns a verified
|
|
891
|
+
* `warm` entry, or it leaves the entry `broken` and throws a `ReturnFailure`
|
|
892
|
+
* naming the step. It never leaves the entry `assigned`, never reports success
|
|
893
|
+
* over an unverified pool, and — unlike the pre-#453 behaviour — never
|
|
894
|
+
* destroys the worktree on failure. A broken entry keeps its provenance
|
|
895
|
+
* (recorded id, actual on-disk path, a pool-owned temp branch) so `status`
|
|
896
|
+
* shows it and `gc` can remove it.
|
|
897
|
+
*/
|
|
641
898
|
function returnWorktree(worktreePath) {
|
|
642
899
|
const gitRoot = findGitRoot();
|
|
643
900
|
const cfg = readPoolFileConfig(gitRoot);
|
|
@@ -646,53 +903,73 @@ function returnWorktree(worktreePath) {
|
|
|
646
903
|
const newId = (0, types_1.generateId)();
|
|
647
904
|
const newTempBranch = `pool/spare-${newId}`;
|
|
648
905
|
const newAbsPath = toAbs(poolDir, newId);
|
|
649
|
-
// Look up entry and mark
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
}
|
|
655
|
-
return {
|
|
656
|
-
state: (0, pool_state_1.updateWorktree)(state, found.id, { status: 'recycling' }),
|
|
657
|
-
result: { ...found },
|
|
658
|
-
};
|
|
659
|
-
});
|
|
906
|
+
// Look up the entry and mark it `recycling`, atomically. This is the one
|
|
907
|
+
// failure with no entry to mark broken — there is no entry — but it still
|
|
908
|
+
// speaks the ReturnFailure contract so the CLI and library callers get the
|
|
909
|
+
// step name rather than a bare internal error.
|
|
910
|
+
let entry;
|
|
660
911
|
try {
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
git(['branch', '-D', entry.assigned_branch], { cwd: absPath });
|
|
912
|
+
entry = withLock(poolDir, (state) => {
|
|
913
|
+
const found = state.worktrees.find((w) => toAbs(poolDir, w.path) === absPath);
|
|
914
|
+
if (!found) {
|
|
915
|
+
throw new Error(`Worktree not found in pool state: ${worktreePath} (resolved to ${absPath}); ` +
|
|
916
|
+
`pool dir ${poolDir} tracks ${state.worktrees.length} entries`);
|
|
667
917
|
}
|
|
668
|
-
|
|
669
|
-
|
|
918
|
+
return {
|
|
919
|
+
state: (0, pool_state_1.updateWorktree)(state, found.id, { status: 'recycling' }),
|
|
920
|
+
result: { ...found },
|
|
921
|
+
};
|
|
922
|
+
});
|
|
923
|
+
}
|
|
924
|
+
catch (err) {
|
|
925
|
+
throw new ReturnFailure('lookup', absPath, null, err);
|
|
926
|
+
}
|
|
927
|
+
// Where the directory, its branch and its state row actually are right now.
|
|
928
|
+
// Updated as the steps progress so a failure after a partial rename — or
|
|
929
|
+
// after `commit-state` has already renamed the entry id — records the truth
|
|
930
|
+
// rather than the values we started from.
|
|
931
|
+
let livePath = absPath;
|
|
932
|
+
let liveTempBranch = entry.temp_branch;
|
|
933
|
+
let liveId = entry.id;
|
|
934
|
+
try {
|
|
935
|
+
step('fetch', () => git(['fetch', (0, pool_state_1.remoteForBaseRef)(cfg.base_ref)], { cwd: livePath }));
|
|
936
|
+
step('checkout-temp-branch', () => git(['checkout', '-b', newTempBranch, cfg.base_ref], { cwd: livePath }));
|
|
937
|
+
liveTempBranch = newTempBranch;
|
|
938
|
+
step('clean', () => git(['clean', '-fd'], { cwd: livePath }));
|
|
939
|
+
const assignedBranch = entry.assigned_branch;
|
|
940
|
+
if (assignedBranch) {
|
|
941
|
+
// Best-effort: the branch may already be gone. Anything else is worth
|
|
942
|
+
// saying out loud — a surviving branch collides on the next claim.
|
|
943
|
+
try {
|
|
944
|
+
git(['branch', '-D', assignedBranch], { cwd: livePath });
|
|
670
945
|
}
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
946
|
+
catch (err) {
|
|
947
|
+
const msg = messageOf(err);
|
|
948
|
+
if (!/not found|no branch named/i.test(msg)) {
|
|
949
|
+
console.error(`Warning: could not delete assigned branch '${assignedBranch}': ${msg}`);
|
|
950
|
+
}
|
|
675
951
|
}
|
|
676
|
-
fs.renameSync(absPath, newAbsPath);
|
|
677
|
-
git(['worktree', 'repair'], { cwd: gitRoot });
|
|
678
952
|
}
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
953
|
+
if (livePath !== newAbsPath) {
|
|
954
|
+
step('rename', () => {
|
|
955
|
+
if (fs.existsSync(newAbsPath)) {
|
|
956
|
+
throw new Error(`Recycle target already exists: ${newAbsPath}`);
|
|
957
|
+
}
|
|
958
|
+
fs.renameSync(livePath, newAbsPath);
|
|
685
959
|
});
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
960
|
+
livePath = newAbsPath;
|
|
961
|
+
// Repair the moved worktree by its new path — see the note in `claim`;
|
|
962
|
+
// a pathless repair leaves a dangling gitdir for `prune` to delete (#443).
|
|
963
|
+
step('repair', () => git(['worktree', 'repair', newAbsPath], { cwd: gitRoot }));
|
|
690
964
|
}
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
965
|
+
const currentBase = step('read-base-commit', () => git(['rev-parse', cfg.base_ref], { cwd: gitRoot }));
|
|
966
|
+
step('warm-commands', () => {
|
|
967
|
+
if (lockfileChangedSince(gitRoot, entry.base_commit, newAbsPath, cfg)) {
|
|
968
|
+
runWarmCommands(newAbsPath, cfg);
|
|
969
|
+
}
|
|
970
|
+
});
|
|
971
|
+
step('commit-state', () => withLock(poolDir, (state) => ({
|
|
972
|
+
state: (0, pool_state_1.updateWorktree)(state, liveId, {
|
|
696
973
|
id: newId,
|
|
697
974
|
path: newId,
|
|
698
975
|
status: 'warm',
|
|
@@ -701,35 +978,45 @@ function returnWorktree(worktreePath) {
|
|
|
701
978
|
warmed_at: new Date().toISOString(),
|
|
702
979
|
assigned_to_issue: null,
|
|
703
980
|
assigned_branch: null,
|
|
981
|
+
broken_step: undefined,
|
|
982
|
+
broken_reason: undefined,
|
|
983
|
+
broken_branch: undefined,
|
|
704
984
|
}),
|
|
705
985
|
result: undefined,
|
|
706
|
-
}));
|
|
986
|
+
})));
|
|
987
|
+
// `commit-state` renamed the row. Anything that fails from here on must
|
|
988
|
+
// mark THIS id, or the entry silently stays `warm` and claimable (#453).
|
|
989
|
+
liveId = newId;
|
|
990
|
+
// AC2: verify against reality before claiming success. A pool that only
|
|
991
|
+
// *says* it is warm is what #453 is about.
|
|
992
|
+
return {
|
|
993
|
+
id: newId,
|
|
994
|
+
path: newAbsPath,
|
|
995
|
+
verification: step('verify', () => verifyRecycled(poolDir, newId, newAbsPath, newTempBranch)),
|
|
996
|
+
};
|
|
707
997
|
}
|
|
708
998
|
catch (err) {
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
}));
|
|
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}`);
|
|
999
|
+
const failedStep = err instanceof StepError ? err.step : 'unknown';
|
|
1000
|
+
const cause = err instanceof StepError ? (err.cause ?? err) : err;
|
|
1001
|
+
const reason = sanitizeReason(messageOf(cause));
|
|
1002
|
+
// Mark broken instead of destroying (#453). The directory stays on disk so
|
|
1003
|
+
// the failure can be inspected; the entry points at where the directory
|
|
1004
|
+
// actually is so `classifyPoolDirEntry` still calls it ours and `gc` can
|
|
1005
|
+
// clear it.
|
|
1006
|
+
const onDiskPath = fs.existsSync(livePath) ? livePath : absPath;
|
|
1007
|
+
const observedBranch = currentBranchOf(onDiskPath);
|
|
1008
|
+
const markError = markEntryBroken(poolDir, liveId, {
|
|
1009
|
+
status: 'broken',
|
|
1010
|
+
path: relativePoolPath(poolDir, onDiskPath, entry.path),
|
|
1011
|
+
// NEVER widen provenance from what happens to be checked out: recording
|
|
1012
|
+
// a developer's branch here would make `classifyPoolDirEntry`'s ownership
|
|
1013
|
+
// test tautological and hand `gc` a directory it must refuse (#438).
|
|
1014
|
+
temp_branch: (0, pool_state_1.isPoolTempBranch)(observedBranch) ? observedBranch : liveTempBranch,
|
|
1015
|
+
broken_step: failedStep,
|
|
1016
|
+
broken_reason: reason,
|
|
1017
|
+
broken_branch: observedBranch,
|
|
1018
|
+
});
|
|
1019
|
+
throw new ReturnFailure(failedStep, onDiskPath, liveId, cause, markError);
|
|
733
1020
|
}
|
|
734
1021
|
}
|
|
735
1022
|
function refresh() {
|
|
@@ -798,6 +1085,7 @@ async function gc(opts = {}) {
|
|
|
798
1085
|
const errors = [];
|
|
799
1086
|
const staleIds = [];
|
|
800
1087
|
const orphanIds = [];
|
|
1088
|
+
const brokenIds = [];
|
|
801
1089
|
let removed = 0;
|
|
802
1090
|
const state = readState(poolDir);
|
|
803
1091
|
const ctx = buildOwnershipContext(gitRoot, poolDir, state);
|
|
@@ -806,8 +1094,32 @@ async function gc(opts = {}) {
|
|
|
806
1094
|
const ownedNames = new Set(onDisk.filter((e) => e.owned).map((e) => e.name));
|
|
807
1095
|
const candidates = [];
|
|
808
1096
|
const claimed = new Set();
|
|
1097
|
+
// Entries a `return` left broken (#453). Collected immediately rather than
|
|
1098
|
+
// after `stale_after_hours`: they are unusable from the moment they are
|
|
1099
|
+
// marked, they occupy `max_pool_size` capacity until removed, and the CLI
|
|
1100
|
+
// tells the operator to clear them with `gc`. Still routed through
|
|
1101
|
+
// `classifyPath`, the removal plan, and the --yes/TTY confirmation.
|
|
1102
|
+
for (const wt of state.worktrees) {
|
|
1103
|
+
if (wt.status !== 'broken')
|
|
1104
|
+
continue;
|
|
1105
|
+
claimed.add(wt.id);
|
|
1106
|
+
const classification = classifyPath(ctx, toAbs(poolDir, wt.path));
|
|
1107
|
+
const why = `return failed at '${wt.broken_step ?? 'unknown'}': ` +
|
|
1108
|
+
`${(wt.broken_reason ?? 'unknown').split('\n')[0]}`;
|
|
1109
|
+
candidates.push({
|
|
1110
|
+
kind: classification.owned ? 'broken' : 'orphan-state',
|
|
1111
|
+
id: wt.id,
|
|
1112
|
+
path: classification.owned ? toAbs(poolDir, wt.path) : null,
|
|
1113
|
+
tempBranch: wt.temp_branch,
|
|
1114
|
+
reason: classification.owned
|
|
1115
|
+
? `${why}; ${classification.reason}`
|
|
1116
|
+
: `${why}; directory is ${classification.reason} — directory left on disk`,
|
|
1117
|
+
});
|
|
1118
|
+
}
|
|
809
1119
|
// Stale pool worktrees.
|
|
810
1120
|
for (const wt of (0, pool_state_1.findStaleWorktrees)(state)) {
|
|
1121
|
+
if (claimed.has(wt.id))
|
|
1122
|
+
continue;
|
|
811
1123
|
claimed.add(wt.id);
|
|
812
1124
|
const classification = classifyPath(ctx, toAbs(poolDir, wt.path));
|
|
813
1125
|
if (classification.owned) {
|
|
@@ -861,7 +1173,7 @@ async function gc(opts = {}) {
|
|
|
861
1173
|
});
|
|
862
1174
|
}
|
|
863
1175
|
describeGcPlan(candidates, foreign);
|
|
864
|
-
const base = { staleIds, orphanIds, foreign, candidates, errors };
|
|
1176
|
+
const base = { staleIds, orphanIds, brokenIds, foreign, candidates, errors };
|
|
865
1177
|
if (opts.dryRun) {
|
|
866
1178
|
console.error('\nDry run — nothing was removed.');
|
|
867
1179
|
return { ...base, removed: 0, dryRun: true, aborted: false };
|
|
@@ -894,6 +1206,8 @@ async function gc(opts = {}) {
|
|
|
894
1206
|
}
|
|
895
1207
|
if (c.kind === 'stale')
|
|
896
1208
|
staleIds.push(c.id);
|
|
1209
|
+
else if (c.kind === 'broken')
|
|
1210
|
+
brokenIds.push(c.id);
|
|
897
1211
|
else
|
|
898
1212
|
orphanIds.push(c.id);
|
|
899
1213
|
removed++;
|
|
@@ -909,10 +1223,12 @@ function status() {
|
|
|
909
1223
|
const gitRoot = findGitRoot();
|
|
910
1224
|
const poolDir = resolvePoolDirSync(gitRoot);
|
|
911
1225
|
const state = readState(poolDir);
|
|
1226
|
+
const { foreign, broken } = reportPoolDirEntries(gitRoot, poolDir, state);
|
|
912
1227
|
return {
|
|
913
1228
|
...(0, pool_state_1.getPoolStatus)(state),
|
|
914
1229
|
pool_dir: poolDir,
|
|
915
|
-
foreign
|
|
1230
|
+
foreign,
|
|
1231
|
+
broken,
|
|
916
1232
|
};
|
|
917
1233
|
}
|
|
918
1234
|
/**
|