mandrel 2.66.0 → 2.68.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/agents/story-worker.md +15 -11
- package/.agents/docs/agentrc-reference.json +5 -2
- package/.agents/docs/configuration.md +38 -2
- 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 +20 -2
- package/.agents/schemas/story-deliver-terminal.schema.json +23 -1
- package/.agents/schemas/validation-evidence.schema.json +3 -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/coverage-capture.js +65 -9
- package/.agents/scripts/drain-pending-cleanup.js +5 -4
- package/.agents/scripts/evidence-gate.js +106 -8
- package/.agents/scripts/lib/baselines/coverage-refresh-scope.js +60 -0
- package/.agents/scripts/lib/baselines/crap-updater-cli.js +101 -4
- package/.agents/scripts/lib/baselines/refresh-service.js +1 -1
- package/.agents/scripts/lib/baselines/seat-missing.js +228 -0
- package/.agents/scripts/lib/child-exec.js +39 -1
- package/.agents/scripts/lib/clean-temp.js +440 -0
- package/.agents/scripts/lib/close-validation/gates.js +59 -19
- package/.agents/scripts/lib/close-validation/process.js +23 -24
- package/.agents/scripts/lib/close-validation/runner.js +71 -40
- package/.agents/scripts/lib/config/gates/coverage.schema.js +21 -0
- package/.agents/scripts/lib/config/quality.js +7 -1
- package/.agents/scripts/lib/config/temp-paths.js +15 -0
- package/.agents/scripts/lib/config-settings-schema-delivery.js +12 -3
- package/.agents/scripts/lib/coverage-baseline.js +78 -5
- package/.agents/scripts/lib/coverage-capture-affected.js +345 -0
- package/.agents/scripts/lib/coverage-capture-delta.js +180 -0
- package/.agents/scripts/lib/coverage-capture-fullscope.js +53 -32
- package/.agents/scripts/lib/coverage-capture-incremental.js +49 -26
- package/.agents/scripts/lib/coverage-capture-usage.js +1 -1
- package/.agents/scripts/lib/coverage-capture.js +121 -81
- package/.agents/scripts/lib/full-suite-lock.js +49 -46
- package/.agents/scripts/lib/full-suite-queue.js +83 -8
- package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
- package/.agents/scripts/lib/observability/source-classifier.js +4 -1
- package/.agents/scripts/lib/orchestration/code-review.js +15 -3
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/cli.js +1 -1
- package/.agents/scripts/lib/orchestration/merge-poll.js +5 -0
- package/.agents/scripts/lib/orchestration/plan-runner/worktree-sweep.js +149 -97
- package/.agents/scripts/lib/orchestration/review-deposit.js +219 -0
- package/.agents/scripts/lib/orchestration/review-providers/code-review.js +11 -7
- package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +29 -10
- package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +124 -73
- package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +38 -20
- package/.agents/scripts/lib/orchestration/single-story-close/phases/lock-wait-pending.js +8 -2
- package/.agents/scripts/lib/orchestration/single-story-close/review-overlap.js +161 -0
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +47 -7
- package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +8 -16
- package/.agents/scripts/lib/process-group.js +1 -1
- package/.agents/scripts/lib/single-story-sweep.js +2 -2
- package/.agents/scripts/lib/supervised-suite.js +247 -0
- package/.agents/scripts/lib/temp-removal.js +110 -0
- package/.agents/scripts/lib/temp-retention.js +122 -73
- package/.agents/scripts/lib/wave-runner/cross-run-overlap.js +120 -0
- package/.agents/scripts/lib/wave-runner/live-probe.js +5 -1
- package/.agents/scripts/lib/worktree/canonical-path.js +34 -0
- package/.agents/scripts/lib/worktree/lifecycle/reap.js +15 -4
- package/.agents/scripts/quality-preview.js +112 -14
- package/.agents/scripts/single-story-init.js +120 -17
- package/.agents/scripts/stories-wave-tick.js +47 -0
- package/.agents/scripts/story-review-compute.js +207 -0
- package/.agents/scripts/update-coverage-baseline.js +15 -10
- package/.agents/scripts/update-crap-baseline.js +12 -2
- package/.agents/scripts/update-maintainability-baseline.js +12 -2
- 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/code-review.md +7 -5
- package/.agents/workflows/helpers/deliver-digest.md +39 -36
- package/.agents/workflows/helpers/deliver-reference.md +115 -5
- package/.agents/workflows/helpers/deliver-story-reference.md +2 -2
- package/.agents/workflows/helpers/deliver-story.md +2 -1
- package/docs/CHANGELOG.md +39 -0
- package/lib/cli/registry.js +125 -18
- package/lib/migrations/steps/strip-removed-agentrc-keys.js +0 -5
- package/package.json +1 -1
|
@@ -0,0 +1,593 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* clean-worktrees.js — enumerate every git worktree of this project (plus
|
|
5
|
+
* unregistered directories under `.worktrees/`), classify each as a removal
|
|
6
|
+
* candidate or as kept-with-a-reason, and — only under `--execute` — remove
|
|
7
|
+
* the candidates through the worktree removal seam (flags: see HELP).
|
|
8
|
+
*
|
|
9
|
+
* Candidate classes: `closed-story`, `merged-branch`, `orphan-dir`,
|
|
10
|
+
* `detached`. Safety: dry-run by default; nothing outside the project root,
|
|
11
|
+
* no dirty tree, no HEAD unreachable from a remote-tracking ref, no tree a
|
|
12
|
+
* live process uses, and a `detached` tree only on a per-entry interactive
|
|
13
|
+
* yes (never under `--yes`).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import fs from 'node:fs';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import { createInterface } from 'node:readline/promises';
|
|
19
|
+
import { parseArgs } from 'node:util';
|
|
20
|
+
|
|
21
|
+
import { spawnCapture } from './lib/child-exec.js';
|
|
22
|
+
import { runAsCli } from './lib/cli-utils.js';
|
|
23
|
+
import { PROJECT_ROOT, resolveConfig } from './lib/config-resolver.js';
|
|
24
|
+
import * as defaultGit from './lib/git-utils.js';
|
|
25
|
+
import { AGENT_LABELS } from './lib/label-constants.js';
|
|
26
|
+
import { createProvider } from './lib/provider-factory.js';
|
|
27
|
+
import { makeGhRunner } from './lib/single-story-sweep/protection-ctx.js';
|
|
28
|
+
import { canonicalPath } from './lib/worktree/canonical-path.js';
|
|
29
|
+
import {
|
|
30
|
+
isInsideWorktree,
|
|
31
|
+
parseWorktreePorcelain,
|
|
32
|
+
samePath,
|
|
33
|
+
} from './lib/worktree/inspector.js';
|
|
34
|
+
import {
|
|
35
|
+
findRunningCodeInside,
|
|
36
|
+
removeWorktreeWithRecovery,
|
|
37
|
+
} from './lib/worktree/lifecycle/reap.js';
|
|
38
|
+
|
|
39
|
+
export const CLASSES = Object.freeze({
|
|
40
|
+
CLOSED_STORY: 'closed-story',
|
|
41
|
+
MERGED_BRANCH: 'merged-branch',
|
|
42
|
+
ORPHAN_DIR: 'orphan-dir',
|
|
43
|
+
DETACHED: 'detached',
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const WORKTREE_DIR = '.worktrees';
|
|
47
|
+
|
|
48
|
+
/** Removal-seam diagnostics go to stderr: stdout carries the table / JSON. */
|
|
49
|
+
const STDERR_LOGGER = Object.freeze({
|
|
50
|
+
info: (m) => process.stderr.write(`${m}\n`),
|
|
51
|
+
warn: (m) => process.stderr.write(`${m}\n`),
|
|
52
|
+
error: (m) => process.stderr.write(`${m}\n`),
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const writeStdout = (text) => process.stdout.write(`${text}\n`);
|
|
56
|
+
|
|
57
|
+
const HELP = `Usage: node .agents/scripts/clean-worktrees.js [options]
|
|
58
|
+
|
|
59
|
+
Lists every worktree of this project — each registered one plus any
|
|
60
|
+
unregistered directory under .worktrees/ — as a removal candidate or as
|
|
61
|
+
kept with a reason, and prints its size. Dry-run by default: nothing is
|
|
62
|
+
removed without --execute.
|
|
63
|
+
|
|
64
|
+
Candidate classes:
|
|
65
|
+
closed-story .worktrees/story-<id> whose Story is closed or agent::done
|
|
66
|
+
merged-branch a branch whose PR is MERGED and whose HEAD is the merged head
|
|
67
|
+
orphan-dir a directory under .worktrees/ git does not register
|
|
68
|
+
detached a registered worktree with a detached HEAD
|
|
69
|
+
(e.g. .claude/worktrees/*) — removed only on a per-entry
|
|
70
|
+
interactive yes, never under --yes
|
|
71
|
+
|
|
72
|
+
Always kept: the main checkout, anything outside the project root, a dirty
|
|
73
|
+
tree, a HEAD no remote-tracking ref contains, a tree the running process or
|
|
74
|
+
(macOS/Linux) any live process uses, an open Story, an unmerged branch.
|
|
75
|
+
|
|
76
|
+
Options:
|
|
77
|
+
--execute Remove candidates (asks per entry unless --yes).
|
|
78
|
+
--yes With --execute, remove non-detached candidates without asking.
|
|
79
|
+
--json Emit the result envelope (with bytesReclaimed) as JSON.
|
|
80
|
+
--cwd <dir> Checkout to inspect. Default: this project's root.
|
|
81
|
+
-h, --help Show this help.
|
|
82
|
+
`;
|
|
83
|
+
|
|
84
|
+
/** Containment on canonical paths (8.3 short names, symlinks, case). */
|
|
85
|
+
function isWithin(child, parent, platform) {
|
|
86
|
+
return isInsideWorktree(
|
|
87
|
+
canonicalPath(child),
|
|
88
|
+
canonicalPath(parent),
|
|
89
|
+
platform,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Working directories of every visible process, or `null` where the platform
|
|
95
|
+
* does not expose them (Windows) — callers then skip the live-process check.
|
|
96
|
+
*
|
|
97
|
+
* @param {{ platform?: string, fsImpl?: typeof fs, spawn?: Function }} [deps]
|
|
98
|
+
* @returns {string[]|null}
|
|
99
|
+
*/
|
|
100
|
+
export function listProcessCwds({
|
|
101
|
+
platform = process.platform,
|
|
102
|
+
fsImpl = fs,
|
|
103
|
+
spawn = spawnCapture,
|
|
104
|
+
} = {}) {
|
|
105
|
+
if (platform === 'linux') return linuxProcessCwds(fsImpl);
|
|
106
|
+
if (platform === 'darwin') return darwinProcessCwds(spawn);
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function linuxProcessCwds(fsImpl) {
|
|
111
|
+
let pids;
|
|
112
|
+
try {
|
|
113
|
+
pids = fsImpl.readdirSync('/proc').filter((d) => /^\d+$/.test(d));
|
|
114
|
+
} catch {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
const cwds = [];
|
|
118
|
+
for (const pid of pids) {
|
|
119
|
+
try {
|
|
120
|
+
cwds.push(fsImpl.readlinkSync(`/proc/${pid}/cwd`));
|
|
121
|
+
} catch {
|
|
122
|
+
// Another user's process, or it exited — not ours to see.
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return cwds;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function darwinProcessCwds(spawn) {
|
|
129
|
+
const res = spawn('lsof', ['-w', '-a', '-d', 'cwd', '-Fn']);
|
|
130
|
+
if (!res || typeof res.stdout !== 'string' || res.stdout === '') return null;
|
|
131
|
+
return res.stdout
|
|
132
|
+
.split('\n')
|
|
133
|
+
.filter((line) => line.startsWith('n'))
|
|
134
|
+
.map((line) => line.slice(1));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Directory size in bytes; `du` where available, else a recursive walk that
|
|
139
|
+
* never follows symlinks. `null` when unreadable.
|
|
140
|
+
*
|
|
141
|
+
* @param {string} dir
|
|
142
|
+
* @param {{ platform?: string, spawn?: Function, fsImpl?: typeof fs }} [deps]
|
|
143
|
+
* @returns {number|null}
|
|
144
|
+
*/
|
|
145
|
+
export function dirSizeBytes(
|
|
146
|
+
dir,
|
|
147
|
+
{ platform = process.platform, spawn = spawnCapture, fsImpl = fs } = {},
|
|
148
|
+
) {
|
|
149
|
+
if (platform !== 'win32') {
|
|
150
|
+
const res = spawn('du', ['-sk', dir]);
|
|
151
|
+
const kb = Number.parseInt(String(res?.stdout ?? ''), 10);
|
|
152
|
+
if (res?.status === 0 && Number.isFinite(kb)) return kb * 1024;
|
|
153
|
+
}
|
|
154
|
+
return walkSize(dir, fsImpl);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function walkSize(target, fsImpl) {
|
|
158
|
+
let stat;
|
|
159
|
+
try {
|
|
160
|
+
stat = fsImpl.lstatSync(target);
|
|
161
|
+
} catch {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
if (!stat.isDirectory()) return stat.size;
|
|
165
|
+
let total = 0;
|
|
166
|
+
for (const name of fsImpl.readdirSync(target)) {
|
|
167
|
+
total += walkSize(path.join(target, name), fsImpl) ?? 0;
|
|
168
|
+
}
|
|
169
|
+
return total;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Registered worktrees (main first, as git lists it) plus directories under
|
|
174
|
+
* `<projectRoot>/.worktrees/` that git does not register.
|
|
175
|
+
*
|
|
176
|
+
* @param {{ cwd: string, git: object, platform: string, fsImpl?: typeof fs }} args
|
|
177
|
+
* @returns {{ projectRoot: string, registered: object[], orphans: string[] }}
|
|
178
|
+
*/
|
|
179
|
+
export function enumerateWorktrees({ cwd, git, platform, fsImpl = fs }) {
|
|
180
|
+
const res = git.gitSpawn(cwd, 'worktree', 'list', '--porcelain');
|
|
181
|
+
if (res.status !== 0) {
|
|
182
|
+
throw new Error(
|
|
183
|
+
`git worktree list failed: ${res.stderr || res.stdout || 'unknown'}`,
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
const registered = parseWorktreePorcelain(res.stdout || '');
|
|
187
|
+
if (registered.length === 0) throw new Error('git listed no worktrees');
|
|
188
|
+
const projectRoot = canonicalPath(registered[0].path);
|
|
189
|
+
const known = registered.map((r) => canonicalPath(r.path));
|
|
190
|
+
const wtDir = path.join(projectRoot, WORKTREE_DIR);
|
|
191
|
+
let names = [];
|
|
192
|
+
try {
|
|
193
|
+
names = fsImpl
|
|
194
|
+
.readdirSync(wtDir, { withFileTypes: true })
|
|
195
|
+
.filter((d) => d.isDirectory())
|
|
196
|
+
.map((d) => d.name);
|
|
197
|
+
} catch {
|
|
198
|
+
// No .worktrees/ directory: nothing orphaned.
|
|
199
|
+
}
|
|
200
|
+
const orphans = names
|
|
201
|
+
.map((name) => path.join(wtDir, name))
|
|
202
|
+
.filter((p) => !known.some((k) => samePath(k, canonicalPath(p), platform)));
|
|
203
|
+
return { projectRoot, registered, orphans };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Kept-reason for a tree the running process or a live process uses. */
|
|
207
|
+
function liveUseReason(wtPath, env) {
|
|
208
|
+
if (findRunningCodeInside(env, wtPath, env.guardPaths)) {
|
|
209
|
+
return 'running-from-tree';
|
|
210
|
+
}
|
|
211
|
+
const cwds = env.processCwds ?? [];
|
|
212
|
+
if (cwds.some((c) => isWithin(c, wtPath, env.platform))) {
|
|
213
|
+
return 'live-process';
|
|
214
|
+
}
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Kept-reason when the tree holds work that exists nowhere else. */
|
|
219
|
+
function uniqueWorkReason(rec, env) {
|
|
220
|
+
const status = env.git.gitSpawn(rec.path, 'status', '--porcelain');
|
|
221
|
+
if (status.status !== 0 || status.stdout.trim() !== '') return 'dirty-tree';
|
|
222
|
+
if (!rec.head) return 'unpushed-commits';
|
|
223
|
+
const refs = env.git.gitSpawn(
|
|
224
|
+
env.projectRoot,
|
|
225
|
+
'for-each-ref',
|
|
226
|
+
'--contains',
|
|
227
|
+
rec.head,
|
|
228
|
+
'--count=1',
|
|
229
|
+
'--format=%(refname)',
|
|
230
|
+
'refs/remotes/',
|
|
231
|
+
);
|
|
232
|
+
if (refs.status !== 0 || refs.stdout.trim() === '') {
|
|
233
|
+
return 'unpushed-commits';
|
|
234
|
+
}
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function isStoryDone(ticket) {
|
|
239
|
+
if (!ticket) return false;
|
|
240
|
+
if (ticket.state === 'closed') return true;
|
|
241
|
+
return (ticket.labels ?? []).includes(AGENT_LABELS.DONE);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async function classifyStory(storyId, env) {
|
|
245
|
+
try {
|
|
246
|
+
const ticket = await env.getTicket(storyId);
|
|
247
|
+
return isStoryDone(ticket)
|
|
248
|
+
? { class: CLASSES.CLOSED_STORY }
|
|
249
|
+
: { reason: 'story-open' };
|
|
250
|
+
} catch (err) {
|
|
251
|
+
return { reason: `provider-error: ${err?.message ?? err}` };
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function classifyBranch(rec, env) {
|
|
256
|
+
let prs;
|
|
257
|
+
try {
|
|
258
|
+
prs = env.prLookup(rec.branch);
|
|
259
|
+
} catch (err) {
|
|
260
|
+
return { reason: `pr-lookup-failed: ${err?.message ?? err}` };
|
|
261
|
+
}
|
|
262
|
+
const merged = (prs ?? []).some((pr) => pr?.headRefOid === rec.head);
|
|
263
|
+
return merged
|
|
264
|
+
? { class: CLASSES.MERGED_BRANCH }
|
|
265
|
+
: { reason: 'unmerged-branch' };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** A Story id only for `<projectRoot>/.worktrees/story-<id>` on `story-<id>`. */
|
|
269
|
+
function storyIdOf(rec, env) {
|
|
270
|
+
const parent = path.dirname(path.resolve(rec.path));
|
|
271
|
+
const wtDir = path.join(env.projectRoot, WORKTREE_DIR);
|
|
272
|
+
if (!samePath(canonicalPath(parent), canonicalPath(wtDir), env.platform)) {
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
275
|
+
const fromDir = defaultGit.parseStoryBranch(path.basename(rec.path));
|
|
276
|
+
return fromDir !== null && fromDir === defaultGit.parseStoryBranch(rec.branch)
|
|
277
|
+
? fromDir
|
|
278
|
+
: null;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** First kept-reason that needs no network, or `null`. */
|
|
282
|
+
function localKeepReason(rec, env) {
|
|
283
|
+
if (rec.bare) return 'bare';
|
|
284
|
+
if (!isWithin(rec.path, env.projectRoot, env.platform)) {
|
|
285
|
+
return 'outside-project';
|
|
286
|
+
}
|
|
287
|
+
if (!fs.existsSync(rec.path)) return 'path-missing';
|
|
288
|
+
return liveUseReason(rec.path, env) ?? uniqueWorkReason(rec, env);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* @param {object} rec a `parseWorktreePorcelain` record
|
|
293
|
+
* @param {object} env
|
|
294
|
+
* @returns {Promise<{ class?: string, reason?: string }>}
|
|
295
|
+
*/
|
|
296
|
+
async function classifyRegistered(rec, env) {
|
|
297
|
+
const kept = localKeepReason(rec, env);
|
|
298
|
+
if (kept) return { reason: kept };
|
|
299
|
+
if (rec.detached) return { class: CLASSES.DETACHED };
|
|
300
|
+
const storyId = storyIdOf(rec, env);
|
|
301
|
+
if (storyId !== null) return classifyStory(storyId, env);
|
|
302
|
+
return classifyBranch(rec, env);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Classify every worktree: exactly one of `class` (a candidate) or `reason`
|
|
307
|
+
* (kept) per entry.
|
|
308
|
+
*
|
|
309
|
+
* @param {object} env resolved dependencies (see `buildEnv`)
|
|
310
|
+
* @returns {Promise<object[]>}
|
|
311
|
+
*/
|
|
312
|
+
export async function classifyWorktrees(env) {
|
|
313
|
+
const entries = [];
|
|
314
|
+
const [main, ...rest] = env.registered;
|
|
315
|
+
entries.push(entryOf(main, { reason: 'main-checkout' }, false));
|
|
316
|
+
for (const rec of rest) {
|
|
317
|
+
entries.push(entryOf(rec, await classifyRegistered(rec, env), true, env));
|
|
318
|
+
}
|
|
319
|
+
for (const dir of env.orphans) {
|
|
320
|
+
const reason = liveUseReason(dir, env);
|
|
321
|
+
const verdict = reason ? { reason } : { class: CLASSES.ORPHAN_DIR };
|
|
322
|
+
entries.push(entryOf({ path: dir }, verdict, true, env));
|
|
323
|
+
}
|
|
324
|
+
return entries;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function entryOf(rec, verdict, sized, env) {
|
|
328
|
+
const exists = sized && fs.existsSync(rec.path);
|
|
329
|
+
return {
|
|
330
|
+
// One spelling per tree: git's `C:/…` and a short-name `RUNNER~1` form
|
|
331
|
+
// both become the canonical native path.
|
|
332
|
+
path: canonicalPath(rec.path),
|
|
333
|
+
branch: rec.branch ?? null,
|
|
334
|
+
head: rec.head ?? null,
|
|
335
|
+
class: verdict.class ?? null,
|
|
336
|
+
reason: verdict.reason ?? null,
|
|
337
|
+
sizeBytes: exists ? env.sizeOf(rec.path) : null,
|
|
338
|
+
action: verdict.class ? 'candidate' : 'kept',
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Whether to remove one candidate: `--yes` covers every class but
|
|
344
|
+
* `detached`, which only a per-entry interactive yes can remove.
|
|
345
|
+
*
|
|
346
|
+
* @returns {Promise<{ remove: boolean, reason?: string }>}
|
|
347
|
+
*/
|
|
348
|
+
async function decide(entry, opts) {
|
|
349
|
+
const detached = entry.class === CLASSES.DETACHED;
|
|
350
|
+
if (opts.yes && !detached) return { remove: true };
|
|
351
|
+
if (detached && opts.yes) {
|
|
352
|
+
return { remove: false, reason: 'detached-never-under-yes' };
|
|
353
|
+
}
|
|
354
|
+
if (!opts.confirm) {
|
|
355
|
+
return { remove: false, reason: 'needs-confirmation' };
|
|
356
|
+
}
|
|
357
|
+
const ok = await opts.confirm(entry);
|
|
358
|
+
return ok ? { remove: true } : { remove: false, reason: 'declined' };
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Remove the candidates `decide` approves; returns bytes reclaimed.
|
|
363
|
+
*
|
|
364
|
+
* @param {object[]} entries mutated in place (`action`, `reason`)
|
|
365
|
+
* @param {object} opts `{ yes, confirm, removeFn }`
|
|
366
|
+
* @returns {Promise<number>}
|
|
367
|
+
*/
|
|
368
|
+
export async function executeRemovals(entries, opts) {
|
|
369
|
+
let bytes = 0;
|
|
370
|
+
for (const entry of entries.filter((e) => e.class)) {
|
|
371
|
+
const verdict = await decide(entry, opts);
|
|
372
|
+
if (!verdict.remove) {
|
|
373
|
+
entry.action = 'skipped';
|
|
374
|
+
entry.reason = verdict.reason;
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
const res = await opts.removeFn(entry.path);
|
|
378
|
+
entry.action = res.removed ? 'removed' : 'failed';
|
|
379
|
+
if (!res.removed) {
|
|
380
|
+
entry.reason = `remove-failed: ${res.reason ?? 'unknown'}`;
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
bytes += entry.sizeBytes ?? 0;
|
|
384
|
+
}
|
|
385
|
+
return bytes;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function makeRemoveFn({ projectRoot, git, platform, logger }) {
|
|
389
|
+
const ctx = {
|
|
390
|
+
repoRoot: projectRoot,
|
|
391
|
+
git,
|
|
392
|
+
platform,
|
|
393
|
+
logger,
|
|
394
|
+
worktreeRoot: path.join(projectRoot, WORKTREE_DIR),
|
|
395
|
+
listCache: { list: null, ts: 0 },
|
|
396
|
+
};
|
|
397
|
+
return (wtPath) => removeWorktreeWithRecovery(ctx, wtPath, {});
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function makeTicketReader(projectRoot) {
|
|
401
|
+
let provider = null;
|
|
402
|
+
return (id) => {
|
|
403
|
+
provider ??= createProvider(resolveConfig({ cwd: projectRoot }));
|
|
404
|
+
return provider.getTicket(id);
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function makePrLookup(projectRoot) {
|
|
409
|
+
const gh = makeGhRunner(projectRoot);
|
|
410
|
+
return (branch) =>
|
|
411
|
+
JSON.parse(
|
|
412
|
+
gh([
|
|
413
|
+
'pr',
|
|
414
|
+
'list',
|
|
415
|
+
'--head',
|
|
416
|
+
branch,
|
|
417
|
+
'--state',
|
|
418
|
+
'merged',
|
|
419
|
+
'--json',
|
|
420
|
+
'number,headRefOid',
|
|
421
|
+
'--limit',
|
|
422
|
+
'10',
|
|
423
|
+
]) || '[]',
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Resolve every dependency, defaulting to the real ones.
|
|
429
|
+
*
|
|
430
|
+
* @param {object} args
|
|
431
|
+
* @returns {object}
|
|
432
|
+
*/
|
|
433
|
+
function buildEnv(args) {
|
|
434
|
+
const platform = args.platform ?? process.platform;
|
|
435
|
+
const git = args.git ?? defaultGit;
|
|
436
|
+
const cwd = path.resolve(args.cwd ?? PROJECT_ROOT);
|
|
437
|
+
const listing = enumerateWorktrees({ cwd, git, platform });
|
|
438
|
+
const { projectRoot } = listing;
|
|
439
|
+
return {
|
|
440
|
+
...listing,
|
|
441
|
+
platform,
|
|
442
|
+
git,
|
|
443
|
+
guardPaths: [cwd, process.cwd(), ...(args.runningPaths ?? [])],
|
|
444
|
+
processCwds:
|
|
445
|
+
args.processCwds !== undefined
|
|
446
|
+
? args.processCwds
|
|
447
|
+
: listProcessCwds({ platform }),
|
|
448
|
+
getTicket: args.getTicket ?? makeTicketReader(projectRoot),
|
|
449
|
+
prLookup: args.prLookup ?? makePrLookup(projectRoot),
|
|
450
|
+
sizeOf: args.sizeOf ?? ((p) => dirSizeBytes(p, { platform })),
|
|
451
|
+
removeFn:
|
|
452
|
+
args.removeFn ??
|
|
453
|
+
makeRemoveFn({
|
|
454
|
+
projectRoot,
|
|
455
|
+
git,
|
|
456
|
+
platform,
|
|
457
|
+
logger: args.logger ?? STDERR_LOGGER,
|
|
458
|
+
}),
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Enumerate, classify and (under `execute`) remove. Seams for tests: every
|
|
464
|
+
* `buildEnv` dependency may be injected.
|
|
465
|
+
*
|
|
466
|
+
* @param {object} [args]
|
|
467
|
+
* @param {string} [args.cwd]
|
|
468
|
+
* @param {boolean} [args.execute]
|
|
469
|
+
* @param {boolean} [args.yes]
|
|
470
|
+
* @param {Function|null} [args.confirm] per-entry interactive prompt
|
|
471
|
+
* @returns {Promise<object>} the `clean-worktrees` envelope.
|
|
472
|
+
*/
|
|
473
|
+
export async function runCleanWorktrees(args = {}) {
|
|
474
|
+
const env = buildEnv(args);
|
|
475
|
+
const entries = await classifyWorktrees(env);
|
|
476
|
+
const bytesReclaimed = args.execute
|
|
477
|
+
? await executeRemovals(entries, {
|
|
478
|
+
yes: args.yes === true,
|
|
479
|
+
confirm: args.confirm ?? null,
|
|
480
|
+
removeFn: env.removeFn,
|
|
481
|
+
})
|
|
482
|
+
: 0;
|
|
483
|
+
return {
|
|
484
|
+
kind: 'clean-worktrees',
|
|
485
|
+
mode: args.execute ? 'execute' : 'dry-run',
|
|
486
|
+
projectRoot: env.projectRoot,
|
|
487
|
+
entries,
|
|
488
|
+
bytesReclaimed,
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* @param {number|null} bytes
|
|
494
|
+
* @returns {string}
|
|
495
|
+
*/
|
|
496
|
+
export function formatBytes(bytes) {
|
|
497
|
+
if (bytes === null || bytes === undefined) return '-';
|
|
498
|
+
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
499
|
+
let value = bytes;
|
|
500
|
+
let unit = 0;
|
|
501
|
+
while (value >= 1024 && unit < units.length - 1) {
|
|
502
|
+
value /= 1024;
|
|
503
|
+
unit++;
|
|
504
|
+
}
|
|
505
|
+
return `${unit === 0 ? value : value.toFixed(1)}${units[unit]}`;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* The human table: class, path, size, branch/HEAD, action and reason.
|
|
510
|
+
*
|
|
511
|
+
* @param {object} result the `runCleanWorktrees` envelope
|
|
512
|
+
* @returns {string}
|
|
513
|
+
*/
|
|
514
|
+
export function renderTable(result) {
|
|
515
|
+
const rows = result.entries.map((e) => [
|
|
516
|
+
e.class ?? 'kept',
|
|
517
|
+
path.relative(result.projectRoot, e.path) || '.',
|
|
518
|
+
formatBytes(e.sizeBytes),
|
|
519
|
+
e.branch ?? (e.head ? `(detached ${e.head.slice(0, 7)})` : '-'),
|
|
520
|
+
e.reason ? `${e.action}: ${e.reason}` : e.action,
|
|
521
|
+
]);
|
|
522
|
+
const header = ['CLASS', 'PATH', 'SIZE', 'BRANCH/HEAD', 'ACTION'];
|
|
523
|
+
const widths = header.map((h, i) =>
|
|
524
|
+
Math.max(h.length, ...rows.map((r) => r[i].length)),
|
|
525
|
+
);
|
|
526
|
+
const line = (cells) =>
|
|
527
|
+
cells
|
|
528
|
+
.map((c, i) => c.padEnd(widths[i]))
|
|
529
|
+
.join(' ')
|
|
530
|
+
.trimEnd();
|
|
531
|
+
const footer =
|
|
532
|
+
result.mode === 'dry-run'
|
|
533
|
+
? 'Dry run — nothing removed. Re-run with --execute to remove candidates.'
|
|
534
|
+
: `Reclaimed ${formatBytes(result.bytesReclaimed)}.`;
|
|
535
|
+
return [line(header), ...rows.map(line), '', footer].join('\n');
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
async function promptConfirm(entry) {
|
|
539
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
540
|
+
try {
|
|
541
|
+
const answer = await rl.question(
|
|
542
|
+
`Remove ${entry.class} worktree ${entry.path}? [y/N] `,
|
|
543
|
+
);
|
|
544
|
+
return /^y(es)?$/i.test(answer.trim());
|
|
545
|
+
} finally {
|
|
546
|
+
rl.close();
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* CLI core: parse argv, run, render.
|
|
552
|
+
*
|
|
553
|
+
* @param {string[]} [argv]
|
|
554
|
+
* @param {{ runImpl?: typeof runCleanWorktrees, write?: (text: string) => void, isTTY?: boolean }} [deps]
|
|
555
|
+
* @returns {Promise<object|undefined>}
|
|
556
|
+
*/
|
|
557
|
+
export async function runCleanWorktreesCli(
|
|
558
|
+
argv = process.argv.slice(2),
|
|
559
|
+
{
|
|
560
|
+
runImpl = runCleanWorktrees,
|
|
561
|
+
write = writeStdout,
|
|
562
|
+
isTTY = process.stdin.isTTY === true,
|
|
563
|
+
} = {},
|
|
564
|
+
) {
|
|
565
|
+
const { values } = parseArgs({
|
|
566
|
+
args: argv,
|
|
567
|
+
options: {
|
|
568
|
+
execute: { type: 'boolean', default: false },
|
|
569
|
+
yes: { type: 'boolean', default: false },
|
|
570
|
+
json: { type: 'boolean', default: false },
|
|
571
|
+
cwd: { type: 'string' },
|
|
572
|
+
help: { type: 'boolean', short: 'h' },
|
|
573
|
+
},
|
|
574
|
+
strict: true,
|
|
575
|
+
});
|
|
576
|
+
if (values.help) {
|
|
577
|
+
write(HELP);
|
|
578
|
+
return undefined;
|
|
579
|
+
}
|
|
580
|
+
const result = await runImpl({
|
|
581
|
+
cwd: values.cwd,
|
|
582
|
+
execute: values.execute,
|
|
583
|
+
yes: values.yes,
|
|
584
|
+
confirm: isTTY && !values.json ? promptConfirm : null,
|
|
585
|
+
});
|
|
586
|
+
write(values.json ? JSON.stringify(result, null, 2) : renderTable(result));
|
|
587
|
+
return result;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
runAsCli(import.meta.url, () => runCleanWorktreesCli(), {
|
|
591
|
+
source: 'clean-worktrees',
|
|
592
|
+
usage: HELP,
|
|
593
|
+
});
|