@ai-dossier/worktree-pool 0.5.3 → 0.6.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 +96 -13
- package/dist/cli.js +84 -19
- 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 +3 -1
- package/dist/index.js.map +1 -1
- package/dist/pool-actions.d.ts +71 -3
- package/dist/pool-actions.d.ts.map +1 -1
- package/dist/pool-actions.js +307 -80
- package/dist/pool-actions.js.map +1 -1
- package/dist/pool-state.d.ts +8 -0
- package/dist/pool-state.d.ts.map +1 -1
- package/dist/pool-state.js +14 -2
- 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,6 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.ReturnFailure = exports.WARM_COMMAND_TIMEOUT_MS = void 0;
|
|
36
37
|
exports.readPoolFileConfig = readPoolFileConfig;
|
|
37
38
|
exports.resolvePoolDir = resolvePoolDir;
|
|
38
39
|
exports.findBrokenEntries = findBrokenEntries;
|
|
@@ -53,7 +54,8 @@ const project_env_1 = require("./project-env");
|
|
|
53
54
|
const types_1 = require("./types");
|
|
54
55
|
const LOCK_TIMEOUT_MS = 10_000;
|
|
55
56
|
const LOCK_RETRY_MS = 200;
|
|
56
|
-
|
|
57
|
+
/** Per-command budget for a warm-up install/build (#561: also the floor `packages/sched`'s batch-setup warm step should give its own exec). */
|
|
58
|
+
exports.WARM_COMMAND_TIMEOUT_MS = 300_000;
|
|
57
59
|
const POOL_CONFIG_FILE = '.worktree-pool.json';
|
|
58
60
|
// --- Git helpers ---
|
|
59
61
|
function findGitRoot() {
|
|
@@ -335,7 +337,7 @@ function runWarmCommands(worktreeAbsPath, cfg) {
|
|
|
335
337
|
(0, node_child_process_1.execFileSync)(bin, args, {
|
|
336
338
|
cwd: projectDir,
|
|
337
339
|
stdio: 'pipe',
|
|
338
|
-
timeout: WARM_COMMAND_TIMEOUT_MS,
|
|
340
|
+
timeout: exports.WARM_COMMAND_TIMEOUT_MS,
|
|
339
341
|
});
|
|
340
342
|
}
|
|
341
343
|
}
|
|
@@ -347,6 +349,53 @@ function lockfilePathForWorktree(worktreeAbsPath, cfg) {
|
|
|
347
349
|
const projectDir = (0, project_env_1.resolveProjectDir)(worktreeAbsPath, cfg.project_subdir);
|
|
348
350
|
return (0, project_env_1.lockfilePathFor)((0, project_env_1.detectProjectEnv)(projectDir), cfg.project_subdir);
|
|
349
351
|
}
|
|
352
|
+
/**
|
|
353
|
+
* Did the project lockfile change between `baseCommit` and the configured base
|
|
354
|
+
* ref? An unreadable diff counts as "changed" — re-warming is cheap, a stale
|
|
355
|
+
* `node_modules` is not.
|
|
356
|
+
*/
|
|
357
|
+
function lockfileChangedSince(gitRoot, baseCommit, worktreeAbsPath, cfg) {
|
|
358
|
+
try {
|
|
359
|
+
const diff = git(['diff', '--name-only', `${baseCommit}..${cfg.base_ref}`], { cwd: gitRoot });
|
|
360
|
+
return (0, project_env_1.lockfileChangedInDiff)(diff, lockfilePathForWorktree(worktreeAbsPath, cfg));
|
|
361
|
+
}
|
|
362
|
+
catch {
|
|
363
|
+
return true;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
/** Message of a thrown value, whatever it is. */
|
|
367
|
+
function messageOf(err) {
|
|
368
|
+
return err instanceof Error ? err.message : String(err);
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* Make git's error text safe to persist and re-print. `.pool-state.json` is a
|
|
372
|
+
* plain file that `status` echoes back, and git quotes remote URLs verbatim in
|
|
373
|
+
* its errors — including any `https://user:token@host` credentials — so an
|
|
374
|
+
* unfiltered message would write a secret to disk and replay terminal escape
|
|
375
|
+
* sequences from a remote on every later `status`.
|
|
376
|
+
*/
|
|
377
|
+
function sanitizeReason(text) {
|
|
378
|
+
return (text
|
|
379
|
+
.replace(/([a-z][a-z0-9+.-]*:\/\/)[^/\s@]+@/gi, '$1<redacted>@')
|
|
380
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: stripping terminal escapes is the point
|
|
381
|
+
.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, '')
|
|
382
|
+
.slice(0, 2000));
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* The value to record as an entry's `path`. State paths are contractually bare
|
|
386
|
+
* directory names directly inside the pool directory (`classifyPath` matches on
|
|
387
|
+
* `basename`), so anything that escapes — from a hand-edited or corrupted state
|
|
388
|
+
* file — keeps the previously recorded name rather than being written back.
|
|
389
|
+
*/
|
|
390
|
+
function relativePoolPath(poolDir, onDiskPath, fallback) {
|
|
391
|
+
const rel = path.relative(poolDir, onDiskPath);
|
|
392
|
+
const isBareSegment = rel.length > 0 &&
|
|
393
|
+
!path.isAbsolute(rel) &&
|
|
394
|
+
!rel.startsWith('..') &&
|
|
395
|
+
!rel.includes(path.sep) &&
|
|
396
|
+
rel !== '.';
|
|
397
|
+
return isBareSegment ? rel : fallback;
|
|
398
|
+
}
|
|
350
399
|
// --- Ownership / provenance ---
|
|
351
400
|
//
|
|
352
401
|
// The pool directory is routinely shared with worktrees a developer made by
|
|
@@ -664,17 +713,7 @@ function claim(issue, branch) {
|
|
|
664
713
|
// Check freshness
|
|
665
714
|
const currentBase = git(['rev-parse', cfg.base_ref], { cwd: gitRoot });
|
|
666
715
|
if (worktree.base_commit !== currentBase) {
|
|
667
|
-
const
|
|
668
|
-
let lockChanged = false;
|
|
669
|
-
try {
|
|
670
|
-
const diff = git(['diff', '--name-only', `${worktree.base_commit}..${cfg.base_ref}`], {
|
|
671
|
-
cwd: gitRoot,
|
|
672
|
-
});
|
|
673
|
-
lockChanged = (0, project_env_1.lockfileChangedInDiff)(diff, lockfilePath);
|
|
674
|
-
}
|
|
675
|
-
catch {
|
|
676
|
-
lockChanged = true;
|
|
677
|
-
}
|
|
716
|
+
const lockChanged = lockfileChangedSince(gitRoot, worktree.base_commit, absPath, cfg);
|
|
678
717
|
git(['fetch', (0, pool_state_1.remoteForBaseRef)(cfg.base_ref)], { cwd: absPath });
|
|
679
718
|
git(['reset', '--hard', cfg.base_ref], { cwd: absPath });
|
|
680
719
|
if (lockChanged) {
|
|
@@ -724,6 +763,139 @@ function claim(issue, branch) {
|
|
|
724
763
|
throw err;
|
|
725
764
|
}
|
|
726
765
|
}
|
|
766
|
+
/**
|
|
767
|
+
* A `return` that failed, tagged with the step that failed (#453 AC1). The
|
|
768
|
+
* pool entry has been left in `status: 'broken'` — not `assigned`, not `warm`
|
|
769
|
+
* — and the worktree directory is deliberately NOT destroyed, so the failure
|
|
770
|
+
* is inspectable and `gc` can clear it deliberately.
|
|
771
|
+
*
|
|
772
|
+
* `markError` is the one case where that promise could not be kept: the
|
|
773
|
+
* broken-marking write itself failed. Callers must report it rather than
|
|
774
|
+
* assume the entry is marked, or they reproduce the very bug #453 is about.
|
|
775
|
+
*/
|
|
776
|
+
class ReturnFailure extends Error {
|
|
777
|
+
step;
|
|
778
|
+
entryId;
|
|
779
|
+
worktreePath;
|
|
780
|
+
/** Why the entry could NOT be marked broken, or `null` when it was marked. */
|
|
781
|
+
markError;
|
|
782
|
+
constructor(step, worktreePath, entryId, cause, markError = null) {
|
|
783
|
+
super(`return failed at step '${step}': ${messageOf(cause)}`, { cause });
|
|
784
|
+
this.name = 'ReturnFailure';
|
|
785
|
+
this.step = step;
|
|
786
|
+
this.entryId = entryId;
|
|
787
|
+
this.worktreePath = worktreePath;
|
|
788
|
+
this.markError = markError;
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
exports.ReturnFailure = ReturnFailure;
|
|
792
|
+
/** Run `fn`, tagging any throw with the step it happened in. */
|
|
793
|
+
function step(name, fn) {
|
|
794
|
+
try {
|
|
795
|
+
return fn();
|
|
796
|
+
}
|
|
797
|
+
catch (err) {
|
|
798
|
+
// Kept so that if a step is ever nested inside another, the innermost
|
|
799
|
+
// attribution wins rather than being overwritten by its caller.
|
|
800
|
+
if (err instanceof StepError)
|
|
801
|
+
throw err;
|
|
802
|
+
throw new StepError(name, err);
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
/** Internal carrier so `step` can attribute a failure without unwinding twice. */
|
|
806
|
+
class StepError extends Error {
|
|
807
|
+
step;
|
|
808
|
+
constructor(stepName, cause) {
|
|
809
|
+
super(messageOf(cause), { cause });
|
|
810
|
+
this.name = 'StepError';
|
|
811
|
+
this.step = stepName;
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
/** Branch checked out at `absPath`, or `null` when it cannot be determined. */
|
|
815
|
+
function currentBranchOf(absPath) {
|
|
816
|
+
try {
|
|
817
|
+
const branch = git(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: absPath });
|
|
818
|
+
return branch === 'HEAD' ? null : branch;
|
|
819
|
+
}
|
|
820
|
+
catch {
|
|
821
|
+
return null;
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
/**
|
|
825
|
+
* Tracked-file cleanliness of `absPath`. A git failure is reported as an error
|
|
826
|
+
* rather than as "dirty" — telling an operator to look for uncommitted changes
|
|
827
|
+
* that do not exist is the wrong place to send them.
|
|
828
|
+
*/
|
|
829
|
+
function inspectWorktreeCleanliness(absPath) {
|
|
830
|
+
let output;
|
|
831
|
+
try {
|
|
832
|
+
output = git(['status', '--porcelain', '--untracked-files=no'], { cwd: absPath });
|
|
833
|
+
}
|
|
834
|
+
catch (err) {
|
|
835
|
+
return { clean: false, entries: [`git status failed: ${messageOf(err)}`] };
|
|
836
|
+
}
|
|
837
|
+
const entries = output.length === 0 ? [] : output.split('\n');
|
|
838
|
+
return { clean: entries.length === 0, entries };
|
|
839
|
+
}
|
|
840
|
+
/**
|
|
841
|
+
* Verify a finished recycle against reality (#453 AC2). Separate from
|
|
842
|
+
* `returnWorktree` so the rules are readable and testable on their own.
|
|
843
|
+
*/
|
|
844
|
+
function verifyRecycled(poolDir, newId, newAbsPath, newTempBranch) {
|
|
845
|
+
const committed = readState(poolDir).worktrees.find((w) => w.id === newId);
|
|
846
|
+
const cleanliness = inspectWorktreeCleanliness(newAbsPath);
|
|
847
|
+
const result = {
|
|
848
|
+
entry_status: committed?.status ?? 'missing',
|
|
849
|
+
directory_clean: cleanliness.clean,
|
|
850
|
+
dirty_entries: cleanliness.entries,
|
|
851
|
+
checked_out_branch: currentBranchOf(newAbsPath),
|
|
852
|
+
expected_branch: newTempBranch,
|
|
853
|
+
};
|
|
854
|
+
const problems = [];
|
|
855
|
+
if (result.entry_status !== 'warm') {
|
|
856
|
+
problems.push(`entry status is '${result.entry_status}', expected 'warm'`);
|
|
857
|
+
}
|
|
858
|
+
if (!result.directory_clean) {
|
|
859
|
+
problems.push(`directory ${newAbsPath} is not clean: ${result.dirty_entries.slice(0, 5).join(', ')}`);
|
|
860
|
+
}
|
|
861
|
+
if (result.checked_out_branch !== newTempBranch) {
|
|
862
|
+
problems.push(`checked-out branch is '${result.checked_out_branch ?? 'detached HEAD'}', ` +
|
|
863
|
+
`expected '${newTempBranch}'`);
|
|
864
|
+
}
|
|
865
|
+
if (problems.length > 0) {
|
|
866
|
+
throw new Error(`post-return self-check failed: ${problems.join('; ')}`);
|
|
867
|
+
}
|
|
868
|
+
return result;
|
|
869
|
+
}
|
|
870
|
+
/**
|
|
871
|
+
* Mark a pool entry `broken` after a failed recycle. Returns `null` on
|
|
872
|
+
* success, or the reason the mark itself failed — never throws, because the
|
|
873
|
+
* caller is already reporting a failure and must not lose it, but never
|
|
874
|
+
* silently either: an unmarked entry is the #453 bug.
|
|
875
|
+
*/
|
|
876
|
+
function markEntryBroken(poolDir, entryId, updates) {
|
|
877
|
+
try {
|
|
878
|
+
withLock(poolDir, (state) => ({
|
|
879
|
+
state: (0, pool_state_1.updateWorktree)(state, entryId, updates),
|
|
880
|
+
result: undefined,
|
|
881
|
+
}));
|
|
882
|
+
return null;
|
|
883
|
+
}
|
|
884
|
+
catch (err) {
|
|
885
|
+
return messageOf(err);
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
/**
|
|
889
|
+
* Recycle an assigned worktree back to `warm`.
|
|
890
|
+
*
|
|
891
|
+
* Transactional in outcome (#453): it either finishes and returns a verified
|
|
892
|
+
* `warm` entry, or it leaves the entry `broken` and throws a `ReturnFailure`
|
|
893
|
+
* naming the step. It never leaves the entry `assigned`, never reports success
|
|
894
|
+
* over an unverified pool, and — unlike the pre-#453 behaviour — never
|
|
895
|
+
* destroys the worktree on failure. A broken entry keeps its provenance
|
|
896
|
+
* (recorded id, actual on-disk path, a pool-owned temp branch) so `status`
|
|
897
|
+
* shows it and `gc` can remove it.
|
|
898
|
+
*/
|
|
727
899
|
function returnWorktree(worktreePath) {
|
|
728
900
|
const gitRoot = findGitRoot();
|
|
729
901
|
const cfg = readPoolFileConfig(gitRoot);
|
|
@@ -732,55 +904,73 @@ function returnWorktree(worktreePath) {
|
|
|
732
904
|
const newId = (0, types_1.generateId)();
|
|
733
905
|
const newTempBranch = `pool/spare-${newId}`;
|
|
734
906
|
const newAbsPath = toAbs(poolDir, newId);
|
|
735
|
-
// Look up entry and mark
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
907
|
+
// Look up the entry and mark it `recycling`, atomically. This is the one
|
|
908
|
+
// failure with no entry to mark broken — there is no entry — but it still
|
|
909
|
+
// speaks the ReturnFailure contract so the CLI and library callers get the
|
|
910
|
+
// step name rather than a bare internal error.
|
|
911
|
+
let entry;
|
|
912
|
+
try {
|
|
913
|
+
entry = withLock(poolDir, (state) => {
|
|
914
|
+
const found = state.worktrees.find((w) => toAbs(poolDir, w.path) === absPath);
|
|
915
|
+
if (!found) {
|
|
916
|
+
throw new Error(`Worktree not found in pool state: ${worktreePath} (resolved to ${absPath}); ` +
|
|
917
|
+
`pool dir ${poolDir} tracks ${state.worktrees.length} entries`);
|
|
918
|
+
}
|
|
919
|
+
return {
|
|
920
|
+
state: (0, pool_state_1.updateWorktree)(state, found.id, { status: 'recycling' }),
|
|
921
|
+
result: { ...found },
|
|
922
|
+
};
|
|
923
|
+
});
|
|
924
|
+
}
|
|
925
|
+
catch (err) {
|
|
926
|
+
throw new ReturnFailure('lookup', absPath, null, err);
|
|
927
|
+
}
|
|
928
|
+
// Where the directory, its branch and its state row actually are right now.
|
|
929
|
+
// Updated as the steps progress so a failure after a partial rename — or
|
|
930
|
+
// after `commit-state` has already renamed the entry id — records the truth
|
|
931
|
+
// rather than the values we started from.
|
|
932
|
+
let livePath = absPath;
|
|
933
|
+
let liveTempBranch = entry.temp_branch;
|
|
934
|
+
let liveId = entry.id;
|
|
746
935
|
try {
|
|
747
|
-
git(['fetch', (0, pool_state_1.remoteForBaseRef)(cfg.base_ref)], { cwd:
|
|
748
|
-
git(['checkout', '-b', newTempBranch, cfg.base_ref], { cwd:
|
|
749
|
-
|
|
750
|
-
|
|
936
|
+
step('fetch', () => git(['fetch', (0, pool_state_1.remoteForBaseRef)(cfg.base_ref)], { cwd: livePath }));
|
|
937
|
+
step('checkout-temp-branch', () => git(['checkout', '-b', newTempBranch, cfg.base_ref], { cwd: livePath }));
|
|
938
|
+
liveTempBranch = newTempBranch;
|
|
939
|
+
step('clean', () => git(['clean', '-fd'], { cwd: livePath }));
|
|
940
|
+
const assignedBranch = entry.assigned_branch;
|
|
941
|
+
if (assignedBranch) {
|
|
942
|
+
// Best-effort: the branch may already be gone. Anything else is worth
|
|
943
|
+
// saying out loud — a surviving branch collides on the next claim.
|
|
751
944
|
try {
|
|
752
|
-
git(['branch', '-D',
|
|
945
|
+
git(['branch', '-D', assignedBranch], { cwd: livePath });
|
|
753
946
|
}
|
|
754
|
-
catch {
|
|
755
|
-
|
|
947
|
+
catch (err) {
|
|
948
|
+
const msg = messageOf(err);
|
|
949
|
+
if (!/not found|no branch named/i.test(msg)) {
|
|
950
|
+
console.error(`Warning: could not delete assigned branch '${assignedBranch}': ${msg}`);
|
|
951
|
+
}
|
|
756
952
|
}
|
|
757
953
|
}
|
|
758
|
-
if (
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
954
|
+
if (livePath !== newAbsPath) {
|
|
955
|
+
step('rename', () => {
|
|
956
|
+
if (fs.existsSync(newAbsPath)) {
|
|
957
|
+
throw new Error(`Recycle target already exists: ${newAbsPath}`);
|
|
958
|
+
}
|
|
959
|
+
fs.renameSync(livePath, newAbsPath);
|
|
960
|
+
});
|
|
961
|
+
livePath = newAbsPath;
|
|
763
962
|
// Repair the moved worktree by its new path — see the note in `claim`;
|
|
764
963
|
// a pathless repair leaves a dangling gitdir for `prune` to delete (#443).
|
|
765
|
-
git(['worktree', 'repair', newAbsPath], { cwd: gitRoot });
|
|
766
|
-
}
|
|
767
|
-
const currentBase = git(['rev-parse', cfg.base_ref], { cwd: gitRoot });
|
|
768
|
-
const lockfilePath = lockfilePathForWorktree(newAbsPath, cfg);
|
|
769
|
-
let lockChanged = false;
|
|
770
|
-
try {
|
|
771
|
-
const diff = git(['diff', '--name-only', `${entry.base_commit}..${cfg.base_ref}`], {
|
|
772
|
-
cwd: gitRoot,
|
|
773
|
-
});
|
|
774
|
-
lockChanged = (0, project_env_1.lockfileChangedInDiff)(diff, lockfilePath);
|
|
964
|
+
step('repair', () => git(['worktree', 'repair', newAbsPath], { cwd: gitRoot }));
|
|
775
965
|
}
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
}
|
|
782
|
-
withLock(poolDir, (state) => ({
|
|
783
|
-
state: (0, pool_state_1.updateWorktree)(state,
|
|
966
|
+
const currentBase = step('read-base-commit', () => git(['rev-parse', cfg.base_ref], { cwd: gitRoot }));
|
|
967
|
+
step('warm-commands', () => {
|
|
968
|
+
if (lockfileChangedSince(gitRoot, entry.base_commit, newAbsPath, cfg)) {
|
|
969
|
+
runWarmCommands(newAbsPath, cfg);
|
|
970
|
+
}
|
|
971
|
+
});
|
|
972
|
+
step('commit-state', () => withLock(poolDir, (state) => ({
|
|
973
|
+
state: (0, pool_state_1.updateWorktree)(state, liveId, {
|
|
784
974
|
id: newId,
|
|
785
975
|
path: newId,
|
|
786
976
|
status: 'warm',
|
|
@@ -789,35 +979,45 @@ function returnWorktree(worktreePath) {
|
|
|
789
979
|
warmed_at: new Date().toISOString(),
|
|
790
980
|
assigned_to_issue: null,
|
|
791
981
|
assigned_branch: null,
|
|
982
|
+
broken_step: undefined,
|
|
983
|
+
broken_reason: undefined,
|
|
984
|
+
broken_branch: undefined,
|
|
792
985
|
}),
|
|
793
986
|
result: undefined,
|
|
794
|
-
}));
|
|
987
|
+
})));
|
|
988
|
+
// `commit-state` renamed the row. Anything that fails from here on must
|
|
989
|
+
// mark THIS id, or the entry silently stays `warm` and claimable (#453).
|
|
990
|
+
liveId = newId;
|
|
991
|
+
// AC2: verify against reality before claiming success. A pool that only
|
|
992
|
+
// *says* it is warm is what #453 is about.
|
|
993
|
+
return {
|
|
994
|
+
id: newId,
|
|
995
|
+
path: newAbsPath,
|
|
996
|
+
verification: step('verify', () => verifyRecycled(poolDir, newId, newAbsPath, newTempBranch)),
|
|
997
|
+
};
|
|
795
998
|
}
|
|
796
999
|
catch (err) {
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
}));
|
|
819
|
-
const suffix = skipped.length > 0 ? ` Left on disk: ${skipped.join('; ')}` : '';
|
|
820
|
-
throw new Error(`Recycle failed (destroyed worktree): ${err instanceof Error ? err.message : String(err)}.${suffix}`);
|
|
1000
|
+
const failedStep = err instanceof StepError ? err.step : 'unknown';
|
|
1001
|
+
const cause = err instanceof StepError ? (err.cause ?? err) : err;
|
|
1002
|
+
const reason = sanitizeReason(messageOf(cause));
|
|
1003
|
+
// Mark broken instead of destroying (#453). The directory stays on disk so
|
|
1004
|
+
// the failure can be inspected; the entry points at where the directory
|
|
1005
|
+
// actually is so `classifyPoolDirEntry` still calls it ours and `gc` can
|
|
1006
|
+
// clear it.
|
|
1007
|
+
const onDiskPath = fs.existsSync(livePath) ? livePath : absPath;
|
|
1008
|
+
const observedBranch = currentBranchOf(onDiskPath);
|
|
1009
|
+
const markError = markEntryBroken(poolDir, liveId, {
|
|
1010
|
+
status: 'broken',
|
|
1011
|
+
path: relativePoolPath(poolDir, onDiskPath, entry.path),
|
|
1012
|
+
// NEVER widen provenance from what happens to be checked out: recording
|
|
1013
|
+
// a developer's branch here would make `classifyPoolDirEntry`'s ownership
|
|
1014
|
+
// test tautological and hand `gc` a directory it must refuse (#438).
|
|
1015
|
+
temp_branch: (0, pool_state_1.isPoolTempBranch)(observedBranch) ? observedBranch : liveTempBranch,
|
|
1016
|
+
broken_step: failedStep,
|
|
1017
|
+
broken_reason: reason,
|
|
1018
|
+
broken_branch: observedBranch,
|
|
1019
|
+
});
|
|
1020
|
+
throw new ReturnFailure(failedStep, onDiskPath, liveId, cause, markError);
|
|
821
1021
|
}
|
|
822
1022
|
}
|
|
823
1023
|
function refresh() {
|
|
@@ -886,6 +1086,7 @@ async function gc(opts = {}) {
|
|
|
886
1086
|
const errors = [];
|
|
887
1087
|
const staleIds = [];
|
|
888
1088
|
const orphanIds = [];
|
|
1089
|
+
const brokenIds = [];
|
|
889
1090
|
let removed = 0;
|
|
890
1091
|
const state = readState(poolDir);
|
|
891
1092
|
const ctx = buildOwnershipContext(gitRoot, poolDir, state);
|
|
@@ -894,8 +1095,32 @@ async function gc(opts = {}) {
|
|
|
894
1095
|
const ownedNames = new Set(onDisk.filter((e) => e.owned).map((e) => e.name));
|
|
895
1096
|
const candidates = [];
|
|
896
1097
|
const claimed = new Set();
|
|
1098
|
+
// Entries a `return` left broken (#453). Collected immediately rather than
|
|
1099
|
+
// after `stale_after_hours`: they are unusable from the moment they are
|
|
1100
|
+
// marked, they occupy `max_pool_size` capacity until removed, and the CLI
|
|
1101
|
+
// tells the operator to clear them with `gc`. Still routed through
|
|
1102
|
+
// `classifyPath`, the removal plan, and the --yes/TTY confirmation.
|
|
1103
|
+
for (const wt of state.worktrees) {
|
|
1104
|
+
if (wt.status !== 'broken')
|
|
1105
|
+
continue;
|
|
1106
|
+
claimed.add(wt.id);
|
|
1107
|
+
const classification = classifyPath(ctx, toAbs(poolDir, wt.path));
|
|
1108
|
+
const why = `return failed at '${wt.broken_step ?? 'unknown'}': ` +
|
|
1109
|
+
`${(wt.broken_reason ?? 'unknown').split('\n')[0]}`;
|
|
1110
|
+
candidates.push({
|
|
1111
|
+
kind: classification.owned ? 'broken' : 'orphan-state',
|
|
1112
|
+
id: wt.id,
|
|
1113
|
+
path: classification.owned ? toAbs(poolDir, wt.path) : null,
|
|
1114
|
+
tempBranch: wt.temp_branch,
|
|
1115
|
+
reason: classification.owned
|
|
1116
|
+
? `${why}; ${classification.reason}`
|
|
1117
|
+
: `${why}; directory is ${classification.reason} — directory left on disk`,
|
|
1118
|
+
});
|
|
1119
|
+
}
|
|
897
1120
|
// Stale pool worktrees.
|
|
898
1121
|
for (const wt of (0, pool_state_1.findStaleWorktrees)(state)) {
|
|
1122
|
+
if (claimed.has(wt.id))
|
|
1123
|
+
continue;
|
|
899
1124
|
claimed.add(wt.id);
|
|
900
1125
|
const classification = classifyPath(ctx, toAbs(poolDir, wt.path));
|
|
901
1126
|
if (classification.owned) {
|
|
@@ -949,7 +1174,7 @@ async function gc(opts = {}) {
|
|
|
949
1174
|
});
|
|
950
1175
|
}
|
|
951
1176
|
describeGcPlan(candidates, foreign);
|
|
952
|
-
const base = { staleIds, orphanIds, foreign, candidates, errors };
|
|
1177
|
+
const base = { staleIds, orphanIds, brokenIds, foreign, candidates, errors };
|
|
953
1178
|
if (opts.dryRun) {
|
|
954
1179
|
console.error('\nDry run — nothing was removed.');
|
|
955
1180
|
return { ...base, removed: 0, dryRun: true, aborted: false };
|
|
@@ -982,6 +1207,8 @@ async function gc(opts = {}) {
|
|
|
982
1207
|
}
|
|
983
1208
|
if (c.kind === 'stale')
|
|
984
1209
|
staleIds.push(c.id);
|
|
1210
|
+
else if (c.kind === 'broken')
|
|
1211
|
+
brokenIds.push(c.id);
|
|
985
1212
|
else
|
|
986
1213
|
orphanIds.push(c.id);
|
|
987
1214
|
removed++;
|