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
|
@@ -37,6 +37,8 @@ const FRAMEWORK_SCRIPT_BASENAMES = Object.freeze([
|
|
|
37
37
|
'check-test-temp-hygiene.js',
|
|
38
38
|
'check-windows-git-perf.js',
|
|
39
39
|
'ceremony-derive.js',
|
|
40
|
+
'clean-temp.js',
|
|
41
|
+
'clean-worktrees.js',
|
|
40
42
|
'cleanup-repo-test-temp.js',
|
|
41
43
|
'coverage-capture.js',
|
|
42
44
|
'deliver-light.js',
|
|
@@ -51,7 +53,7 @@ const FRAMEWORK_SCRIPT_BASENAMES = Object.freeze([
|
|
|
51
53
|
'generate-lens-checklists.js',
|
|
52
54
|
'generate-skills-index.js',
|
|
53
55
|
'generate-workflows-doc.js',
|
|
54
|
-
'git
|
|
56
|
+
'clean-git.js',
|
|
55
57
|
'lint-issue-body.js',
|
|
56
58
|
'mandrel-update-preflight.js',
|
|
57
59
|
'merge-baseline.js',
|
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* plan-runner/worktree-sweep.js —
|
|
2
|
+
* plan-runner/worktree-sweep.js — boot sweep that removes
|
|
3
3
|
* `.worktrees/story-<id>/` entries whose Story is closed or `agent::done`,
|
|
4
|
-
* after draining the pending-cleanup manifest.
|
|
4
|
+
* after draining the pending-cleanup manifest. Called by `runBootSweep`
|
|
5
|
+
* (`boot-sweep.js`) under the shared merged-branch sweep lock.
|
|
5
6
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
7
|
+
* Removal goes through `removeWorktreeWithRecovery` — the same seam close
|
|
8
|
+
* uses: Windows lock retries, an `fs.rm` fallback that discards residue a
|
|
9
|
+
* done Story's merged branch no longer needs, and the pending-cleanup
|
|
10
|
+
* hand-off when that exhausts. Branches are never deleted here; the
|
|
11
|
+
* merged-branch sweep owns them.
|
|
12
|
+
*
|
|
13
|
+
* Two trees are never touched: an open Story's, and the one the running
|
|
14
|
+
* process was loaded from or is working in (`findRunningCodeInside`).
|
|
9
15
|
*/
|
|
10
16
|
|
|
11
17
|
import path from 'node:path';
|
|
@@ -13,8 +19,13 @@ import * as defaultGit from '../../git-utils.js';
|
|
|
13
19
|
import { NOOP_LOGGER } from '../../Logger.js';
|
|
14
20
|
import { AGENT_LABELS } from '../../label-constants.js';
|
|
15
21
|
import { concurrentMap } from '../../util/concurrent-map.js';
|
|
16
|
-
import {
|
|
22
|
+
import { canonicalPath } from '../../worktree/canonical-path.js';
|
|
23
|
+
import { parseWorktreePorcelain, samePath } from '../../worktree/inspector.js';
|
|
17
24
|
import { forceDrainPendingCleanup } from '../../worktree/lifecycle/force-drain.js';
|
|
25
|
+
import {
|
|
26
|
+
findRunningCodeInside,
|
|
27
|
+
removeWorktreeWithRecovery,
|
|
28
|
+
} from '../../worktree/lifecycle/reap.js';
|
|
18
29
|
|
|
19
30
|
const TICKET_READ_CONCURRENCY = 8;
|
|
20
31
|
|
|
@@ -27,33 +38,32 @@ function isStoryDone(ticket) {
|
|
|
27
38
|
return labels.includes(DONE_LABEL);
|
|
28
39
|
}
|
|
29
40
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
41
|
+
/**
|
|
42
|
+
* The Story id of a `<worktreeRoot>/story-<id>` entry, else `null` — a
|
|
43
|
+
* `story-<id>` directory anywhere else (e.g. `.claude/worktrees/`) is not
|
|
44
|
+
* this sweep's to judge.
|
|
45
|
+
*
|
|
46
|
+
* @param {string} wtPath
|
|
47
|
+
* @param {string} worktreeRoot
|
|
48
|
+
* @param {string} platform
|
|
49
|
+
* @returns {number|null}
|
|
50
|
+
*/
|
|
51
|
+
function storyIdFromPath(wtPath, worktreeRoot, platform) {
|
|
52
|
+
const parent = path.dirname(canonicalPath(wtPath));
|
|
53
|
+
if (!samePath(parent, canonicalPath(worktreeRoot), platform)) return null;
|
|
54
|
+
return defaultGit.parseStoryBranch(path.basename(path.resolve(wtPath)));
|
|
34
55
|
}
|
|
35
56
|
|
|
36
57
|
/**
|
|
37
|
-
*
|
|
58
|
+
* Validate and normalise the options bag.
|
|
38
59
|
*
|
|
39
60
|
* @param {object} opts
|
|
40
|
-
* @
|
|
41
|
-
* @param {string} opts.repoRoot Absolute path to the main checkout.
|
|
42
|
-
* @param {object} [opts.git]
|
|
43
|
-
* @param {object} [opts.logger]
|
|
44
|
-
* @returns {Promise<{
|
|
45
|
-
* reaped: Array<{ storyId: number, path: string }>,
|
|
46
|
-
* skipped: Array<{ storyId: number|null, path: string, reason: string }>,
|
|
47
|
-
* }>}
|
|
61
|
+
* @returns {object}
|
|
48
62
|
*/
|
|
49
|
-
|
|
63
|
+
function resolveSweepOptions(opts) {
|
|
50
64
|
const ctx = opts.ctx;
|
|
51
65
|
const provider = opts.provider ?? ctx?.provider;
|
|
52
66
|
const repoRoot = opts.repoRoot ?? ctx?.cwd;
|
|
53
|
-
const git = opts.git ?? defaultGit;
|
|
54
|
-
const logger = opts.logger ?? ctx?.logger ?? NOOP_LOGGER;
|
|
55
|
-
const fsRm = opts.fsRm;
|
|
56
|
-
const worktreeRoot = opts.worktreeRoot;
|
|
57
67
|
if (!provider || typeof provider.getTicket !== 'function') {
|
|
58
68
|
throw new Error(
|
|
59
69
|
'sweepStaleStoryWorktrees: provider with getTicket(id) is required',
|
|
@@ -62,107 +72,149 @@ export async function sweepStaleStoryWorktrees(opts = {}) {
|
|
|
62
72
|
if (!repoRoot || typeof repoRoot !== 'string') {
|
|
63
73
|
throw new Error('sweepStaleStoryWorktrees: repoRoot is required');
|
|
64
74
|
}
|
|
75
|
+
const platform = opts.platform ?? process.platform;
|
|
76
|
+
return {
|
|
77
|
+
provider,
|
|
78
|
+
repoRoot,
|
|
79
|
+
platform,
|
|
80
|
+
git: opts.git ?? defaultGit,
|
|
81
|
+
logger: opts.logger ?? ctx?.logger ?? NOOP_LOGGER,
|
|
82
|
+
fsRm: opts.fsRm,
|
|
83
|
+
sleepFn: opts.sleepFn,
|
|
84
|
+
worktreeRoot: path.resolve(
|
|
85
|
+
opts.worktreeRoot ?? path.join(repoRoot, '.worktrees'),
|
|
86
|
+
),
|
|
87
|
+
guardPaths: [process.cwd(), ...(opts.runningPaths ?? [])],
|
|
88
|
+
};
|
|
89
|
+
}
|
|
65
90
|
|
|
66
|
-
|
|
67
|
-
|
|
91
|
+
/**
|
|
92
|
+
* Read every Story worktree's ticket; each mapper captures its own error so
|
|
93
|
+
* one provider hiccup cannot abort the sweep via concurrentMap's
|
|
94
|
+
* first-rejection-wins policy.
|
|
95
|
+
*/
|
|
96
|
+
function readStoryTickets(entries, o) {
|
|
97
|
+
return concurrentMap(
|
|
98
|
+
entries,
|
|
99
|
+
async (entry) => {
|
|
100
|
+
const wtPath = entry.path;
|
|
101
|
+
const storyId = wtPath
|
|
102
|
+
? storyIdFromPath(wtPath, o.worktreeRoot, o.platform)
|
|
103
|
+
: null;
|
|
104
|
+
if (storyId === null) return { kind: 'non-story' };
|
|
105
|
+
try {
|
|
106
|
+
const ticket = await o.provider.getTicket(storyId);
|
|
107
|
+
return { kind: 'ok', wtPath, storyId, ticket };
|
|
108
|
+
} catch (err) {
|
|
109
|
+
return { kind: 'provider-error', wtPath, storyId, error: err };
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
{ concurrency: TICKET_READ_CONCURRENCY },
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Why a read entry must be kept, or `null` when it may be removed.
|
|
118
|
+
*
|
|
119
|
+
* @returns {string|null}
|
|
120
|
+
*/
|
|
121
|
+
function keepReason(r, o) {
|
|
122
|
+
if (r.kind === 'provider-error') {
|
|
123
|
+
o.logger.warn(
|
|
124
|
+
`worktree-sweep: provider.getTicket(#${r.storyId}) failed: ${r.error.message}`,
|
|
125
|
+
);
|
|
126
|
+
return `provider-error: ${r.error.message}`;
|
|
127
|
+
}
|
|
128
|
+
if (!isStoryDone(r.ticket)) return 'story-open';
|
|
129
|
+
const selfPath = findRunningCodeInside(o, r.wtPath, o.guardPaths);
|
|
130
|
+
if (selfPath) {
|
|
131
|
+
o.logger.warn(
|
|
132
|
+
`worktree-sweep: skipped storyId=${r.storyId} path=${r.wtPath} — the running process uses it (${selfPath})`,
|
|
133
|
+
);
|
|
134
|
+
return 'running-from-target-tree';
|
|
135
|
+
}
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function removeOne(r, o) {
|
|
140
|
+
const ctx = {
|
|
141
|
+
repoRoot: o.repoRoot,
|
|
142
|
+
git: o.git,
|
|
143
|
+
logger: o.logger,
|
|
144
|
+
platform: o.platform,
|
|
145
|
+
worktreeRoot: o.worktreeRoot,
|
|
146
|
+
listCache: { list: null, ts: 0 },
|
|
147
|
+
fsRm: o.fsRm,
|
|
148
|
+
};
|
|
149
|
+
const res = await removeWorktreeWithRecovery(ctx, r.wtPath, {
|
|
150
|
+
storyId: r.storyId,
|
|
151
|
+
...(o.sleepFn ? { sleepFn: o.sleepFn, retryDelay: 0 } : {}),
|
|
152
|
+
});
|
|
153
|
+
return res.removed
|
|
154
|
+
? null
|
|
155
|
+
: `remove-failed: ${res.reason ?? 'worktree-remove-failed'}`;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Never touches a worktree whose Story is still open, nor the one the
|
|
160
|
+
* running process uses.
|
|
161
|
+
*
|
|
162
|
+
* @param {object} opts
|
|
163
|
+
* @param {object} opts.provider Only `getTicket(id)` is required.
|
|
164
|
+
* @param {string} opts.repoRoot Absolute path to the main checkout.
|
|
165
|
+
* @param {object} [opts.git]
|
|
166
|
+
* @param {object} [opts.logger]
|
|
167
|
+
* @param {string[]} [opts.runningPaths] Extra paths the running process owns.
|
|
168
|
+
* @returns {Promise<{
|
|
169
|
+
* reaped: Array<{ storyId: number, path: string }>,
|
|
170
|
+
* skipped: Array<{ storyId: number|null, path: string, reason: string }>,
|
|
171
|
+
* }>}
|
|
172
|
+
*/
|
|
173
|
+
export async function sweepStaleStoryWorktrees(opts = {}) {
|
|
174
|
+
const o = resolveSweepOptions(opts);
|
|
68
175
|
|
|
69
176
|
// Drain the pending-cleanup manifest first so entries whose Windows locks
|
|
70
177
|
// have released self-heal instead of accumulating.
|
|
71
178
|
const drainResult = await forceDrainPendingCleanup({
|
|
72
|
-
repoRoot,
|
|
73
|
-
worktreeRoot:
|
|
74
|
-
git,
|
|
75
|
-
fsRm,
|
|
76
|
-
logger,
|
|
179
|
+
repoRoot: o.repoRoot,
|
|
180
|
+
worktreeRoot: o.worktreeRoot,
|
|
181
|
+
git: o.git,
|
|
182
|
+
fsRm: o.fsRm,
|
|
183
|
+
logger: o.logger,
|
|
77
184
|
});
|
|
78
185
|
|
|
79
186
|
const reaped = [];
|
|
80
187
|
const skipped = [];
|
|
81
188
|
|
|
82
|
-
const listRes = git.gitSpawn(repoRoot, 'worktree', 'list', '--porcelain');
|
|
189
|
+
const listRes = o.git.gitSpawn(o.repoRoot, 'worktree', 'list', '--porcelain');
|
|
83
190
|
if (listRes.status !== 0) {
|
|
84
|
-
logger.warn(
|
|
191
|
+
o.logger.warn(
|
|
85
192
|
`worktree-sweep: git worktree list failed: ${listRes.stderr || listRes.stdout || 'unknown'}`,
|
|
86
193
|
);
|
|
87
194
|
return { reaped, skipped };
|
|
88
195
|
}
|
|
89
196
|
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
// the sweep via concurrentMap's first-rejection-wins policy.
|
|
94
|
-
const reads = await concurrentMap(
|
|
95
|
-
entries,
|
|
96
|
-
async (entry) => {
|
|
97
|
-
const wtPath = entry.path;
|
|
98
|
-
if (!wtPath) return { kind: 'no-path' };
|
|
99
|
-
const storyId = storyIdFromPath(wtPath);
|
|
100
|
-
if (storyId === null) return { kind: 'non-story' };
|
|
101
|
-
try {
|
|
102
|
-
const ticket = await provider.getTicket(storyId);
|
|
103
|
-
return { kind: 'ok', wtPath, storyId, ticket };
|
|
104
|
-
} catch (err) {
|
|
105
|
-
return { kind: 'provider-error', wtPath, storyId, error: err };
|
|
106
|
-
}
|
|
107
|
-
},
|
|
108
|
-
{ concurrency: TICKET_READ_CONCURRENCY },
|
|
197
|
+
const reads = await readStoryTickets(
|
|
198
|
+
parseWorktreePorcelain(listRes.stdout || ''),
|
|
199
|
+
o,
|
|
109
200
|
);
|
|
110
201
|
|
|
111
202
|
// Removes stay sequential: they mutate .git/worktrees/, and racing git's
|
|
112
203
|
// locking on Windows can leave a partial admin dir the next remove trips on.
|
|
113
204
|
for (const r of reads) {
|
|
114
|
-
if (r.kind === '
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
path: r.wtPath,
|
|
119
|
-
reason: `provider-error: ${r.error.message}`,
|
|
120
|
-
});
|
|
121
|
-
logger.warn(
|
|
122
|
-
`worktree-sweep: provider.getTicket(#${r.storyId}) failed: ${r.error.message}`,
|
|
123
|
-
);
|
|
124
|
-
continue;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
if (!isStoryDone(r.ticket)) {
|
|
128
|
-
skipped.push({
|
|
129
|
-
storyId: r.storyId,
|
|
130
|
-
path: r.wtPath,
|
|
131
|
-
reason: 'story-open',
|
|
132
|
-
});
|
|
133
|
-
continue;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
const res = git.gitSpawn(
|
|
137
|
-
repoRoot,
|
|
138
|
-
'worktree',
|
|
139
|
-
'remove',
|
|
140
|
-
'--force',
|
|
141
|
-
r.wtPath,
|
|
142
|
-
);
|
|
143
|
-
if (res.status !== 0) {
|
|
144
|
-
const reason = (
|
|
145
|
-
res.stderr ||
|
|
146
|
-
res.stdout ||
|
|
147
|
-
'worktree-remove-failed'
|
|
148
|
-
).trim();
|
|
149
|
-
skipped.push({
|
|
150
|
-
storyId: r.storyId,
|
|
151
|
-
path: r.wtPath,
|
|
152
|
-
reason: `remove-failed: ${reason}`,
|
|
153
|
-
});
|
|
154
|
-
logger.warn(
|
|
155
|
-
`worktree-sweep: failed to reap storyId=${r.storyId} path=${r.wtPath}: ${reason}`,
|
|
156
|
-
);
|
|
205
|
+
if (r.kind === 'non-story') continue;
|
|
206
|
+
const reason = keepReason(r, o) ?? (await removeOne(r, o));
|
|
207
|
+
if (reason) {
|
|
208
|
+
skipped.push({ storyId: r.storyId, path: r.wtPath, reason });
|
|
157
209
|
continue;
|
|
158
210
|
}
|
|
159
211
|
reaped.push({ storyId: r.storyId, path: r.wtPath });
|
|
160
|
-
logger.info(
|
|
212
|
+
o.logger.info(
|
|
161
213
|
`worktree-sweep: reaped stale worktree storyId=${r.storyId} path=${r.wtPath}`,
|
|
162
214
|
);
|
|
163
215
|
}
|
|
164
216
|
|
|
165
|
-
git.gitSpawn(repoRoot, 'worktree', 'prune');
|
|
217
|
+
o.git.gitSpawn(o.repoRoot, 'worktree', 'prune');
|
|
166
218
|
|
|
167
219
|
return {
|
|
168
220
|
reaped,
|
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
executeFastForward as defaultExecuteFastForward,
|
|
12
12
|
planCleanup as defaultPlanCleanup,
|
|
13
13
|
planFastForward as defaultPlanFastForward,
|
|
14
|
-
} from '../git
|
|
14
|
+
} from '../clean-git.js';
|
|
15
15
|
import { evaluateProtection as defaultEvaluateProtection } from './single-story-sweep/protection.js';
|
|
16
16
|
import { acquireSweepLock as defaultAcquireSweepLock } from './single-story-sweep/sweep-lock.js';
|
|
17
17
|
|
|
@@ -175,7 +175,7 @@ export function sweepMergedStoryBranches(args = {}) {
|
|
|
175
175
|
|
|
176
176
|
/**
|
|
177
177
|
* Content-merged candidates are report-only: content equivalence is weaker
|
|
178
|
-
* than a merged PR or ancestry, so only an operator-confirmed `/git
|
|
178
|
+
* than a merged PR or ancestry, so only an operator-confirmed `/clean-git`
|
|
179
179
|
* may reap them.
|
|
180
180
|
*/
|
|
181
181
|
function partitionContentMerged(candidates) {
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Filesystem primitives for the temp-retention engine: sizing a tree and
|
|
3
|
+
* deleting one while sparing the never-purged basenames at any depth.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Never deleted, re-checked at the deletion site: `signals.ndjson` is read
|
|
10
|
+
* long after merge and its loss is silent and unrecoverable.
|
|
11
|
+
*/
|
|
12
|
+
export const KEEP_BASENAMES = Object.freeze(['signals.ndjson']);
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* `readdir` yielding `[]` for an absent or unreadable directory.
|
|
16
|
+
*
|
|
17
|
+
* @param {typeof import('node:fs/promises')} fsp
|
|
18
|
+
* @param {string} dir
|
|
19
|
+
* @returns {Promise<import('node:fs').Dirent[]>}
|
|
20
|
+
*/
|
|
21
|
+
export async function safeReaddir(fsp, dir) {
|
|
22
|
+
try {
|
|
23
|
+
return await fsp.readdir(dir, { withFileTypes: true });
|
|
24
|
+
} catch {
|
|
25
|
+
return [];
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Recursive byte total; a vanished child is skipped.
|
|
31
|
+
*
|
|
32
|
+
* @param {typeof import('node:fs/promises')} fsp
|
|
33
|
+
* @param {string} target
|
|
34
|
+
* @returns {Promise<number>}
|
|
35
|
+
*/
|
|
36
|
+
export async function sizeOf(fsp, target) {
|
|
37
|
+
let total = 0;
|
|
38
|
+
const stack = [target];
|
|
39
|
+
while (stack.length > 0) {
|
|
40
|
+
const current = stack.pop();
|
|
41
|
+
let stats;
|
|
42
|
+
try {
|
|
43
|
+
stats = await fsp.stat(current);
|
|
44
|
+
} catch {
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (!stats.isDirectory()) {
|
|
48
|
+
total += stats.size;
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
for (const child of await safeReaddir(fsp, current)) {
|
|
52
|
+
stack.push(path.join(current, child.name));
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return total;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Paths of every never-purged basename at any depth under `target`.
|
|
60
|
+
*
|
|
61
|
+
* @param {typeof import('node:fs/promises')} fsp
|
|
62
|
+
* @param {string} target
|
|
63
|
+
* @returns {Promise<string[]>}
|
|
64
|
+
*/
|
|
65
|
+
async function findKeptDescendants(fsp, target) {
|
|
66
|
+
const kept = [];
|
|
67
|
+
const stack = [target];
|
|
68
|
+
while (stack.length > 0) {
|
|
69
|
+
const current = stack.pop();
|
|
70
|
+
for (const child of await safeReaddir(fsp, current)) {
|
|
71
|
+
const childPath = path.join(current, child.name);
|
|
72
|
+
if (child.isDirectory()) stack.push(childPath);
|
|
73
|
+
else if (KEEP_BASENAMES.includes(child.name)) kept.push(childPath);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return kept;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Delete `target` while sparing every never-purged basename beneath it: a
|
|
81
|
+
* tree holding none goes in one `rm`; otherwise its children are removed
|
|
82
|
+
* one by one and the kept files (with their parent dirs) stay.
|
|
83
|
+
*
|
|
84
|
+
* @param {typeof import('node:fs/promises')} fsp
|
|
85
|
+
* @param {string} target
|
|
86
|
+
* @param {number} [knownBytes] Pre-computed size, spared a second walk.
|
|
87
|
+
* @returns {Promise<{ bytes: number, kept: string[] }>}
|
|
88
|
+
*/
|
|
89
|
+
export async function removeSparingKept(fsp, target, knownBytes) {
|
|
90
|
+
const stats = await fsp.stat(target);
|
|
91
|
+
if (!stats.isDirectory()) {
|
|
92
|
+
if (KEEP_BASENAMES.includes(path.basename(target))) {
|
|
93
|
+
return { bytes: 0, kept: [target] };
|
|
94
|
+
}
|
|
95
|
+
await fsp.rm(target, { force: true });
|
|
96
|
+
return { bytes: stats.size, kept: [] };
|
|
97
|
+
}
|
|
98
|
+
const kept = await findKeptDescendants(fsp, target);
|
|
99
|
+
if (kept.length === 0) {
|
|
100
|
+
const bytes = knownBytes ?? (await sizeOf(fsp, target));
|
|
101
|
+
await fsp.rm(target, { recursive: true, force: true });
|
|
102
|
+
return { bytes, kept };
|
|
103
|
+
}
|
|
104
|
+
let bytes = 0;
|
|
105
|
+
for (const child of await safeReaddir(fsp, target)) {
|
|
106
|
+
bytes += (await removeSparingKept(fsp, path.join(target, child.name)))
|
|
107
|
+
.bytes;
|
|
108
|
+
}
|
|
109
|
+
return { bytes, kept };
|
|
110
|
+
}
|