@webpieces/pr-gate 0.4.687 → 0.4.689

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.
@@ -6,10 +6,28 @@ const readline = tslib_1.__importStar(require("readline"));
6
6
  const rules_config_1 = require("@webpieces/rules-config");
7
7
  const inversify_1 = require("inversify");
8
8
  const worktree_cleanup_1 = require("./worktree-cleanup");
9
+ const cleanup_options_1 = require("./cleanup-options");
9
10
  const SEP = '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n';
11
+ // Singular / plural as a pair, because every one of these strings is read by a human deciding whether
12
+ // to delete something and `branch(s)` reads as a typo in the middle of exactly that sentence.
13
+ const BRANCH_KIND = 'branch';
14
+ const BRANCH_KINDS = 'branches';
15
+ const WORKTREE_KIND = 'worktree';
16
+ const WORKTREE_KINDS = 'worktrees';
17
+ // The verdicts that reap themselves — a merged PR, or a snapshot of a ref that still exists. Named
18
+ // here only so `--report` can SAY what a real run would take; the reaping itself is BranchReaper's.
19
+ const AUTO_REAPED_CLASSIFICATIONS = [
20
+ rules_config_1.CLASSIFICATION_MERGED_PR,
21
+ rules_config_1.CLASSIFICATION_BACKUP_OF_MERGED,
22
+ rules_config_1.CLASSIFICATION_BACKUP_OF_LIVE,
23
+ ];
10
24
  // One line of human-facing explanation per promptable classification, ordered most-safe first. These
11
25
  // replace the single string `no merged PR found — a human must decide`, which covered all three of
12
26
  // these situations identically and so told a human nothing they could act on.
