mandrel 2.66.0 → 2.67.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/.agents/agents/acceptance-critic.md +2 -2
- package/.agents/docs/agentrc-reference.json +2 -1
- package/.agents/docs/configuration.md +2 -1
- package/.agents/docs/workflows.md +4 -2
- package/.agents/instructions.md +2 -1
- package/.agents/rules/git-conventions-reference.md +5 -5
- package/.agents/rules/git-conventions.md +1 -1
- package/.agents/schemas/agentrc.schema.json +6 -1
- package/.agents/scripts/boot-sweep.js +97 -9
- package/.agents/scripts/{git-cleanup.js → clean-git.js} +2 -2
- package/.agents/scripts/clean-temp.js +54 -0
- package/.agents/scripts/clean-worktrees.js +593 -0
- package/.agents/scripts/drain-pending-cleanup.js +5 -4
- package/.agents/scripts/lib/clean-temp.js +440 -0
- package/.agents/scripts/lib/config-settings-schema-delivery.js +11 -2
- package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
- package/.agents/scripts/lib/observability/source-classifier.js +3 -1
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/cli.js +1 -1
- package/.agents/scripts/lib/orchestration/plan-runner/worktree-sweep.js +149 -97
- package/.agents/scripts/lib/single-story-sweep.js +2 -2
- package/.agents/scripts/lib/temp-removal.js +110 -0
- package/.agents/scripts/lib/temp-retention.js +122 -73
- package/.agents/scripts/lib/worktree/canonical-path.js +34 -0
- package/.agents/scripts/lib/worktree/lifecycle/reap.js +15 -4
- package/.agents/scripts/single-story-init.js +120 -17
- package/.agents/workflows/{git-cleanup.md → clean-git.md} +10 -10
- package/.agents/workflows/clean-temp.md +67 -0
- package/.agents/workflows/clean-worktrees.md +63 -0
- package/.agents/workflows/git-deliver.md +1 -1
- package/.agents/workflows/helpers/acceptance-self-eval.md +3 -2
- package/.agents/workflows/helpers/deliver-story-reference.md +2 -2
- package/docs/CHANGELOG.md +13 -0
- package/package.json +1 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Allowlisted auto-purge of spent temp artifacts: only a declared class's
|
|
3
|
-
* entries are candidates; the rest are reported, never touched
|
|
3
|
+
* entries are candidates; the rest are reported, never touched by the
|
|
4
|
+
* auto-purge (only `/clean-temp`'s operator-confirmed path). Never throws
|
|
4
5
|
* — a failed purge must not fail a land, boot, or persist.
|
|
5
6
|
*/
|
|
6
7
|
|
|
@@ -13,6 +14,14 @@ import {
|
|
|
13
14
|
tempRootFrom,
|
|
14
15
|
} from './config/temp-paths.js';
|
|
15
16
|
import { Logger } from './Logger.js';
|
|
17
|
+
import {
|
|
18
|
+
KEEP_BASENAMES,
|
|
19
|
+
removeSparingKept,
|
|
20
|
+
safeReaddir,
|
|
21
|
+
sizeOf,
|
|
22
|
+
} from './temp-removal.js';
|
|
23
|
+
|
|
24
|
+
export { KEEP_BASENAMES };
|
|
16
25
|
|
|
17
26
|
/**
|
|
18
27
|
* Defaults for `delivery.tempRetention`; purge is on unless turned off.
|
|
@@ -26,6 +35,7 @@ export const TEMP_RETENTION_DEFAULTS = Object.freeze({
|
|
|
26
35
|
validationEvidence: true,
|
|
27
36
|
auditResults: true,
|
|
28
37
|
planDirs: true,
|
|
38
|
+
scratch: true,
|
|
29
39
|
}),
|
|
30
40
|
});
|
|
31
41
|
|
|
@@ -33,12 +43,6 @@ export const PURGE_CLASS_NAMES = Object.freeze(
|
|
|
33
43
|
Object.keys(TEMP_RETENTION_DEFAULTS.classes),
|
|
34
44
|
);
|
|
35
45
|
|
|
36
|
-
/**
|
|
37
|
-
* Never deleted, re-checked at the deletion site: `signals.ndjson` is read
|
|
38
|
-
* long after merge and its loss is silent and unrecoverable.
|
|
39
|
-
*/
|
|
40
|
-
export const KEEP_BASENAMES = Object.freeze(['signals.ndjson']);
|
|
41
|
-
|
|
42
46
|
/** Explicit allowlist: an untaught file in a Story dir is kept. */
|
|
43
47
|
const STORY_EVIDENCE_BASENAMES = Object.freeze([
|
|
44
48
|
'validation-evidence.json',
|
|
@@ -49,6 +53,9 @@ const STORY_EVIDENCE_BASENAMES = Object.freeze([
|
|
|
49
53
|
/** Framework-owned, never purged (`*.lock` files are also skipped). */
|
|
50
54
|
const RESERVED_TOP_LEVEL = Object.freeze(['qa', 'cache']);
|
|
51
55
|
|
|
56
|
+
/** Agent-authored scratch: `scratch/story-<id>/` or any other child. */
|
|
57
|
+
const SCRATCH_DIRNAME = 'scratch';
|
|
58
|
+
|
|
52
59
|
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
|
53
60
|
|
|
54
61
|
const STORY_DIR_PATTERN = /^story-(\d+)$/;
|
|
@@ -77,50 +84,6 @@ export function resolveTempRetention(config) {
|
|
|
77
84
|
};
|
|
78
85
|
}
|
|
79
86
|
|
|
80
|
-
/**
|
|
81
|
-
* `readdir` yielding `[]` for an absent or unreadable directory.
|
|
82
|
-
*
|
|
83
|
-
* @param {typeof fsPromises} fsp
|
|
84
|
-
* @param {string} dir
|
|
85
|
-
* @returns {Promise<import('node:fs').Dirent[]>}
|
|
86
|
-
*/
|
|
87
|
-
async function safeReaddir(fsp, dir) {
|
|
88
|
-
try {
|
|
89
|
-
return await fsp.readdir(dir, { withFileTypes: true });
|
|
90
|
-
} catch {
|
|
91
|
-
return [];
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
/**
|
|
96
|
-
* Recursive byte total; a vanished child is skipped.
|
|
97
|
-
*
|
|
98
|
-
* @param {typeof fsPromises} fsp
|
|
99
|
-
* @param {string} target
|
|
100
|
-
* @returns {Promise<number>}
|
|
101
|
-
*/
|
|
102
|
-
async function sizeOf(fsp, target) {
|
|
103
|
-
let total = 0;
|
|
104
|
-
const stack = [target];
|
|
105
|
-
while (stack.length > 0) {
|
|
106
|
-
const current = stack.pop();
|
|
107
|
-
let stats;
|
|
108
|
-
try {
|
|
109
|
-
stats = await fsp.stat(current);
|
|
110
|
-
} catch {
|
|
111
|
-
continue;
|
|
112
|
-
}
|
|
113
|
-
if (!stats.isDirectory()) {
|
|
114
|
-
total += stats.size;
|
|
115
|
-
continue;
|
|
116
|
-
}
|
|
117
|
-
for (const child of await safeReaddir(fsp, current)) {
|
|
118
|
-
stack.push(path.join(current, child.name));
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
return total;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
87
|
/**
|
|
125
88
|
* One classified entry. `mtimeMs` is the entry's own mtime, not the newest
|
|
126
89
|
* beneath it; widening it would change when an abandoned dir becomes eligible.
|
|
@@ -257,13 +220,44 @@ async function scanPlanDirs(tempRoot, fsp) {
|
|
|
257
220
|
return entries;
|
|
258
221
|
}
|
|
259
222
|
|
|
223
|
+
/**
|
|
224
|
+
* `<tempRoot>/scratch/*`: `story-<id>/` is Story-keyed, anything else is
|
|
225
|
+
* age-floored — the one place an agent's ad-hoc files are reapable.
|
|
226
|
+
*/
|
|
227
|
+
async function scanScratch(tempRoot, fsp) {
|
|
228
|
+
const dir = path.join(tempRoot, SCRATCH_DIRNAME);
|
|
229
|
+
const entries = [];
|
|
230
|
+
for (const dirent of await safeReaddir(fsp, dir)) {
|
|
231
|
+
const match = dirent.isDirectory()
|
|
232
|
+
? STORY_DIR_PATTERN.exec(dirent.name)
|
|
233
|
+
: null;
|
|
234
|
+
const entry = await makeEntry(
|
|
235
|
+
fsp,
|
|
236
|
+
path.join(dir, dirent.name),
|
|
237
|
+
'scratch',
|
|
238
|
+
match ? Number(match[1]) : null,
|
|
239
|
+
);
|
|
240
|
+
if (entry) entries.push(entry);
|
|
241
|
+
}
|
|
242
|
+
return entries;
|
|
243
|
+
}
|
|
244
|
+
|
|
260
245
|
const SCANNERS = Object.freeze({
|
|
261
246
|
orchestrationLogs: scanOrchestrationLogs,
|
|
262
247
|
validationEvidence: scanValidationEvidence,
|
|
263
248
|
auditResults: scanAuditResults,
|
|
264
249
|
planDirs: scanPlanDirs,
|
|
250
|
+
scratch: scanScratch,
|
|
265
251
|
});
|
|
266
252
|
|
|
253
|
+
/** Fixed top-level dirs a class scanner walks. */
|
|
254
|
+
const CLASS_OWNED_DIRNAMES = Object.freeze([
|
|
255
|
+
ORCHESTRATION_DIRNAME,
|
|
256
|
+
'standalone',
|
|
257
|
+
'audits',
|
|
258
|
+
SCRATCH_DIRNAME,
|
|
259
|
+
]);
|
|
260
|
+
|
|
267
261
|
/**
|
|
268
262
|
* Keep in lockstep with the scanners: an entry no class walks must surface
|
|
269
263
|
* as unrecognized.
|
|
@@ -273,29 +267,49 @@ const SCANNERS = Object.freeze({
|
|
|
273
267
|
*/
|
|
274
268
|
function isClassOwnedTopLevel(name) {
|
|
275
269
|
return (
|
|
276
|
-
name
|
|
277
|
-
name === 'standalone' ||
|
|
278
|
-
name === 'audits' ||
|
|
270
|
+
CLASS_OWNED_DIRNAMES.includes(name) ||
|
|
279
271
|
name.startsWith('plan-') ||
|
|
280
272
|
RUN_DIR_PATTERN.test(name)
|
|
281
273
|
);
|
|
282
274
|
}
|
|
283
275
|
|
|
284
276
|
/**
|
|
285
|
-
*
|
|
277
|
+
* Top-level names no path may ever delete: the reserved trees, lock files,
|
|
278
|
+
* and the never-purged basenames.
|
|
279
|
+
*
|
|
280
|
+
* @param {string} name
|
|
281
|
+
* @returns {boolean}
|
|
282
|
+
*/
|
|
283
|
+
export function isReservedTopLevel(name) {
|
|
284
|
+
return (
|
|
285
|
+
RESERVED_TOP_LEVEL.includes(name) ||
|
|
286
|
+
name.endsWith('.lock') ||
|
|
287
|
+
KEEP_BASENAMES.includes(name)
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Unclaimed, non-reserved top-level entries: reported with sizes and the
|
|
293
|
+
* entry's own mtime. The auto-purge never deletes one; only an operator-
|
|
294
|
+
* confirmed `purgeUnrecognizedEntries` call does.
|
|
286
295
|
*
|
|
287
296
|
* @param {string} tempRoot
|
|
288
297
|
* @param {typeof fsPromises} fsp
|
|
289
|
-
* @returns {Promise<Array<{ path: string, bytes: number }>>}
|
|
298
|
+
* @returns {Promise<Array<{ path: string, bytes: number, mtimeMs: number }>>}
|
|
290
299
|
*/
|
|
291
300
|
async function collectUnrecognized(tempRoot, fsp) {
|
|
292
301
|
const found = [];
|
|
293
302
|
for (const dirent of await safeReaddir(fsp, tempRoot)) {
|
|
294
303
|
const { name } = dirent;
|
|
295
|
-
if (isClassOwnedTopLevel(name)) continue;
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
304
|
+
if (isClassOwnedTopLevel(name) || isReservedTopLevel(name)) continue;
|
|
305
|
+
const entry = await makeEntry(fsp, path.join(tempRoot, name), null, null);
|
|
306
|
+
if (entry) {
|
|
307
|
+
found.push({
|
|
308
|
+
path: entry.path,
|
|
309
|
+
bytes: entry.bytes,
|
|
310
|
+
mtimeMs: entry.mtimeMs,
|
|
311
|
+
});
|
|
312
|
+
}
|
|
299
313
|
}
|
|
300
314
|
return found;
|
|
301
315
|
}
|
|
@@ -304,7 +318,7 @@ async function collectUnrecognized(tempRoot, fsp) {
|
|
|
304
318
|
* Classify a temp tree without deleting anything.
|
|
305
319
|
*
|
|
306
320
|
* @param {{ config?: object, tempRoot?: string, fsp?: typeof fsPromises }} [args]
|
|
307
|
-
* @returns {Promise<{ tempRoot: string, entries: object[], unrecognized: Array<{ path: string, bytes: number }> }>}
|
|
321
|
+
* @returns {Promise<{ tempRoot: string, entries: object[], unrecognized: Array<{ path: string, bytes: number, mtimeMs: number }> }>}
|
|
308
322
|
*/
|
|
309
323
|
export async function collectTempEntries({
|
|
310
324
|
config,
|
|
@@ -353,6 +367,7 @@ function isPurgeable(entry, ctx) {
|
|
|
353
367
|
* @param {typeof fsPromises} [args.fsp]
|
|
354
368
|
* @param {{ info: Function }} [args.logger]
|
|
355
369
|
* @param {string} [args.label]
|
|
370
|
+
* @param {boolean} [args.dryRun] Report what would go; delete nothing.
|
|
356
371
|
* @returns {Promise<object>} Result envelope; never throws.
|
|
357
372
|
*/
|
|
358
373
|
async function purgeTempArtifacts({
|
|
@@ -366,6 +381,7 @@ async function purgeTempArtifacts({
|
|
|
366
381
|
fsp = fsPromises,
|
|
367
382
|
logger = Logger,
|
|
368
383
|
label = 'temp-retention',
|
|
384
|
+
dryRun = false,
|
|
369
385
|
} = {}) {
|
|
370
386
|
const policy = resolveTempRetention(config);
|
|
371
387
|
const base = {
|
|
@@ -402,22 +418,55 @@ async function purgeTempArtifacts({
|
|
|
402
418
|
if (entry.keep) result.kept.push(entry.path);
|
|
403
419
|
continue;
|
|
404
420
|
}
|
|
405
|
-
|
|
406
|
-
await fsp.rm(entry.path, { recursive: true, force: true });
|
|
407
|
-
result.purged.push({ path: entry.path, bytes: entry.bytes });
|
|
408
|
-
result.bytesReclaimed += entry.bytes;
|
|
409
|
-
} catch (err) {
|
|
410
|
-
result.errors.push(`${entry.path}: ${String(err?.message ?? err)}`);
|
|
411
|
-
}
|
|
421
|
+
await purgeOne(fsp, entry, result, dryRun);
|
|
412
422
|
}
|
|
413
423
|
|
|
414
|
-
if (
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
424
|
+
if (!dryRun) reportPurge(logger, label, result);
|
|
425
|
+
return result;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* One summary line for a purge that deleted something.
|
|
430
|
+
*
|
|
431
|
+
* @param {{ info?: Function }|undefined} logger
|
|
432
|
+
* @param {string} label
|
|
433
|
+
* @param {object} result
|
|
434
|
+
*/
|
|
435
|
+
function reportPurge(logger, label, result) {
|
|
436
|
+
if (result.purged.length === 0) return;
|
|
437
|
+
logger?.info?.(
|
|
438
|
+
`[${label}] purged ${result.purged.length} spent temp artifact(s), ` +
|
|
439
|
+
`reclaimed ${formatBytes(result.bytesReclaimed)} under ${result.tempRoot}.`,
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Remove (or, on a dry run, only record) one purgeable entry into `result`.
|
|
445
|
+
*
|
|
446
|
+
* @param {typeof fsPromises} fsp
|
|
447
|
+
* @param {{ path: string, bytes: number }} entry
|
|
448
|
+
* @param {object} result Mutated in place.
|
|
449
|
+
* @param {boolean} dryRun
|
|
450
|
+
* @returns {Promise<void>}
|
|
451
|
+
*/
|
|
452
|
+
async function purgeOne(fsp, entry, result, dryRun) {
|
|
453
|
+
if (dryRun) {
|
|
454
|
+
result.purged.push({ path: entry.path, bytes: entry.bytes });
|
|
455
|
+
result.bytesReclaimed += entry.bytes;
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
try {
|
|
459
|
+
const { bytes, kept } = await removeSparingKept(
|
|
460
|
+
fsp,
|
|
461
|
+
entry.path,
|
|
462
|
+
entry.bytes,
|
|
418
463
|
);
|
|
464
|
+
result.kept.push(...kept);
|
|
465
|
+
result.purged.push({ path: entry.path, bytes });
|
|
466
|
+
result.bytesReclaimed += bytes;
|
|
467
|
+
} catch (err) {
|
|
468
|
+
result.errors.push(`${entry.path}: ${String(err?.message ?? err)}`);
|
|
419
469
|
}
|
|
420
|
-
return result;
|
|
421
470
|
}
|
|
422
471
|
|
|
423
472
|
/**
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* worktree/canonical-path.js — the on-disk identity of a path, for every
|
|
3
|
+
* worktree identity and containment comparison.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* `realpathSync.native` resolves symlinks (macOS `/var` ↔ `/private/var`) and
|
|
11
|
+
* expands Windows 8.3 short names (`RUNNER~1` ↔ `runneradmin`) — `git
|
|
12
|
+
* worktree list` reports the long form while `os.tmpdir()` / `process.cwd()`
|
|
13
|
+
* may carry the short one. A path that no longer exists canonicalises its
|
|
14
|
+
* nearest existing ancestor and re-appends the rest.
|
|
15
|
+
*
|
|
16
|
+
* @param {string} p
|
|
17
|
+
* @param {{ realpath?: (p: string) => string }} [deps]
|
|
18
|
+
* @returns {string}
|
|
19
|
+
*/
|
|
20
|
+
export function canonicalPath(p, { realpath = fs.realpathSync.native } = {}) {
|
|
21
|
+
const resolved = path.resolve(p);
|
|
22
|
+
const tail = [];
|
|
23
|
+
let current = resolved;
|
|
24
|
+
for (;;) {
|
|
25
|
+
try {
|
|
26
|
+
return path.join(realpath(current), ...tail);
|
|
27
|
+
} catch {
|
|
28
|
+
const parent = path.dirname(current);
|
|
29
|
+
if (parent === current) return resolved;
|
|
30
|
+
tail.unshift(path.basename(current));
|
|
31
|
+
current = parent;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import fs from 'node:fs';
|
|
8
8
|
import { rm as fsPromisesRm } from 'node:fs/promises';
|
|
9
9
|
import { fileURLToPath } from 'node:url';
|
|
10
|
+
import { canonicalPath } from '../canonical-path.js';
|
|
10
11
|
import { isInsideWorktree, samePath } from '../inspector.js';
|
|
11
12
|
import { sleepSync } from '../node-modules-strategy.js';
|
|
12
13
|
import { checkMergeReachability } from './merge-reachability.js';
|
|
@@ -441,15 +442,25 @@ function runningCodePaths() {
|
|
|
441
442
|
* The running-code path inside `wtPath`, or `null`. Reaping the tree the
|
|
442
443
|
* running script was loaded from kills the process later, at its first lazy
|
|
443
444
|
* read or dynamic `import()`; refusing only defers the tree to the next sweep.
|
|
445
|
+
* Shared by every reaper (close, the boot worktree sweep, `/clean-worktrees`).
|
|
446
|
+
* Both sides are canonicalised first: a short-name or symlinked spelling of
|
|
447
|
+
* the same tree must still be recognised.
|
|
444
448
|
*
|
|
445
|
-
* @param {
|
|
449
|
+
* @param {{ platform?: string }} ctx
|
|
446
450
|
* @param {string} wtPath
|
|
451
|
+
* @param {string[]} [extraPaths] Further paths to guard (e.g. `process.cwd()`).
|
|
447
452
|
* @returns {string|null}
|
|
448
453
|
*/
|
|
449
|
-
function findRunningCodeInside(ctx, wtPath) {
|
|
454
|
+
export function findRunningCodeInside(ctx, wtPath, extraPaths = []) {
|
|
455
|
+
const guarded = [...runningCodePaths(), ...extraPaths];
|
|
456
|
+
const target = canonicalPath(wtPath);
|
|
450
457
|
return (
|
|
451
|
-
|
|
452
|
-
|
|
458
|
+
guarded.find(
|
|
459
|
+
(p) =>
|
|
460
|
+
typeof p === 'string' &&
|
|
461
|
+
p !== '' &&
|
|
462
|
+
isInsideWorktree(canonicalPath(p), target, ctx.platform),
|
|
463
|
+
) ?? null
|
|
453
464
|
);
|
|
454
465
|
}
|
|
455
466
|
|
|
@@ -229,10 +229,60 @@ export function decideStoryBranchSeed({ localHas, remoteHas }) {
|
|
|
229
229
|
}
|
|
230
230
|
|
|
231
231
|
/**
|
|
232
|
-
*
|
|
233
|
-
*
|
|
234
|
-
*
|
|
235
|
-
*
|
|
232
|
+
* Remove closed-Story `.worktrees/story-<id>` trees through the boot sweep's
|
|
233
|
+
* own seam (`runWorktreeSweep`: same lock, same invariants). The Story being
|
|
234
|
+
* initialized is always kept, whatever its ticket state. Never throws: a
|
|
235
|
+
* failure lands in the returned outcome, which rides the init envelope.
|
|
236
|
+
*
|
|
237
|
+
* @returns {Promise<object>} `{ ok, reaped, skipped, reason?, error? }`.
|
|
238
|
+
*/
|
|
239
|
+
export async function reapClosedStoryWorktrees({
|
|
240
|
+
cwd,
|
|
241
|
+
storyBranch,
|
|
242
|
+
provider,
|
|
243
|
+
lockPath,
|
|
244
|
+
lockTimeoutMs,
|
|
245
|
+
worktreeSweepFn,
|
|
246
|
+
acquireLockFn,
|
|
247
|
+
}) {
|
|
248
|
+
const logger = {
|
|
249
|
+
info: (m) => progress('CLEANUP', m),
|
|
250
|
+
warn: (m) => progress('CLEANUP', `⚠️ ${m}`),
|
|
251
|
+
};
|
|
252
|
+
try {
|
|
253
|
+
const { runWorktreeSweep } = await import('./boot-sweep.js');
|
|
254
|
+
const outcome = await runWorktreeSweep({
|
|
255
|
+
root: cwd,
|
|
256
|
+
provider,
|
|
257
|
+
lockPath,
|
|
258
|
+
lockTimeoutMs,
|
|
259
|
+
...(worktreeSweepFn ? { sweepFn: worktreeSweepFn } : {}),
|
|
260
|
+
...(acquireLockFn ? { acquireLockFn } : {}),
|
|
261
|
+
logger,
|
|
262
|
+
logTag: '[worktree-sweep]',
|
|
263
|
+
keepPaths: [path.join(cwd, '.worktrees', storyBranch)],
|
|
264
|
+
});
|
|
265
|
+
if (outcome.reaped?.length > 0) {
|
|
266
|
+
progress(
|
|
267
|
+
'CLEANUP',
|
|
268
|
+
`🧹 removed ${outcome.reaped.length} closed-Story worktree(s).`,
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
return outcome;
|
|
272
|
+
} catch (err) {
|
|
273
|
+
const msg = err?.message ?? String(err);
|
|
274
|
+
logger.warn(`worktree sweep threw (init continues): ${msg}`);
|
|
275
|
+
return { ok: false, error: msg, reaped: [], skipped: [] };
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Reap merged `story-*` branches (excluding the current one), then the
|
|
281
|
+
* closed-Story worktrees. Never blocks init. Protected candidates (unpushed
|
|
282
|
+
* work, dirty worktree, open Story) are skipped; the lockfile is shared with
|
|
283
|
+
* `boot-sweep.js` via `resolveSweepLockPath` so concurrent reaps cannot race.
|
|
284
|
+
*
|
|
285
|
+
* @returns {Promise<{ worktreeSweep: object }>}
|
|
236
286
|
*/
|
|
237
287
|
export async function reapMergedStoryBranches({
|
|
238
288
|
cwd,
|
|
@@ -241,6 +291,8 @@ export async function reapMergedStoryBranches({
|
|
|
241
291
|
config,
|
|
242
292
|
provider,
|
|
243
293
|
injectedSweep,
|
|
294
|
+
worktreeSweepFn,
|
|
295
|
+
acquireLockFn,
|
|
244
296
|
}) {
|
|
245
297
|
const sweepFn =
|
|
246
298
|
injectedSweep ??
|
|
@@ -248,7 +300,37 @@ export async function reapMergedStoryBranches({
|
|
|
248
300
|
const tempRoot = config?.project?.paths?.tempRoot ?? 'temp';
|
|
249
301
|
const lockPath = resolveSweepLockPath({ cwd, tempRoot });
|
|
250
302
|
const lockTimeoutMs =
|
|
251
|
-
config
|
|
303
|
+
config?.delivery?.worktreeIsolation?.sweepLockMs ?? 60_000;
|
|
304
|
+
await reapMergedBranches({
|
|
305
|
+
cwd,
|
|
306
|
+
baseBranch,
|
|
307
|
+
storyBranch,
|
|
308
|
+
provider,
|
|
309
|
+
sweepFn,
|
|
310
|
+
lockPath,
|
|
311
|
+
lockTimeoutMs,
|
|
312
|
+
});
|
|
313
|
+
const worktreeSweep = await reapClosedStoryWorktrees({
|
|
314
|
+
cwd,
|
|
315
|
+
storyBranch,
|
|
316
|
+
provider,
|
|
317
|
+
lockPath,
|
|
318
|
+
lockTimeoutMs,
|
|
319
|
+
worktreeSweepFn,
|
|
320
|
+
acquireLockFn,
|
|
321
|
+
});
|
|
322
|
+
return { worktreeSweep };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
async function reapMergedBranches({
|
|
326
|
+
cwd,
|
|
327
|
+
baseBranch,
|
|
328
|
+
storyBranch,
|
|
329
|
+
provider,
|
|
330
|
+
sweepFn,
|
|
331
|
+
lockPath,
|
|
332
|
+
lockTimeoutMs,
|
|
333
|
+
}) {
|
|
252
334
|
try {
|
|
253
335
|
const sweep = await sweepFn({
|
|
254
336
|
cwd,
|
|
@@ -300,9 +382,12 @@ export async function reapMergedStoryBranches({
|
|
|
300
382
|
* @param {object} opts.config
|
|
301
383
|
* @param {object} opts.provider
|
|
302
384
|
* @param {Function|undefined} opts.injectedSweep
|
|
385
|
+
* @param {Function} [opts.worktreeSweepFn] Test override for the
|
|
386
|
+
* closed-Story worktree sweep.
|
|
303
387
|
* @param {Function} opts.progress
|
|
304
388
|
* @param {import('./lib/git/cached-fetch.js').FetchCache} [opts.fetchCache]
|
|
305
389
|
* Test override; production shares the module singleton.
|
|
390
|
+
* @returns {Promise<{ worktreeSweep: object }>}
|
|
306
391
|
*/
|
|
307
392
|
export async function materializeBaseBranch({
|
|
308
393
|
cwd,
|
|
@@ -311,6 +396,7 @@ export async function materializeBaseBranch({
|
|
|
311
396
|
config,
|
|
312
397
|
provider,
|
|
313
398
|
injectedSweep,
|
|
399
|
+
worktreeSweepFn,
|
|
314
400
|
progress,
|
|
315
401
|
fetchCache,
|
|
316
402
|
}) {
|
|
@@ -326,13 +412,14 @@ export async function materializeBaseBranch({
|
|
|
326
412
|
);
|
|
327
413
|
}
|
|
328
414
|
|
|
329
|
-
await reapMergedStoryBranches({
|
|
415
|
+
const { worktreeSweep } = await reapMergedStoryBranches({
|
|
330
416
|
cwd,
|
|
331
417
|
baseBranch,
|
|
332
418
|
storyBranch,
|
|
333
419
|
config,
|
|
334
420
|
provider,
|
|
335
421
|
injectedSweep,
|
|
422
|
+
worktreeSweepFn,
|
|
336
423
|
});
|
|
337
424
|
|
|
338
425
|
if (!branchExistsLocally(baseBranch, cwd)) {
|
|
@@ -342,10 +429,19 @@ export async function materializeBaseBranch({
|
|
|
342
429
|
`Failed to fetch base branch ${baseBranch}: ${r.stderr || '(no stderr)'}`,
|
|
343
430
|
);
|
|
344
431
|
}
|
|
345
|
-
return;
|
|
432
|
+
return { worktreeSweep };
|
|
346
433
|
}
|
|
347
434
|
|
|
348
|
-
|
|
435
|
+
fastForwardBase({ cwd, baseBranch, progress });
|
|
436
|
+
return { worktreeSweep };
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* `git fetch` leaves local base at the old tip until fast-forwarded.
|
|
441
|
+
*
|
|
442
|
+
* @param {{ cwd: string, baseBranch: string, progress: Function }} opts
|
|
443
|
+
*/
|
|
444
|
+
function fastForwardBase({ cwd, baseBranch, progress }) {
|
|
349
445
|
const ffPlan = planFastForward({ cwd, baseBranch });
|
|
350
446
|
const ff = executeFastForward({
|
|
351
447
|
cwd,
|
|
@@ -461,6 +557,7 @@ export async function runSingleStoryInit({
|
|
|
461
557
|
injectedProvider,
|
|
462
558
|
injectedConfig,
|
|
463
559
|
injectedSweep,
|
|
560
|
+
injectedWorktreeSweep,
|
|
464
561
|
injectedAcquireLease,
|
|
465
562
|
steal = false,
|
|
466
563
|
injectedVerifyRemote,
|
|
@@ -533,6 +630,7 @@ export async function runSingleStoryInit({
|
|
|
533
630
|
let workCwd = cwd;
|
|
534
631
|
let worktreeCreated = false;
|
|
535
632
|
let installStatus = { status: 'skipped', reason: 'dry-run' };
|
|
633
|
+
let worktreeSweep = null;
|
|
536
634
|
|
|
537
635
|
if (!dryRun) {
|
|
538
636
|
const acquire = injectedAcquireLease ?? acquireStoryLease;
|
|
@@ -563,15 +661,17 @@ export async function runSingleStoryInit({
|
|
|
563
661
|
await rollUpContainerEpic(provider, storyId, config);
|
|
564
662
|
|
|
565
663
|
try {
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
664
|
+
({ worktreeSweep } =
|
|
665
|
+
(await injectedMaterialize({
|
|
666
|
+
cwd,
|
|
667
|
+
baseBranch,
|
|
668
|
+
storyBranch,
|
|
669
|
+
config,
|
|
670
|
+
provider,
|
|
671
|
+
injectedSweep,
|
|
672
|
+
worktreeSweepFn: injectedWorktreeSweep,
|
|
673
|
+
progress,
|
|
674
|
+
})) ?? {});
|
|
575
675
|
injectedSeedBranch({ cwd, storyBranch, baseBranch, progress });
|
|
576
676
|
({ workCwd, worktreeCreated, installStatus } =
|
|
577
677
|
await injectedProvisionWorktree({
|
|
@@ -610,6 +710,9 @@ export async function runSingleStoryInit({
|
|
|
610
710
|
installStatus,
|
|
611
711
|
dependenciesInstalled,
|
|
612
712
|
installFailed: installStatus.status === 'failed',
|
|
713
|
+
// Closed-Story worktree sweep outcome; a failure degrades here, never
|
|
714
|
+
// into an init failure. `null` under --dry-run.
|
|
715
|
+
worktreeSweep: worktreeSweep ?? null,
|
|
613
716
|
dryRun,
|
|
614
717
|
remoteVerified: remote.remoteVerified,
|
|
615
718
|
remoteProbe: { remoteUrl: remote.remoteUrl, detail: remote.detail },
|
|
@@ -5,9 +5,9 @@ description: >-
|
|
|
5
5
|
`git stash` entries — each step gated by operator confirmation.
|
|
6
6
|
---
|
|
7
7
|
|
|
8
|
-
# /git
|
|
8
|
+
# /clean-git [--fast-forward-main] [--prune-remotes] [--branches] [--stashes] [--execute] [--remote] [--yes] [--include-content-merged] [--drop-stashes <ref>] [--exclude <pattern>] [--json]
|
|
9
9
|
|
|
10
|
-
`/git
|
|
10
|
+
`/clean-git` folds the four cleanup steps operators routinely run by hand
|
|
11
11
|
after a busy session into a single pipeline with per-step confirmation. It is a
|
|
12
12
|
**recovery tool**, not a routine chore: the delivering flows already reap their
|
|
13
13
|
own merged refs and fast-forward the base branch (see
|
|
@@ -22,13 +22,13 @@ Reach for it when the automated hygiene left an unusual state behind.
|
|
|
22
22
|
> skill citation.
|
|
23
23
|
|
|
24
24
|
The enumeration + reap logic lives in
|
|
25
|
-
[`git
|
|
25
|
+
[`clean-git.js`](../scripts/clean-git.js) — it computes the candidate list,
|
|
26
26
|
the skip taxonomy, the detection signals, and the JSON envelope, and prints them
|
|
27
27
|
itself. Without `--execute` the script is a **dry-run preview**; nothing is
|
|
28
28
|
mutated. When no phase flag is passed, **all four phases run** sequentially; a
|
|
29
29
|
phase flag narrows the run. A failure in one phase does not short-circuit the
|
|
30
30
|
others — each runs and reports independently. The script documents its own
|
|
31
|
-
flags: `node .agents/scripts/git
|
|
31
|
+
flags: `node .agents/scripts/clean-git.js --help`.
|
|
32
32
|
|
|
33
33
|
## Phases
|
|
34
34
|
|
|
@@ -65,24 +65,24 @@ merged-PR branch in scope unless `--exclude`d.
|
|
|
65
65
|
|
|
66
66
|
```bash
|
|
67
67
|
# Preview all four phases (no mutation).
|
|
68
|
-
node .agents/scripts/git
|
|
68
|
+
node .agents/scripts/clean-git.js
|
|
69
69
|
|
|
70
70
|
# Run everything non-interactively, including origin refs. Branches detected
|
|
71
71
|
# only by content-equivalence keep their origin ref — see the note below.
|
|
72
|
-
node .agents/scripts/git
|
|
72
|
+
node .agents/scripts/clean-git.js --execute --remote --yes
|
|
73
73
|
|
|
74
74
|
# Same, but also delete the origin refs of content-merged branches. Nobody is
|
|
75
75
|
# watching, so opting in is the whole confirmation this delete ever gets.
|
|
76
|
-
node .agents/scripts/git
|
|
76
|
+
node .agents/scripts/clean-git.js --execute --remote --yes \
|
|
77
77
|
--include-content-merged
|
|
78
78
|
|
|
79
79
|
# Only fast-forward main.
|
|
80
|
-
node .agents/scripts/git
|
|
80
|
+
node .agents/scripts/clean-git.js --fast-forward-main --execute
|
|
81
81
|
|
|
82
82
|
# Only sweep merged branches + their origin refs.
|
|
83
|
-
node .agents/scripts/git
|
|
83
|
+
node .agents/scripts/clean-git.js --branches --execute --remote
|
|
84
84
|
|
|
85
85
|
# Drop specific stashes under --yes.
|
|
86
|
-
node .agents/scripts/git
|
|
86
|
+
node .agents/scripts/clean-git.js --stashes --execute --yes \
|
|
87
87
|
--drop-stashes 'stash@{0}' --drop-stashes 'stash@{2}'
|
|
88
88
|
```
|