27
+ //
28
+ // CLASSIFICATION_NO_COMMITS has no entry and needs none: a zero-commit ref never reaches this block
29
+ // any more — it is reaped on sight (see `husks`). A worktree spared for holding uncommitted work is
30
+ // reported by the worktree section, with that as its reason, rather than offered as a question.
13
31
  const CLASSIFICATION_HEADINGS = {
14
32
  [rules_config_1.CLASSIFICATION_SUPERSEDED]: 'SUPERSEDED — the PR was closed WITHOUT merging and later PRs have merged since. Near-certainly\n'
15
33
  + ' the abandoned first attempt at work that landed under a different number. Safest group to delete.',
@@ -17,41 +35,45 @@ const CLASSIFICATION_HEADINGS = {
17
35
  + ' The work is not unique to this branch; only the commit objects are.',
18
36
  [rules_config_1.CLASSIFICATION_NEVER_PROPOSED]: 'NEVER PROPOSED — no PR was ever opened, and these commits may be the ONLY copy in existence.\n'
19
37
  + ' Read the unique-commit counts before answering. This is the group to say no to if unsure.',
20
- [rules_config_1.CLASSIFICATION_NO_COMMITS]: 'NO COMMITS YET — identical to origin/main, so deleting the REF loses nothing. But this is also\n'
21
- + ' exactly what a worktree looks like while somebody is still working in it and has not committed,\n'
22
- + ' so it is asked about rather than reaped. Say no to any of these you (or an agent) are using.',
23
38
  };
24
39
  /**
25
- * wp-cleanup: remove the dead WORKTREES, delete the local branches whose PR is already MERGED, then ASK
26
- * about the ones that are merely probably-dead.
40
+ * wp-cleanup: remove the dead WORKTREES, delete the local branches whose PR is already MERGED, REAP
41
+ * THE ZERO-COMMIT HUSKS, then report everything that is left with the exact command that takes it.
27
42
  *
28
- * A merged PR is the ONLY proof that reaps anything unattended. "Holds no commits" used to be a second
29
- * proof and no longer is: it is equally the signature of a worktree an agent is working in right now,
30
- * so it moved into the group this command ASKS about.
43
+ * ─── WHY A ZERO-COMMIT REF IS REAPED AND NOT ASKED ABOUT ─────────────────────────────────────────
44
+ * A ref with 0 unique commits is byte-identical to origin/main. Deleting it loses a NAME, not a
45
+ * commit and the name comes back with one `git checkout -b`, or from the archive tag this writes
46
+ * first. It was previously PROMPTED about, for one real reason: a WORKING TREE looks exactly like
47
+ * that husk from `git worktree add -b` until its first commit, i.e. for exactly the window an agent
48
+ * is working in it. But that case is DETECTABLE, and detecting it is cheaper than asking a human:
31
49
  *
32
- * WHY WORKTREES ARE PART OF THIS: they were the half that never got reaped. The verdicts existed —
33
- * merged-branches.ts has been writing a full `DeletableWorktree[]` into the cache all along and their
34
- * only consumer used them to BLOCK the next `git worktree add`, never to remove anything. Meanwhile a
35
- * live worktree pins its branch, so the branch was spared too. Two things the tooling could PROVE were
36
- * dead, accumulating forever, until the guard refused to create the next branch and the only remedy it
37
- * could offer was loosening its own cap. See WorktreeCleanupSection and WorktreeReaper.
50
+ * · the worktree holds uncommitted or untracked files (`git status --porcelain`) spared, said out loud
51
+ * · the worktree is LOCKED by a live holder (a lock reason naming something present, or a claude
52
+ * agent whose pid is still running) spared by the verdicts before it ever reaches here
53
+ * · it is the tree we are standing in, or a detached HEAD spared, likewise
38
54
  *
39
- * WHY a named command instead of the `git branch -D a b c` the guards used to print: an AI agent
40
- * reads a raw `-D` as destructive, so it asks permission and stops which is exactly why branches
41
- * piled up despite the tooling knowing precisely which ones were dead. `pnpm wp-cleanup` is one
42
- * boring, allowlistable verb whose safety is a property of the command itself rather than of the
43
- * agent's judgement about a git flag.
55
+ * So the bar moved from "prove it is dead" to "prove somebody is holding it", deliberately, because
56
+ * the cost of being wrong is a re-`checkout -b` and the cost of being cautious was a prompt that
57
+ * stopped a human's terminal to adjudicate two refs that could not possibly hold work. Refs that DO
58
+ * carry unique commits keep every bit of the old caution.
44
59
  *
45
- * WHY IT NOW PROMPTS: sparing silently was the other half of the same problem. Every spared branch
46
- * reported the identical `no merged PR found a human must decide`, so the human could not decide,
47
- * so nothing got deleted, so the pile grew until branch-creation-guard refused to make the next branch
48
- * and an agent went looking for a config knob to loosen. Shown a real classification with unique-commit
49
- * counts, the human in that session answered in five words: "these should all be delete branches".
50
- * The prompt is cheap because archiving happens FIRST — a yes costs a tag, not the history.
60
+ * ─── WHY FLAGS, AND WHY THEY BEAT THE TTY SNIFF ──────────────────────────────────────────────────
61
+ * `process.stdin.isTTY !== true` used to be the whole basis for "is a human standing here?", and it
62
+ * is a proxy, not a fact: a human running `pnpm wp-cleanup | tee log` has no tty, an agent on a pty
63
+ * has one. The sniff stays as the DEFAULT selector; `--delete-branches` / `--delete-worktrees` /
64
+ * `--interactive` / `--report` let the caller who KNOWS say so, and an explicit flag always wins.
51
65
  *
52
- * All the danger still lives in the verdicts, not here see BranchReaper for why every AUTOMATICALLY
53
- * deleted branch is provably dead and recoverable, and note that nothing in the prompted group is ever
54
- * deleted without an explicit typed answer.
66
+ * ─── WHY A NON-TTY RUN NOW DELETES NOTHING IT WAS NOT ASKED TO ───────────────────────────────────
67
+ * It used to silently take the "redundant" groups and print a bare list of names it left. That is
68
+ * strictly less than the human sees, and — worse — it broke the one invariant that makes numeric
69
+ * selection safe: THE NUMBERS IN THE FLAG ARE THE NUMBERS JUST PRINTED. A run that deletes half of a
70
+ * numbered list renumbers the rest, so the `--delete-branches=3,4` it printed would land on entries
71
+ * 5 and 6 next time. So the unattended path now prints the IDENTICAL numbered, classified block a
72
+ * human sees plus the exact command that takes it, and takes nothing from that block itself. The
73
+ * husks it does reap are never IN the block, so reaping them cannot shift a single number.
74
+ *
75
+ * All the danger still lives in the verdicts, not here — see BranchReaper for why every automatically
76
+ * deleted branch is recoverable, and note that EVERY delete on every path here archives first.
55
77
  */
56
78
  let CleanupCommand = class CleanupCommand {
57
79
  repoRootFinder;
@@ -71,39 +93,102 @@ let CleanupCommand = class CleanupCommand {
71
93
  * next one. Reaping the worktree takes its branch with it, and the branch pass then recomputes its
72
94
  * verdicts from scratch against the post-removal truth.
73
95
  */
74
- async run() {
96
+ async run(options) {
75
97
  const repoRoot = this.repoRootFinder.resolveRepoRoot(process.cwd());
76
98
  const retention = (0, rules_config_1.loadAndValidate)(repoRoot).prGate.landPr.branchRetention;
77
- await this.cleanUpWorktrees(repoRoot, retention);
78
- await this.cleanUpBranches(repoRoot, retention);
99
+ if (options.report) {
100
+ this.reportOnly(repoRoot, retention);
101
+ return;
102
+ }
103
+ await this.cleanUpWorktrees(repoRoot, retention, options);
104
+ await this.cleanUpBranches(repoRoot, retention, options);
79
105
  }
80
- async cleanUpBranches(repoRoot, retention) {
106
+ /**
107
+ * `--report`: the whole classified picture, and NOT ONE DELETE.
108
+ *
109
+ * This is the only run whose numbers are guaranteed still valid when the next command starts,
110
+ * because a run that deletes nothing cannot renumber anything. That is what makes it the honest
111
+ * first half of `--report` → read the numbers → `--delete-branches=1,3`.
112
+ */
113
+ reportOnly(repoRoot, retention) {
114
+ process.stdout.write('\n' + SEP + '🔎 wp-cleanup --report — nothing will be deleted\n' + SEP);
115
+ const verdicts = this.worktreeSection.verdicts(repoRoot);
116
+ const deadTrees = this.worktreeSection.provablyDead(verdicts);
117
+ process.stdout.write(this.wouldReapBlock(WORKTREE_KINDS, deadTrees.map((tree) => `${tree.path} — ${tree.reason}`)));
118
+ const treePromptable = this.worktreeSection.promptable(verdicts);
119
+ const treeHusks = this.husks(treePromptable);
120
+ process.stdout.write(this.wouldReapBlock(`zero-commit ${WORKTREE_KINDS}`, treeHusks.map((tree) => `${tree.path} [${tree.branch}]`)));
121
+ process.stdout.write(this.worktreeSection.sparedBlock(verdicts, deadTrees));
122
+ const treeBlock = this.rest(treePromptable, treeHusks);
123
+ if (treeBlock.length > 0) {
124
+ process.stdout.write(this.worktreeSection.promptBlock(treeBlock));
125
+ process.stdout.write(this.flagHint(WORKTREE_KINDS, cleanup_options_1.FLAG_DELETE_WORKTREES, treeBlock.length));
126
+ }
127
+ // Retention 'keep' turns BranchReaper.reap into a pure verdict computation — every branch
128
+ // lands in `spared`, nothing is touched. That is exactly what a report needs, and it means
129
+ // the classified block below is computed by the identical code path a real run uses, so the
130
+ // numbering it prints IS the numbering the flag will act on.
131
+ const branches = this.branchReaper.reap(repoRoot, 'wp-cleanup', null, rules_config_1.BRANCH_RETENTION_KEEP).spared;
132
+ process.stdout.write(this.wouldReapBlock(BRANCH_KINDS, branches
133
+ .filter((entry) => AUTO_REAPED_CLASSIFICATIONS.includes(entry.classification))
134
+ .map((entry) => `${entry.branch} — ${entry.reason}`)));
135
+ const promptable = this.promptable(branches);
136
+ const husks = this.husks(promptable);
137
+ process.stdout.write(this.wouldReapBlock(`zero-commit ${BRANCH_KINDS}`, husks.map((entry) => entry.branch)));
138
+ const block = this.rest(promptable, husks);
139
+ if (block.length > 0) {
140
+ process.stdout.write(this.classifiedBlock(block));
141
+ process.stdout.write(this.flagHint(BRANCH_KINDS, cleanup_options_1.FLAG_DELETE_BRANCHES, block.length));
142
+ }
143
+ process.stdout.write(`\nNothing was deleted (--report). Retention policy in effect: ${retention}.\n`
144
+ + 'Re-run without --report to act, or with the flags above to say exactly what to take.\n');
145
+ }
146
+ wouldReapBlock(kinds, lines) {
147
+ if (lines.length === 0)
148
+ return '';
149
+ let out = `\nWould reap ${String(lines.length)} ${kinds} — no question asked, each archived first:\n`;
150
+ for (const line of lines)
151
+ out += ` ✓ ${line}\n`;
152
+ return out;
153
+ }
154
+ async cleanUpBranches(repoRoot, retention, options) {
81
155
  // No cache argument: wp-cleanup recomputes the verdicts itself. The file on disk is allowed to
82
156
  // go stale, and stale evidence is fine for BLOCKING but never for DELETING.
83
157
  const result = this.branchReaper.reap(repoRoot, 'wp-cleanup', null, retention);
84
- process.stdout.write(this.report(result));
158
+ const first = this.report(result);
159
+ process.stdout.write(first);
85
160
  const promptable = this.promptable(result.spared);
86
- if (promptable.length === 0)
161
+ const husks = this.husks(promptable);
162
+ const block = this.rest(promptable, husks);
163
+ // Range-check the flag against the block BEFORE anything in this half is deleted, so a caller
164
+ // holding stale numbers stops the run rather than half-executing it.
165
+ options.branches.pick(block);
166
+ if (husks.length > 0) {
167
+ process.stdout.write(this.huskBlock(BRANCH_KINDS, husks.map((entry) => entry.branch)));
168
+ process.stdout.write(this.report(this.branchReaper.reapApproved(repoRoot, 'wp-cleanup', husks, retention)));
169
+ }
170
+ if (block.length === 0) {
171
+ if (first === '' && husks.length === 0)
172
+ process.stdout.write(this.nothingToDo());
87
173
  return;
88
- process.stdout.write(this.classifiedBlock(promptable));
89
- const approved = await this.askWhichToDelete(promptable, 'branch');
174
+ }
175
+ process.stdout.write(this.classifiedBlock(block));
176
+ const approved = await this.decide(block, BRANCH_KIND, BRANCH_KINDS, cleanup_options_1.FLAG_DELETE_BRANCHES, options.branches, options);
90
177
  if (approved.length === 0) {
91
- process.stdout.write('\nNothing deleted — the branches above were kept.\n');
178
+ process.stdout.write(`\nNothing else deleted — the ${String(block.length)} ${BRANCH_KINDS} above were kept.\n`);
92
179
  return;
93
180
  }
94
- const second = this.branchReaper.reapApproved(repoRoot, 'wp-cleanup', approved, retention);
95
- process.stdout.write(this.report(second));
181
+ process.stdout.write(this.report(this.branchReaper.reapApproved(repoRoot, 'wp-cleanup', approved, retention)));
96
182
  }
97
183
  /**
98
- * Reap the provably-dead worktrees, then ASK about the probably-dead ones the same two-tier
99
- * posture the branch half has, because it is the same verdict on the same branch.
184
+ * Reap the provably-dead worktrees and the zero-commit husks, then decide about the rest.
100
185
  *
101
186
  * WorktreeReaper enforces the safety rails regardless of what is passed or answered: never the
102
187
  * primary clone, never the tree this command is running in, and never `--force` (git's refusal to
103
188
  * remove a worktree holding untracked or modified files is a feature, and forcing it is how a
104
189
  * cleanup command becomes a data-loss command).
105
190
  */
106
- async cleanUpWorktrees(repoRoot, retention) {
191
+ async cleanUpWorktrees(repoRoot, retention, options) {
107
192
  const verdicts = this.worktreeSection.verdicts(repoRoot);
108
193
  const dead = this.worktreeSection.provablyDead(verdicts);
109
194
  if (dead.length > 0) {
@@ -111,21 +196,46 @@ let CleanupCommand = class CleanupCommand {
111
196
  }
112
197
  process.stdout.write(this.worktreeSection.sparedBlock(verdicts, dead));
113
198
  const promptable = this.worktreeSection.promptable(verdicts);
114
- if (promptable.length === 0)
199
+ // A zero-commit worktree is a husk ONLY if nobody is holding it. Uncommitted or untracked
200
+ // files are the one thing that says "somebody is", and they are the one thing no archive tag
201
+ // can bring back — so they are checked here rather than left to git's refusal at removal time.
202
+ const husks = this.worktreeSection.withoutUncommitted(this.husks(promptable));
203
+ const block = this.rest(promptable, husks);
204
+ options.worktrees.pick(block);
205
+ if (husks.length > 0) {
206
+ process.stdout.write(this.huskBlock(WORKTREE_KINDS, husks.map((tree) => `${tree.path} [${tree.branch}]`)));
207
+ process.stdout.write(this.worktreeSection.report(this.worktreeSection.reap(repoRoot, 'wp-cleanup', husks, retention)));
208
+ }
209
+ if (block.length === 0)
115
210
  return;
116
- process.stdout.write(this.worktreeSection.promptBlock(promptable));
117
- const approved = await this.askWhichToDelete(promptable, 'worktree');
211
+ process.stdout.write(this.worktreeSection.promptBlock(block));
212
+ const approved = await this.decide(block, WORKTREE_KIND, WORKTREE_KINDS, cleanup_options_1.FLAG_DELETE_WORKTREES, options.worktrees, options);
118
213
  if (approved.length === 0) {
119
- process.stdout.write('\nNothing removed — the worktrees above were kept.\n');
214
+ process.stdout.write(`\nNothing else removed — the ${String(block.length)} ${WORKTREE_KINDS} above were kept.\n`);
120
215
  return;
121
216
  }
122
217
  process.stdout.write(this.worktreeSection.report(this.worktreeSection.reap(repoRoot, 'wp-cleanup', approved, retention)));
123
218
  }
219
+ // What a bare run with an empty repo says. It points at --help because the flags are the whole
220
+ // reason a caller who DOES have something to say never has to be prompted.
221
+ nothingToDo() {
222
+ return '\nNothing else to decide. `pnpm wp-cleanup --help` lists every flag '
223
+ + '(--report, --delete-branches, --delete-worktrees, --interactive).\n';
224
+ }
225
+ huskBlock(kinds, names) {
226
+ let out = '\n' + SEP
227
+ + `♻️ Reaping ${String(names.length)} zero-commit ${kinds} — 0 unique commits, identical to origin/main\n`
228
+ + SEP + '\n'
229
+ + 'Nothing is held here: no uncommitted files, no live lock, not the tree you are standing in.\n'
230
+ + 'Deleting one of these loses a NAME, not a commit — and the name is archived to a tag first.\n\n';
231
+ for (const name of names)
232
+ out += ` · ${name}\n`;
233
+ return out;
234
+ }
124
235
  report(result) {
125
236
  const gone = result.alreadyGone.length;
126
- if (result.reaped.length === 0 && result.failed.length === 0 && gone === 0) {
127
- return '\n✅ Nothing to clean up — no local branch is provably dead.\n';
128
- }
237
+ if (result.reaped.length === 0 && result.failed.length === 0 && gone === 0)
238
+ return '';
129
239
  let out = '\n' + SEP + `🧹 Cleaned up ${String(result.reaped.length)} dead local branch(es)\n` + SEP + '\n';
130
240
  for (const entry of result.reaped)
131
241
  out += this.reapedLine(entry);
@@ -174,7 +284,7 @@ let CleanupCommand = class CleanupCommand {
174
284
  */
175
285
  promptable(spared) {
176
286
  const out = [];
177
- for (const classification of rules_config_1.PROMPTABLE_CLASSIFICATIONS) {
287
+ for (const classification of rules_config_1.ADJUDICATED_CLASSIFICATIONS) {
178
288
  for (const entry of spared) {
179
289
  if (entry.classification === classification)
180
290
  out.push(entry);
@@ -182,6 +292,24 @@ let CleanupCommand = class CleanupCommand {
182
292
  }
183
293
  return out;
184
294
  }
295
+ /**
296
+ * The zero-commit husks out of a promptable list — reaped by default, and never numbered.
297
+ *
298
+ * Keeping them OUT of the numbered block is what keeps `--delete-branches=1,3` honest: whatever
299
+ * this reaps, the entries the caller can name are the same entries in the same order as on the
300
+ * `--report` run that printed those numbers.
301
+ *
302
+ * THE CLASSIFICATION TOKEN IS THE ONLY SPELLING OF "husk". It would read as belt-and-braces to
303
+ * also accept `commits === 0` here, and it is the opposite: MergedBranchesService.classify tests
304
+ * commits===0 BEFORE anything else, so a second test can only ever disagree with the verdict — and
305
+ * disagreeing in the widening direction, auto-reaping something no classification called a husk.
306
+ */
307
+ husks(adjudicated) {
308
+ return adjudicated.filter((entry) => entry.classification === rules_config_1.CLASSIFICATION_NO_COMMITS);
309
+ }
310
+ rest(promptable, husks) {
311
+ return promptable.filter((entry) => !husks.includes(entry));
312
+ }
185
313
  // The classification table: what each group means, and per branch its unique-commit count — the one
186
314
  // number that says whether a yes costs nothing or costs the only copy of somebody's work.
187
315
  classifiedBlock(promptable) {
@@ -199,59 +327,59 @@ let CleanupCommand = class CleanupCommand {
199
327
  return out;
200
328
  }
201
329
  /**
202
- * Ask which of the classified branches to delete. Answers: `all`, `none` (default), or a
203
- * comma/space-separated list of the numbers shown.
330
+ * Who decides about the numbered block, in priority order:
204
331
  *
205
- * NON-INTERACTIVE (no TTYCI, a hook, an AGENT'S piped shell) takes everything except
206
- * NEVER PROPOSED, and says exactly what it took.
332
+ * 1. an explicit `--delete-branches` / `--delete-worktrees`the caller said so, and a caller who
333
+ * said so is never overruled by what stdin happens to be attached to;
334
+ * 2. a terminal, or `--interactive` — ask;
335
+ * 3. otherwise — take NOTHING, and print the exact command that takes it.
207
336
  *
208
- * It used to take nothing at all, on the reasoning that "a prompt nobody can see must never be
209
- * read as consent". Correct about consent, wrong about who is standing there: the overwhelmingly
210
- * common non-TTY caller is an agent that was TOLD to clean up, and for it the prompt was a dead
211
- * end — wp-cleanup deleted nothing, the branch cap still blocked, and the only path left was to
212
- * hand the human a table of branches to adjudicate. The human's response to receiving one of
213
- * those: "I am tired of AI asking me to cleanup things it can do by itself".
214
- *
215
- * So consent is honoured where it is actually needed rather than everywhere. NEVER PROPOSED is
216
- * the group whose commits may be the only copy in existence; it is never taken unattended and is
217
- * the one thing left to ask about. The rest — a closed-unmerged PR superseded by later merges,
218
- * commits already patch-equivalent in main, a ref identical to origin/main — are redundant by
219
- * evidence, and every one of them is archived to an `archive/<date>/<branch>` tag with a
220
- * `recover=` command logged before it is touched. An interactive human still gets the full
221
- * prompt, including NEVER PROPOSED.
337
+ * (3) is the case an agent lands in, and it is deliberately empty-handed. See the class comment:
338
+ * a run that deletes part of a numbered list invalidates the numbers it just printed, and those
339
+ * numbers are the whole interface the next run is given.
222
340
  */
223
- async askWhichToDelete(promptable, kind) {
224
- if (process.stdin.isTTY !== true)
225
- return this.unattendedPicks(promptable, kind);
341
+ async decide(block, kind, kinds, flag, selection, options) {
342
+ if (selection.given()) {
343
+ const picked = selection.pick(block);
344
+ process.stdout.write(`\n${flag} chose ${String(picked.length)} of the ${String(block.length)} `
345
+ + `${kinds} above.\n`);
346
+ if (picked.length < block.length)
347
+ process.stdout.write(this.flagHint(kinds, flag, block.length));
348
+ return picked;
349
+ }
350
+ if (!options.prompts()) {
351
+ process.stdout.write(this.flagHint(kinds, flag, block.length));
352
+ return [];
353
+ }
226
354
  const answer = (await this.question(`\nDelete which ${kind}(s)? [all / none / e.g. "1,3"] (default none): `)).trim().toLowerCase();
227
355
  if (answer === '' || answer === 'none' || answer === 'n')
228
356
  return [];
229
357
  if (answer === 'all' || answer === 'a')
230
- return promptable;
231
- return this.pickByNumber(promptable, answer);
358
+ return block;
359
+ return this.pickByNumber(block, answer);
232
360
  }
233
361
  /**
234
- * What an unattended run takes: everything promptable except NEVER PROPOSED (see above).
362
+ * The exact command that takes what this run left the half a non-tty caller never used to get.
235
363
  *
236
- * Prints the split either way, because a cleanup that deletes silently is a cleanup nobody can
237
- * audit and the leftover NEVER PROPOSED names are exactly the short question an agent should
238
- * put to a human, in place of the six-branch table this replaced.
364
+ * It states the numbering contract out loud because a shifted number is the one way this command
365
+ * can delete the wrong ref, and the caller reading this is usually a program.
239
366
  */
240
- unattendedPicks(promptable, kind) {
241
- const taken = promptable.filter((entry) => entry.classification !== rules_config_1.CLASSIFICATION_NEVER_PROPOSED);
242
- const left = promptable.filter((entry) => entry.classification === rules_config_1.CLASSIFICATION_NEVER_PROPOSED);
243
- process.stdout.write(`\nNot a terminal, so no prompt — taking the ${String(taken.length)} redundant `
244
- + `${kind}(s) above (each archived to a tag first, every delete recoverable).\n`);
245
- if (left.length > 0) {
246
- // Both verdict classes carry `branch` a worktree's is the branch it holds.
247
- const names = left.map((entry) => entry.branch || '(detached)').join(', ');
248
- process.stdout.write(`Left alone NEVER PROPOSED, may be the only copy: ${names}\n`
249
- + 'Ask a human about those specifically; do not delete them yourself.\n');
250
- }
251
- return taken;
367
+ flagHint(kinds, flag, count) {
368
+ return `\nLeft for you to decide (${String(count)} ${kinds}, numbered above).\n`
369
+ + 'To delete them yourself, re-run with:\n'
370
+ + ` pnpm wp-cleanup ${flag}=all # every one listed above\n`
371
+ + ` pnpm wp-cleanup ${flag}=1,3 # just those, BY THE NUMBERS PRINTED ABOVE\n`
372
+ + ` pnpm wp-cleanup ${flag}=none # say no out loud\n`
373
+ + 'Those numbers are the numbers of THIS run. wp-cleanup renumbers from a fresh scan every\n'
374
+ + 'time, so once anything in the list is deleted, re-run `pnpm wp-cleanup --report` (which\n'
375
+ + 'deletes nothing) and read the new numbers before passing numbers again.\n'
376
+ + 'Every delete is archived to a tag first; `recover=` lines land in '
377
+ + '.webpieces/logs/branch-mutations.log\n';
252
378
  }
253
- // Parse `1,3` / `1 3` into branches, ignoring anything out of range. An unparseable answer selects
254
- // nothing, which is the fail-safe direction for a question about deleting.
379
+ // Parse `1,3` / `1 3` from a HUMAN at the prompt, ignoring anything out of range. An unparseable
380
+ // answer selects nothing, which is the fail-safe direction for a typed answer to a delete question.
381
+ // The `--delete-*` flags deliberately do NOT behave this way: a program passing an out-of-range
382
+ // number is holding stale numbers, and that stops the run (see DeleteSelection.pick).
255
383
  pickByNumber(promptable, answer) {
256
384
  const out = [];
257
385
  for (const token of answer.split(/[\s,]+/)) {
@@ -1 +1 @@
1
- {"version":3,"file":"cleanup-command.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/pr-gate/src/scripts/commands/cleanup-command.ts"],"names":[],"mappings":"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","sourcesContent":["import * as readline from 'readline';\nimport {\n BranchArchiver,\n BranchReaper,\n DeletableBranch,\n DeletableWorktree,\n ReapResult,\n ReapedBranch,\n RepoRootFinder,\n loadAndValidate,\n CLASSIFICATION_SUPERSEDED,\n CLASSIFICATION_CONTENT_IN_MAIN,\n CLASSIFICATION_NEVER_PROPOSED,\n CLASSIFICATION_NO_COMMITS,\n PROMPTABLE_CLASSIFICATIONS,\n} from '@webpieces/rules-config';\nimport { injectable, bindingScopeValues } from 'inversify';\n\nimport { WorktreeCleanupSection } from './worktree-cleanup';\n\nconst SEP = '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\\n';\n\n// One line of human-facing explanation per promptable classification, ordered most-safe first. These\n// replace the single string `no merged PR found — a human must decide`, which covered all three of\n// these situations identically and so told a human nothing they could act on.\nconst CLASSIFICATION_HEADINGS: Readonly<Record<string, string>> = {\n [CLASSIFICATION_SUPERSEDED]:\n 'SUPERSEDED — the PR was closed WITHOUT merging and later PRs have merged since. Near-certainly\\n'\n + ' the abandoned first attempt at work that landed under a different number. Safest group to delete.',\n [CLASSIFICATION_CONTENT_IN_MAIN]:\n 'CONTENT ALREADY IN MAIN — every commit has a patch-equivalent in origin/main (git cherry).\\n'\n + ' The work is not unique to this branch; only the commit objects are.',\n [CLASSIFICATION_NEVER_PROPOSED]:\n 'NEVER PROPOSED — no PR was ever opened, and these commits may be the ONLY copy in existence.\\n'\n + ' Read the unique-commit counts before answering. This is the group to say no to if unsure.',\n [CLASSIFICATION_NO_COMMITS]:\n 'NO COMMITS YET — identical to origin/main, so deleting the REF loses nothing. But this is also\\n'\n + ' exactly what a worktree looks like while somebody is still working in it and has not committed,\\n'\n + ' so it is asked about rather than reaped. Say no to any of these you (or an agent) are using.',\n};\n\n/**\n * wp-cleanup: remove the dead WORKTREES, delete the local branches whose PR is already MERGED, then ASK\n * about the ones that are merely probably-dead.\n *\n * A merged PR is the ONLY proof that reaps anything unattended. \"Holds no commits\" used to be a second\n * proof and no longer is: it is equally the signature of a worktree an agent is working in right now,\n * so it moved into the group this command ASKS about.\n *\n * WHY WORKTREES ARE PART OF THIS: they were the half that never got reaped. The verdicts existed —\n * merged-branches.ts has been writing a full `DeletableWorktree[]` into the cache all along — and their\n * only consumer used them to BLOCK the next `git worktree add`, never to remove anything. Meanwhile a\n * live worktree pins its branch, so the branch was spared too. Two things the tooling could PROVE were\n * dead, accumulating forever, until the guard refused to create the next branch and the only remedy it\n * could offer was loosening its own cap. See WorktreeCleanupSection and WorktreeReaper.\n *\n * WHY a named command instead of the `git branch -D a b c` the guards used to print: an AI agent\n * reads a raw `-D` as destructive, so it asks permission and stops — which is exactly why branches\n * piled up despite the tooling knowing precisely which ones were dead. `pnpm wp-cleanup` is one\n * boring, allowlistable verb whose safety is a property of the command itself rather than of the\n * agent's judgement about a git flag.\n *\n * WHY IT NOW PROMPTS: sparing silently was the other half of the same problem. Every spared branch\n * reported the identical `no merged PR found — a human must decide`, so the human could not decide,\n * so nothing got deleted, so the pile grew until branch-creation-guard refused to make the next branch\n * and an agent went looking for a config knob to loosen. Shown a real classification with unique-commit\n * counts, the human in that session answered in five words: \"these should all be delete branches\".\n * The prompt is cheap because archiving happens FIRST — a yes costs a tag, not the history.\n *\n * All the danger still lives in the verdicts, not here — see BranchReaper for why every AUTOMATICALLY\n * deleted branch is provably dead and recoverable, and note that nothing in the prompted group is ever\n * deleted without an explicit typed answer.\n */\n@injectable(bindingScopeValues.Singleton)\nexport class CleanupCommand {\n constructor(\n private readonly repoRootFinder: RepoRootFinder,\n private readonly branchReaper: BranchReaper,\n private readonly archiver: BranchArchiver,\n private readonly worktreeSection: WorktreeCleanupSection,\n ) {}\n\n /**\n * WORKTREES FIRST, then branches. The order is the fix, not a detail: a worktree HOLDS its branch,\n * so that branch is spared `in-use` (\"remove that worktree before deleting the branch\") and nothing\n * used to remove the worktree — so both piled up until branch-creation-guard refused to make the\n * next one. Reaping the worktree takes its branch with it, and the branch pass then recomputes its\n * verdicts from scratch against the post-removal truth.\n */\n async run(): Promise<void> {\n const repoRoot = this.repoRootFinder.resolveRepoRoot(process.cwd());\n const retention = loadAndValidate(repoRoot).prGate.landPr.branchRetention;\n await this.cleanUpWorktrees(repoRoot, retention);\n await this.cleanUpBranches(repoRoot, retention);\n }\n\n private async cleanUpBranches(repoRoot: string, retention: string): Promise<void> {\n // No cache argument: wp-cleanup recomputes the verdicts itself. The file on disk is allowed to\n // go stale, and stale evidence is fine for BLOCKING but never for DELETING.\n const result = this.branchReaper.reap(repoRoot, 'wp-cleanup', null, retention);\n process.stdout.write(this.report(result));\n\n const promptable = this.promptable(result.spared);\n if (promptable.length === 0) return;\n process.stdout.write(this.classifiedBlock(promptable));\n const approved = await this.askWhichToDelete(promptable, 'branch');\n if (approved.length === 0) {\n process.stdout.write('\\nNothing deleted — the branches above were kept.\\n');\n return;\n }\n const second = this.branchReaper.reapApproved(repoRoot, 'wp-cleanup', approved, retention);\n process.stdout.write(this.report(second));\n }\n\n /**\n * Reap the provably-dead worktrees, then ASK about the probably-dead ones — the same two-tier\n * posture the branch half has, because it is the same verdict on the same branch.\n *\n * WorktreeReaper enforces the safety rails regardless of what is passed or answered: never the\n * primary clone, never the tree this command is running in, and never `--force` (git's refusal to\n * remove a worktree holding untracked or modified files is a feature, and forcing it is how a\n * cleanup command becomes a data-loss command).\n */\n private async cleanUpWorktrees(repoRoot: string, retention: string): Promise<void> {\n const verdicts = this.worktreeSection.verdicts(repoRoot);\n const dead = this.worktreeSection.provablyDead(verdicts);\n if (dead.length > 0) {\n process.stdout.write(\n this.worktreeSection.report(\n this.worktreeSection.reap(repoRoot, 'wp-cleanup', dead, retention)));\n }\n process.stdout.write(this.worktreeSection.sparedBlock(verdicts, dead));\n\n const promptable = this.worktreeSection.promptable(verdicts);\n if (promptable.length === 0) return;\n process.stdout.write(this.worktreeSection.promptBlock(promptable));\n const approved = await this.askWhichToDelete(promptable, 'worktree');\n if (approved.length === 0) {\n process.stdout.write('\\nNothing removed — the worktrees above were kept.\\n');\n return;\n }\n process.stdout.write(\n this.worktreeSection.report(\n this.worktreeSection.reap(repoRoot, 'wp-cleanup', approved, retention)));\n }\n\n private report(result: ReapResult): string {\n const gone = result.alreadyGone.length;\n if (result.reaped.length === 0 && result.failed.length === 0 && gone === 0) {\n return '\\n✅ Nothing to clean up — no local branch is provably dead.\\n';\n }\n\n let out = '\\n' + SEP + `🧹 Cleaned up ${String(result.reaped.length)} dead local branch(es)\\n` + SEP + '\\n';\n for (const entry of result.reaped) out += this.reapedLine(entry);\n\n // Stated, not warned about. These branches ARE gone — the detached refresher's auto-reap\n // deleted and archived them moments before this pass reached them, which it is entitled to do\n // (it acts on its own fresh verdicts, and the hooks start it on the same commands you run\n // wp-cleanup from). Before this line they came out under \"⚠️ N branch(es) could not be\n // deleted\", which reads as work left undone and sends a human hunting for branches that no\n // longer exist.\n if (gone > 0) {\n const names = result.alreadyGone.map((entry: ReapedBranch): string => entry.branch).join(', ');\n out += `\\nℹ️ ${String(gone)} branch(es) were already gone — a concurrent auto-reap deleted and\\n`\n + ` archived them first, so there was nothing left to do: ${names}\\n`;\n }\n\n if (result.failed.length > 0) {\n out += `\\n⚠️ ${String(result.failed.length)} branch(es) could not be deleted:\\n`;\n for (const entry of result.failed) out += ` ✗ ${entry.branch} — ${entry.error}\\n`;\n }\n\n // Printed even on success: a deletion the human cannot undo is a deletion they have to trust\n // blindly, and the whole argument for auto-cleanup is that they never have to.\n out += '\\nEvery deletion is logged with its pre-delete SHA in .webpieces/logs/branch-mutations.log —\\n'\n + 'recover any of them with the `recover=` command on its line.\\n';\n return out;\n }\n\n private reapedLine(entry: ReapedBranch): string {\n const sha = entry.sha !== '' ? ` (was ${entry.sha.slice(0, 8)})` : '';\n // The archive tag is printed inline because it is the ONE thing that makes this delete casually\n // reversible — a name a human can type, rather than a sha they have to go dig out of a log.\n const archived = entry.archiveTag !== ''\n ? `\\n archived → ${entry.archiveTag} (restore: ${this.archiver.restoreCommand(entry.branch, entry.archiveTag)})`\n : '';\n return ` ✓ ${entry.branch}${sha} — ${entry.reason}${archived}\\n`;\n }\n\n /**\n * The spared branches a human can meaningfully rule on, grouped and ordered most-safe first.\n *\n * Branches spared as IN_USE — checked out in a worktree — are still excluded here, but the reason\n * is no longer \"git would simply refuse\". That premise died the moment worktrees became reapable.\n * The real reason is the ORDER in run(): the worktree pass has already run, so an IN_USE branch is\n * one of exactly two things. Either its worktree was dead and the reap took the branch with it (so\n * it is not in this list at all), or its worktree is one we are deliberately keeping — locked, held\n * open by uncommitted work, or the one we are standing in — and offering to delete the branch out\n * from under a live checkout is not a question worth asking. The pair is offered TOGETHER by the\n * worktree prompt, which shows both the path and the branch it holds, or it is not offered at all.\n */\n private promptable(spared: DeletableBranch[]): DeletableBranch[] {\n const out: DeletableBranch[] = [];\n for (const classification of PROMPTABLE_CLASSIFICATIONS) {\n for (const entry of spared) {\n if (entry.classification === classification) out.push(entry);\n }\n }\n return out;\n }\n\n // The classification table: what each group means, and per branch its unique-commit count — the one\n // number that says whether a yes costs nothing or costs the only copy of somebody's work.\n private classifiedBlock(promptable: DeletableBranch[]): string {\n let out = '\\n' + SEP + `🤔 ${String(promptable.length)} branch(es) are probably dead — your call\\n` + SEP;\n let current = '';\n for (let i = 0; i < promptable.length; i += 1) {\n const entry = promptable[i];\n if (entry.classification !== current) {\n current = entry.classification;\n out += `\\n${CLASSIFICATION_HEADINGS[current] ?? current}\\n\\n`;\n }\n const commits = entry.commits >= 0 ? `${String(entry.commits)} unique commit(s)` : 'unique commits unknown';\n out += ` [${String(i + 1)}] ${entry.branch}\\n ${commits} — ${entry.reason}\\n`;\n }\n return out;\n }\n\n /**\n * Ask which of the classified branches to delete. Answers: `all`, `none` (default), or a\n * comma/space-separated list of the numbers shown.\n *\n * NON-INTERACTIVE (no TTY — CI, a hook, an AGENT'S piped shell) takes everything except\n * NEVER PROPOSED, and says exactly what it took.\n *\n * It used to take nothing at all, on the reasoning that \"a prompt nobody can see must never be\n * read as consent\". Correct about consent, wrong about who is standing there: the overwhelmingly\n * common non-TTY caller is an agent that was TOLD to clean up, and for it the prompt was a dead\n * end — wp-cleanup deleted nothing, the branch cap still blocked, and the only path left was to\n * hand the human a table of branches to adjudicate. The human's response to receiving one of\n * those: \"I am tired of AI asking me to cleanup things it can do by itself\".\n *\n * So consent is honoured where it is actually needed rather than everywhere. NEVER PROPOSED is\n * the group whose commits may be the only copy in existence; it is never taken unattended and is\n * the one thing left to ask about. The rest — a closed-unmerged PR superseded by later merges,\n * commits already patch-equivalent in main, a ref identical to origin/main — are redundant by\n * evidence, and every one of them is archived to an `archive/<date>/<branch>` tag with a\n * `recover=` command logged before it is touched. An interactive human still gets the full\n * prompt, including NEVER PROPOSED.\n */\n private async askWhichToDelete<T extends DeletableBranch | DeletableWorktree>(\n promptable: T[], kind: string,\n ): Promise<T[]> {\n if (process.stdin.isTTY !== true) return this.unattendedPicks(promptable, kind);\n const answer = (await this.question(\n `\\nDelete which ${kind}(s)? [all / none / e.g. \"1,3\"] (default none): `)).trim().toLowerCase();\n if (answer === '' || answer === 'none' || answer === 'n') return [];\n if (answer === 'all' || answer === 'a') return promptable;\n return this.pickByNumber(promptable, answer);\n }\n\n /**\n * What an unattended run takes: everything promptable except NEVER PROPOSED (see above).\n *\n * Prints the split either way, because a cleanup that deletes silently is a cleanup nobody can\n * audit — and the leftover NEVER PROPOSED names are exactly the short question an agent should\n * put to a human, in place of the six-branch table this replaced.\n */\n private unattendedPicks<T extends DeletableBranch | DeletableWorktree>(\n promptable: T[], kind: string,\n ): T[] {\n const taken = promptable.filter(\n (entry: T): boolean => entry.classification !== CLASSIFICATION_NEVER_PROPOSED);\n const left = promptable.filter(\n (entry: T): boolean => entry.classification === CLASSIFICATION_NEVER_PROPOSED);\n\n process.stdout.write(`\\nNot a terminal, so no prompt — taking the ${String(taken.length)} redundant `\n + `${kind}(s) above (each archived to a tag first, every delete recoverable).\\n`);\n if (left.length > 0) {\n // Both verdict classes carry `branch` — a worktree's is the branch it holds.\n const names = left.map((entry: T): string => entry.branch || '(detached)').join(', ');\n process.stdout.write(\n `Left alone — NEVER PROPOSED, may be the only copy: ${names}\\n`\n + 'Ask a human about those specifically; do not delete them yourself.\\n');\n }\n return taken;\n }\n\n // Parse `1,3` / `1 3` into branches, ignoring anything out of range. An unparseable answer selects\n // nothing, which is the fail-safe direction for a question about deleting.\n private pickByNumber<T extends DeletableBranch | DeletableWorktree>(promptable: T[], answer: string): T[] {\n const out: T[] = [];\n for (const token of answer.split(/[\\s,]+/)) {\n const index = Number(token);\n if (!Number.isInteger(index) || index < 1 || index > promptable.length) continue;\n out.push(promptable[index - 1]);\n }\n return out;\n }\n\n // Seam: overridden in the spec so the prompt parsing is testable with no terminal.\n protected question(prompt: string): Promise<string> {\n const rl = readline.createInterface({ input: process.stdin, output: process.stdout });\n return new Promise<string>((resolve: (value: string) => void): void => {\n rl.question(prompt, (answer: string): void => {\n rl.close();\n resolve(answer);\n });\n });\n }\n}\n"]}
1
+ {"version":3,"file":"cleanup-command.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/pr-gate/src/scripts/commands/cleanup-command.ts"],"names":[],"mappings":"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","sourcesContent":["import * as readline from 'readline';\nimport {\n BranchArchiver,\n BranchReaper,\n DeletableBranch,\n DeletableWorktree,\n ReapResult,\n ReapedBranch,\n RepoRootFinder,\n loadAndValidate,\n BRANCH_RETENTION_KEEP,\n CLASSIFICATION_SUPERSEDED,\n CLASSIFICATION_CONTENT_IN_MAIN,\n CLASSIFICATION_NEVER_PROPOSED,\n CLASSIFICATION_NO_COMMITS,\n CLASSIFICATION_MERGED_PR,\n CLASSIFICATION_BACKUP_OF_MERGED,\n CLASSIFICATION_BACKUP_OF_LIVE,\n ADJUDICATED_CLASSIFICATIONS,\n} from '@webpieces/rules-config';\nimport { injectable, bindingScopeValues } from 'inversify';\n\nimport { WorktreeCleanupSection } from './worktree-cleanup';\nimport {\n CleanupOptions,\n DeleteSelection,\n FLAG_DELETE_BRANCHES,\n FLAG_DELETE_WORKTREES,\n} from './cleanup-options';\n\nconst SEP = '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\\n';\n\n// Singular / plural as a pair, because every one of these strings is read by a human deciding whether\n// to delete something and `branch(s)` reads as a typo in the middle of exactly that sentence.\nconst BRANCH_KIND = 'branch';\nconst BRANCH_KINDS = 'branches';\nconst WORKTREE_KIND = 'worktree';\nconst WORKTREE_KINDS = 'worktrees';\n\n// The verdicts that reap themselves — a merged PR, or a snapshot of a ref that still exists. Named\n// here only so `--report` can SAY what a real run would take; the reaping itself is BranchReaper's.\nconst AUTO_REAPED_CLASSIFICATIONS: readonly string[] = [\n CLASSIFICATION_MERGED_PR,\n CLASSIFICATION_BACKUP_OF_MERGED,\n CLASSIFICATION_BACKUP_OF_LIVE,\n];\n\n// One line of human-facing explanation per promptable classification, ordered most-safe first. These\n// replace the single string `no merged PR found — a human must decide`, which covered all three of\n// these situations identically and so told a human nothing they could act on.\n//\n// CLASSIFICATION_NO_COMMITS has no entry and needs none: a zero-commit ref never reaches this block\n// any more — it is reaped on sight (see `husks`). A worktree spared for holding uncommitted work is\n// reported by the worktree section, with that as its reason, rather than offered as a question.\nconst CLASSIFICATION_HEADINGS: Readonly<Record<string, string>> = {\n [CLASSIFICATION_SUPERSEDED]:\n 'SUPERSEDED — the PR was closed WITHOUT merging and later PRs have merged since. Near-certainly\\n'\n + ' the abandoned first attempt at work that landed under a different number. Safest group to delete.',\n [CLASSIFICATION_CONTENT_IN_MAIN]:\n 'CONTENT ALREADY IN MAIN — every commit has a patch-equivalent in origin/main (git cherry).\\n'\n + ' The work is not unique to this branch; only the commit objects are.',\n [CLASSIFICATION_NEVER_PROPOSED]:\n 'NEVER PROPOSED — no PR was ever opened, and these commits may be the ONLY copy in existence.\\n'\n + ' Read the unique-commit counts before answering. This is the group to say no to if unsure.',\n};\n\n/**\n * wp-cleanup: remove the dead WORKTREES, delete the local branches whose PR is already MERGED, REAP\n * THE ZERO-COMMIT HUSKS, then report everything that is left with the exact command that takes it.\n *\n * ─── WHY A ZERO-COMMIT REF IS REAPED AND NOT ASKED ABOUT ─────────────────────────────────────────\n * A ref with 0 unique commits is byte-identical to origin/main. Deleting it loses a NAME, not a\n * commit — and the name comes back with one `git checkout -b`, or from the archive tag this writes\n * first. It was previously PROMPTED about, for one real reason: a WORKING TREE looks exactly like\n * that husk from `git worktree add -b` until its first commit, i.e. for exactly the window an agent\n * is working in it. But that case is DETECTABLE, and detecting it is cheaper than asking a human:\n *\n * · the worktree holds uncommitted or untracked files (`git status --porcelain`) — spared, said out loud\n * · the worktree is LOCKED by a live holder (a lock reason naming something present, or a claude\n * agent whose pid is still running) — spared by the verdicts before it ever reaches here\n * · it is the tree we are standing in, or a detached HEAD — spared, likewise\n *\n * So the bar moved from \"prove it is dead\" to \"prove somebody is holding it\", deliberately, because\n * the cost of being wrong is a re-`checkout -b` and the cost of being cautious was a prompt that\n * stopped a human's terminal to adjudicate two refs that could not possibly hold work. Refs that DO\n * carry unique commits keep every bit of the old caution.\n *\n * ─── WHY FLAGS, AND WHY THEY BEAT THE TTY SNIFF ──────────────────────────────────────────────────\n * `process.stdin.isTTY !== true` used to be the whole basis for \"is a human standing here?\", and it\n * is a proxy, not a fact: a human running `pnpm wp-cleanup | tee log` has no tty, an agent on a pty\n * has one. The sniff stays as the DEFAULT selector; `--delete-branches` / `--delete-worktrees` /\n * `--interactive` / `--report` let the caller who KNOWS say so, and an explicit flag always wins.\n *\n * ─── WHY A NON-TTY RUN NOW DELETES NOTHING IT WAS NOT ASKED TO ───────────────────────────────────\n * It used to silently take the \"redundant\" groups and print a bare list of names it left. That is\n * strictly less than the human sees, and — worse — it broke the one invariant that makes numeric\n * selection safe: THE NUMBERS IN THE FLAG ARE THE NUMBERS JUST PRINTED. A run that deletes half of a\n * numbered list renumbers the rest, so the `--delete-branches=3,4` it printed would land on entries\n * 5 and 6 next time. So the unattended path now prints the IDENTICAL numbered, classified block a\n * human sees plus the exact command that takes it, and takes nothing from that block itself. The\n * husks it does reap are never IN the block, so reaping them cannot shift a single number.\n *\n * All the danger still lives in the verdicts, not here — see BranchReaper for why every automatically\n * deleted branch is recoverable, and note that EVERY delete on every path here archives first.\n */\n@injectable(bindingScopeValues.Singleton)\nexport class CleanupCommand {\n constructor(\n private readonly repoRootFinder: RepoRootFinder,\n private readonly branchReaper: BranchReaper,\n private readonly archiver: BranchArchiver,\n private readonly worktreeSection: WorktreeCleanupSection,\n ) {}\n\n /**\n * WORKTREES FIRST, then branches. The order is the fix, not a detail: a worktree HOLDS its branch,\n * so that branch is spared `in-use` (\"remove that worktree before deleting the branch\") and nothing\n * used to remove the worktree — so both piled up until branch-creation-guard refused to make the\n * next one. Reaping the worktree takes its branch with it, and the branch pass then recomputes its\n * verdicts from scratch against the post-removal truth.\n */\n async run(options: CleanupOptions): Promise<void> {\n const repoRoot = this.repoRootFinder.resolveRepoRoot(process.cwd());\n const retention = loadAndValidate(repoRoot).prGate.landPr.branchRetention;\n if (options.report) {\n this.reportOnly(repoRoot, retention);\n return;\n }\n await this.cleanUpWorktrees(repoRoot, retention, options);\n await this.cleanUpBranches(repoRoot, retention, options);\n }\n\n /**\n * `--report`: the whole classified picture, and NOT ONE DELETE.\n *\n * This is the only run whose numbers are guaranteed still valid when the next command starts,\n * because a run that deletes nothing cannot renumber anything. That is what makes it the honest\n * first half of `--report` → read the numbers → `--delete-branches=1,3`.\n */\n private reportOnly(repoRoot: string, retention: string): void {\n process.stdout.write('\\n' + SEP + '🔎 wp-cleanup --report — nothing will be deleted\\n' + SEP);\n\n const verdicts = this.worktreeSection.verdicts(repoRoot);\n const deadTrees = this.worktreeSection.provablyDead(verdicts);\n process.stdout.write(this.wouldReapBlock(\n WORKTREE_KINDS, deadTrees.map((tree: DeletableWorktree): string => `${tree.path} — ${tree.reason}`)));\n const treePromptable = this.worktreeSection.promptable(verdicts);\n const treeHusks = this.husks(treePromptable);\n process.stdout.write(this.wouldReapBlock(`zero-commit ${WORKTREE_KINDS}`,\n treeHusks.map((tree: DeletableWorktree): string => `${tree.path} [${tree.branch}]`)));\n process.stdout.write(this.worktreeSection.sparedBlock(verdicts, deadTrees));\n const treeBlock = this.rest(treePromptable, treeHusks);\n if (treeBlock.length > 0) {\n process.stdout.write(this.worktreeSection.promptBlock(treeBlock));\n process.stdout.write(this.flagHint(WORKTREE_KINDS, FLAG_DELETE_WORKTREES, treeBlock.length));\n }\n\n // Retention 'keep' turns BranchReaper.reap into a pure verdict computation — every branch\n // lands in `spared`, nothing is touched. That is exactly what a report needs, and it means\n // the classified block below is computed by the identical code path a real run uses, so the\n // numbering it prints IS the numbering the flag will act on.\n const branches = this.branchReaper.reap(repoRoot, 'wp-cleanup', null, BRANCH_RETENTION_KEEP).spared;\n process.stdout.write(this.wouldReapBlock(BRANCH_KINDS, branches\n .filter((entry: DeletableBranch): boolean =>\n AUTO_REAPED_CLASSIFICATIONS.includes(entry.classification))\n .map((entry: DeletableBranch): string => `${entry.branch} — ${entry.reason}`)));\n const promptable = this.promptable(branches);\n const husks = this.husks(promptable);\n process.stdout.write(this.wouldReapBlock(`zero-commit ${BRANCH_KINDS}`,\n husks.map((entry: DeletableBranch): string => entry.branch)));\n const block = this.rest(promptable, husks);\n if (block.length > 0) {\n process.stdout.write(this.classifiedBlock(block));\n process.stdout.write(this.flagHint(BRANCH_KINDS, FLAG_DELETE_BRANCHES, block.length));\n }\n process.stdout.write(`\\nNothing was deleted (--report). Retention policy in effect: ${retention}.\\n`\n + 'Re-run without --report to act, or with the flags above to say exactly what to take.\\n');\n }\n\n private wouldReapBlock(kinds: string, lines: string[]): string {\n if (lines.length === 0) return '';\n let out = `\\nWould reap ${String(lines.length)} ${kinds} — no question asked, each archived first:\\n`;\n for (const line of lines) out += ` ✓ ${line}\\n`;\n return out;\n }\n\n private async cleanUpBranches(repoRoot: string, retention: string, options: CleanupOptions): Promise<void> {\n // No cache argument: wp-cleanup recomputes the verdicts itself. The file on disk is allowed to\n // go stale, and stale evidence is fine for BLOCKING but never for DELETING.\n const result = this.branchReaper.reap(repoRoot, 'wp-cleanup', null, retention);\n const first = this.report(result);\n process.stdout.write(first);\n\n const promptable = this.promptable(result.spared);\n const husks = this.husks(promptable);\n const block = this.rest(promptable, husks);\n\n // Range-check the flag against the block BEFORE anything in this half is deleted, so a caller\n // holding stale numbers stops the run rather than half-executing it.\n options.branches.pick(block);\n\n if (husks.length > 0) {\n process.stdout.write(this.huskBlock(BRANCH_KINDS, husks.map(\n (entry: DeletableBranch): string => entry.branch)));\n process.stdout.write(this.report(\n this.branchReaper.reapApproved(repoRoot, 'wp-cleanup', husks, retention)));\n }\n\n if (block.length === 0) {\n if (first === '' && husks.length === 0) process.stdout.write(this.nothingToDo());\n return;\n }\n process.stdout.write(this.classifiedBlock(block));\n const approved = await this.decide(\n block, BRANCH_KIND, BRANCH_KINDS, FLAG_DELETE_BRANCHES, options.branches, options);\n if (approved.length === 0) {\n process.stdout.write(\n `\\nNothing else deleted — the ${String(block.length)} ${BRANCH_KINDS} above were kept.\\n`);\n return;\n }\n process.stdout.write(this.report(\n this.branchReaper.reapApproved(repoRoot, 'wp-cleanup', approved, retention)));\n }\n\n /**\n * Reap the provably-dead worktrees and the zero-commit husks, then decide about the rest.\n *\n * WorktreeReaper enforces the safety rails regardless of what is passed or answered: never the\n * primary clone, never the tree this command is running in, and never `--force` (git's refusal to\n * remove a worktree holding untracked or modified files is a feature, and forcing it is how a\n * cleanup command becomes a data-loss command).\n */\n private async cleanUpWorktrees(repoRoot: string, retention: string, options: CleanupOptions): Promise<void> {\n const verdicts = this.worktreeSection.verdicts(repoRoot);\n const dead = this.worktreeSection.provablyDead(verdicts);\n if (dead.length > 0) {\n process.stdout.write(\n this.worktreeSection.report(\n this.worktreeSection.reap(repoRoot, 'wp-cleanup', dead, retention)));\n }\n process.stdout.write(this.worktreeSection.sparedBlock(verdicts, dead));\n\n const promptable = this.worktreeSection.promptable(verdicts);\n // A zero-commit worktree is a husk ONLY if nobody is holding it. Uncommitted or untracked\n // files are the one thing that says \"somebody is\", and they are the one thing no archive tag\n // can bring back — so they are checked here rather than left to git's refusal at removal time.\n const husks = this.worktreeSection.withoutUncommitted(this.husks(promptable));\n const block = this.rest(promptable, husks);\n\n options.worktrees.pick(block);\n\n if (husks.length > 0) {\n process.stdout.write(this.huskBlock(WORKTREE_KINDS, husks.map(\n (tree: DeletableWorktree): string => `${tree.path} [${tree.branch}]`)));\n process.stdout.write(\n this.worktreeSection.report(\n this.worktreeSection.reap(repoRoot, 'wp-cleanup', husks, retention)));\n }\n\n if (block.length === 0) return;\n process.stdout.write(this.worktreeSection.promptBlock(block));\n const approved = await this.decide(\n block, WORKTREE_KIND, WORKTREE_KINDS, FLAG_DELETE_WORKTREES, options.worktrees, options);\n if (approved.length === 0) {\n process.stdout.write(\n `\\nNothing else removed — the ${String(block.length)} ${WORKTREE_KINDS} above were kept.\\n`);\n return;\n }\n process.stdout.write(\n this.worktreeSection.report(\n this.worktreeSection.reap(repoRoot, 'wp-cleanup', approved, retention)));\n }\n\n // What a bare run with an empty repo says. It points at --help because the flags are the whole\n // reason a caller who DOES have something to say never has to be prompted.\n private nothingToDo(): string {\n return '\\nNothing else to decide. `pnpm wp-cleanup --help` lists every flag '\n + '(--report, --delete-branches, --delete-worktrees, --interactive).\\n';\n }\n\n private huskBlock(kinds: string, names: string[]): string {\n let out = '\\n' + SEP\n + `♻️ Reaping ${String(names.length)} zero-commit ${kinds} — 0 unique commits, identical to origin/main\\n`\n + SEP + '\\n'\n + 'Nothing is held here: no uncommitted files, no live lock, not the tree you are standing in.\\n'\n + 'Deleting one of these loses a NAME, not a commit — and the name is archived to a tag first.\\n\\n';\n for (const name of names) out += ` · ${name}\\n`;\n return out;\n }\n\n private report(result: ReapResult): string {\n const gone = result.alreadyGone.length;\n if (result.reaped.length === 0 && result.failed.length === 0 && gone === 0) return '';\n\n let out = '\\n' + SEP + `🧹 Cleaned up ${String(result.reaped.length)} dead local branch(es)\\n` + SEP + '\\n';\n for (const entry of result.reaped) out += this.reapedLine(entry);\n\n // Stated, not warned about. These branches ARE gone — the detached refresher's auto-reap\n // deleted and archived them moments before this pass reached them, which it is entitled to do\n // (it acts on its own fresh verdicts, and the hooks start it on the same commands you run\n // wp-cleanup from). Before this line they came out under \"⚠️ N branch(es) could not be\n // deleted\", which reads as work left undone and sends a human hunting for branches that no\n // longer exist.\n if (gone > 0) {\n const names = result.alreadyGone.map((entry: ReapedBranch): string => entry.branch).join(', ');\n out += `\\nℹ️ ${String(gone)} branch(es) were already gone — a concurrent auto-reap deleted and\\n`\n + ` archived them first, so there was nothing left to do: ${names}\\n`;\n }\n\n if (result.failed.length > 0) {\n out += `\\n⚠️ ${String(result.failed.length)} branch(es) could not be deleted:\\n`;\n for (const entry of result.failed) out += ` ✗ ${entry.branch} — ${entry.error}\\n`;\n }\n\n // Printed even on success: a deletion the human cannot undo is a deletion they have to trust\n // blindly, and the whole argument for auto-cleanup is that they never have to.\n out += '\\nEvery deletion is logged with its pre-delete SHA in .webpieces/logs/branch-mutations.log —\\n'\n + 'recover any of them with the `recover=` command on its line.\\n';\n return out;\n }\n\n private reapedLine(entry: ReapedBranch): string {\n const sha = entry.sha !== '' ? ` (was ${entry.sha.slice(0, 8)})` : '';\n // The archive tag is printed inline because it is the ONE thing that makes this delete casually\n // reversible — a name a human can type, rather than a sha they have to go dig out of a log.\n const archived = entry.archiveTag !== ''\n ? `\\n archived → ${entry.archiveTag} (restore: ${this.archiver.restoreCommand(entry.branch, entry.archiveTag)})`\n : '';\n return ` ✓ ${entry.branch}${sha} — ${entry.reason}${archived}\\n`;\n }\n\n /**\n * The spared branches a human can meaningfully rule on, grouped and ordered most-safe first.\n *\n * Branches spared as IN_USE — checked out in a worktree — are still excluded here, but the reason\n * is no longer \"git would simply refuse\". That premise died the moment worktrees became reapable.\n * The real reason is the ORDER in run(): the worktree pass has already run, so an IN_USE branch is\n * one of exactly two things. Either its worktree was dead and the reap took the branch with it (so\n * it is not in this list at all), or its worktree is one we are deliberately keeping — locked, held\n * open by uncommitted work, or the one we are standing in — and offering to delete the branch out\n * from under a live checkout is not a question worth asking. The pair is offered TOGETHER by the\n * worktree prompt, which shows both the path and the branch it holds, or it is not offered at all.\n */\n private promptable(spared: DeletableBranch[]): DeletableBranch[] {\n const out: DeletableBranch[] = [];\n for (const classification of ADJUDICATED_CLASSIFICATIONS) {\n for (const entry of spared) {\n if (entry.classification === classification) out.push(entry);\n }\n }\n return out;\n }\n\n /**\n * The zero-commit husks out of a promptable list — reaped by default, and never numbered.\n *\n * Keeping them OUT of the numbered block is what keeps `--delete-branches=1,3` honest: whatever\n * this reaps, the entries the caller can name are the same entries in the same order as on the\n * `--report` run that printed those numbers.\n *\n * THE CLASSIFICATION TOKEN IS THE ONLY SPELLING OF \"husk\". It would read as belt-and-braces to\n * also accept `commits === 0` here, and it is the opposite: MergedBranchesService.classify tests\n * commits===0 BEFORE anything else, so a second test can only ever disagree with the verdict — and\n * disagreeing in the widening direction, auto-reaping something no classification called a husk.\n */\n private husks<T extends DeletableBranch | DeletableWorktree>(adjudicated: T[]): T[] {\n return adjudicated.filter(\n (entry: T): boolean => entry.classification === CLASSIFICATION_NO_COMMITS);\n }\n\n private rest<T extends DeletableBranch | DeletableWorktree>(promptable: T[], husks: T[]): T[] {\n return promptable.filter((entry: T): boolean => !husks.includes(entry));\n }\n\n // The classification table: what each group means, and per branch its unique-commit count — the one\n // number that says whether a yes costs nothing or costs the only copy of somebody's work.\n private classifiedBlock(promptable: DeletableBranch[]): string {\n let out = '\\n' + SEP + `🤔 ${String(promptable.length)} branch(es) are probably dead — your call\\n` + SEP;\n let current = '';\n for (let i = 0; i < promptable.length; i += 1) {\n const entry = promptable[i];\n if (entry.classification !== current) {\n current = entry.classification;\n out += `\\n${CLASSIFICATION_HEADINGS[current] ?? current}\\n\\n`;\n }\n const commits = entry.commits >= 0 ? `${String(entry.commits)} unique commit(s)` : 'unique commits unknown';\n out += ` [${String(i + 1)}] ${entry.branch}\\n ${commits} — ${entry.reason}\\n`;\n }\n return out;\n }\n\n /**\n * Who decides about the numbered block, in priority order:\n *\n * 1. an explicit `--delete-branches` / `--delete-worktrees` — the caller said so, and a caller who\n * said so is never overruled by what stdin happens to be attached to;\n * 2. a terminal, or `--interactive` — ask;\n * 3. otherwise — take NOTHING, and print the exact command that takes it.\n *\n * (3) is the case an agent lands in, and it is deliberately empty-handed. See the class comment:\n * a run that deletes part of a numbered list invalidates the numbers it just printed, and those\n * numbers are the whole interface the next run is given.\n */\n private async decide<T extends DeletableBranch | DeletableWorktree>(\n block: T[], kind: string, kinds: string, flag: string, selection: DeleteSelection,\n options: CleanupOptions,\n ): Promise<T[]> {\n if (selection.given()) {\n const picked = selection.pick(block);\n process.stdout.write(`\\n${flag} chose ${String(picked.length)} of the ${String(block.length)} `\n + `${kinds} above.\\n`);\n if (picked.length < block.length) process.stdout.write(this.flagHint(kinds, flag, block.length));\n return picked;\n }\n if (!options.prompts()) {\n process.stdout.write(this.flagHint(kinds, flag, block.length));\n return [];\n }\n const answer = (await this.question(\n `\\nDelete which ${kind}(s)? [all / none / e.g. \"1,3\"] (default none): `)).trim().toLowerCase();\n if (answer === '' || answer === 'none' || answer === 'n') return [];\n if (answer === 'all' || answer === 'a') return block;\n return this.pickByNumber(block, answer);\n }\n\n /**\n * The exact command that takes what this run left — the half a non-tty caller never used to get.\n *\n * It states the numbering contract out loud because a shifted number is the one way this command\n * can delete the wrong ref, and the caller reading this is usually a program.\n */\n private flagHint(kinds: string, flag: string, count: number): string {\n return `\\nLeft for you to decide (${String(count)} ${kinds}, numbered above).\\n`\n + 'To delete them yourself, re-run with:\\n'\n + ` pnpm wp-cleanup ${flag}=all # every one listed above\\n`\n + ` pnpm wp-cleanup ${flag}=1,3 # just those, BY THE NUMBERS PRINTED ABOVE\\n`\n + ` pnpm wp-cleanup ${flag}=none # say no out loud\\n`\n + 'Those numbers are the numbers of THIS run. wp-cleanup renumbers from a fresh scan every\\n'\n + 'time, so once anything in the list is deleted, re-run `pnpm wp-cleanup --report` (which\\n'\n + 'deletes nothing) and read the new numbers before passing numbers again.\\n'\n + 'Every delete is archived to a tag first; `recover=` lines land in '\n + '.webpieces/logs/branch-mutations.log\\n';\n }\n\n // Parse `1,3` / `1 3` from a HUMAN at the prompt, ignoring anything out of range. An unparseable\n // answer selects nothing, which is the fail-safe direction for a typed answer to a delete question.\n // The `--delete-*` flags deliberately do NOT behave this way: a program passing an out-of-range\n // number is holding stale numbers, and that stops the run (see DeleteSelection.pick).\n private pickByNumber<T extends DeletableBranch | DeletableWorktree>(promptable: T[], answer: string): T[] {\n const out: T[] = [];\n for (const token of answer.split(/[\\s,]+/)) {\n const index = Number(token);\n if (!Number.isInteger(index) || index < 1 || index > promptable.length) continue;\n out.push(promptable[index - 1]);\n }\n return out;\n }\n\n // Seam: overridden in the spec so the prompt parsing is testable with no terminal.\n protected question(prompt: string): Promise<string> {\n const rl = readline.createInterface({ input: process.stdin, output: process.stdout });\n return new Promise<string>((resolve: (value: string) => void): void => {\n rl.question(prompt, (answer: string): void => {\n rl.close();\n resolve(answer);\n });\n });\n }\n}\n"]}