@webpieces/rules-config 0.4.571 → 0.4.573

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webpieces/rules-config",
3
- "version": "0.4.571",
3
+ "version": "0.4.573",
4
4
  "description": "Shared webpieces.config.json loader. Single source of truth for validation rule configuration consumed by @webpieces/ai-hook-rules, @webpieces/code-rules, and @webpieces/nx-webpieces-rules.",
5
5
  "type": "commonjs",
6
6
  "main": "./src/index.js",
@@ -31,6 +31,20 @@ export declare class ReapedBranch {
31
31
  * reflog expiry. Field-with-default so every existing `new ReapedBranch(...)` call site still builds.
32
32
  */
33
33
  archiveTag: string;
34
+ /**
35
+ * The branch was ALREADY GONE when we reached it — someone else reaped it first.
36
+ *
37
+ * Not a failure, and the distinction is not cosmetic. `wp-cleanup` and the detached refresher's
38
+ * `auto-reap` both act on their own fresh verdicts, and the hooks kick off the refresher on the
39
+ * same commands a human runs wp-cleanup from, so the two race by design. Observed 2026-08-04:
40
+ * auto-reap deleted both dead branches at 14:30:58.207 and 14:30:59.035, wp-cleanup reached them
41
+ * 300ms later and reported "⚠️ 2 branch(es) could not be deleted — could not archive it first:
42
+ * unknown revision", i.e. it announced a failure for the exact outcome it wanted. Whoever wins,
43
+ * the branch is gone, archived and logged by the winner.
44
+ *
45
+ * Field-with-default so every existing `new ReapedBranch(...)` call site still builds.
46
+ */
47
+ alreadyGone: boolean;
34
48
  constructor(branch: string, sha: string, reason: string, pr: number, ok: boolean, error: string);
35
49
  }
36
50
  /**
@@ -42,7 +56,16 @@ export declare class ReapResult {
42
56
  reaped: ReapedBranch[];
43
57
  failed: ReapedBranch[];
44
58
  spared: DeletableBranch[];
45
- constructor(reaped: ReapedBranch[], failed: ReapedBranch[], spared: DeletableBranch[]);
59
+ /**
60
+ * Branches a CONCURRENT reaper got to first (see ReapedBranch.alreadyGone). A separate bucket
61
+ * because they belong in neither of the other two: counting them as `reaped` would credit this
62
+ * run with a delete and an archive tag it did not write, and counting them as `failed` is the bug
63
+ * this fixes — a red warning about branches that are, in fact, gone.
64
+ *
65
+ * Optional so every existing `new ReapResult(...)` call site still builds.
66
+ */
67
+ alreadyGone: ReapedBranch[];
68
+ constructor(reaped: ReapedBranch[], failed: ReapedBranch[], spared: DeletableBranch[], alreadyGone?: ReapedBranch[]);
46
69
  }
47
70
  export declare class BranchReaper {
48
71
  private readonly mergedBranches;
@@ -78,6 +101,14 @@ export declare class BranchReaper {
78
101
  * branch that survives one more cleanup cycle.
79
102
  */
80
103
  private deleteOne;
104
+ /**
105
+ * The branch vanished between the verdict and the reap — a concurrent reaper got it.
106
+ *
107
+ * Logged like every other outcome (a mutation nobody logs is a mutation nobody can audit), but
108
+ * as ALREADY_GONE rather than SKIPPED/FAILED, and carried in its own bucket so the report can
109
+ * say "already gone" instead of warning about a branch that is exactly as dead as intended.
110
+ */
111
+ private alreadyGone;
81
112
  private archiveFailed;
82
113
  /**
83
114
  * Write the verdicts back with the reaped branches removed, so the branch-creation-guard's cap
@@ -41,6 +41,20 @@ class ReapedBranch {
41
41
  * reflog expiry. Field-with-default so every existing `new ReapedBranch(...)` call site still builds.
42
42
  */
43
43
  archiveTag = '';
44
+ /**
45
+ * The branch was ALREADY GONE when we reached it — someone else reaped it first.
46
+ *
47
+ * Not a failure, and the distinction is not cosmetic. `wp-cleanup` and the detached refresher's
48
+ * `auto-reap` both act on their own fresh verdicts, and the hooks kick off the refresher on the
49
+ * same commands a human runs wp-cleanup from, so the two race by design. Observed 2026-08-04:
50
+ * auto-reap deleted both dead branches at 14:30:58.207 and 14:30:59.035, wp-cleanup reached them
51
+ * 300ms later and reported "⚠️ 2 branch(es) could not be deleted — could not archive it first:
52
+ * unknown revision", i.e. it announced a failure for the exact outcome it wanted. Whoever wins,
53
+ * the branch is gone, archived and logged by the winner.
54
+ *
55
+ * Field-with-default so every existing `new ReapedBranch(...)` call site still builds.
56
+ */
57
+ alreadyGone = false;
44
58
  // eslint-disable-next-line @typescript-eslint/max-params
45
59
  constructor(branch, sha, reason, pr, ok, error) {
46
60
  this.branch = branch;
@@ -61,10 +75,20 @@ class ReapResult {
61
75
  reaped;
62
76
  failed;
63
77
  spared;
64
- constructor(reaped, failed, spared) {
78
+ /**
79
+ * Branches a CONCURRENT reaper got to first (see ReapedBranch.alreadyGone). A separate bucket
80
+ * because they belong in neither of the other two: counting them as `reaped` would credit this
81
+ * run with a delete and an archive tag it did not write, and counting them as `failed` is the bug
82
+ * this fixes — a red warning about branches that are, in fact, gone.
83
+ *
84
+ * Optional so every existing `new ReapResult(...)` call site still builds.
85
+ */
86
+ alreadyGone;
87
+ constructor(reaped, failed, spared, alreadyGone = []) {
65
88
  this.reaped = reaped;
66
89
  this.failed = failed;
67
90
  this.spared = spared;
91
+ this.alreadyGone = alreadyGone;
68
92
  }
69
93
  }
70
94
  exports.ReapResult = ReapResult;
@@ -109,27 +133,35 @@ let BranchReaper = class BranchReaper {
109
133
  return new ReapResult([], [], approved);
110
134
  const reaped = [];
111
135
  const failed = [];
136
+ const alreadyGone = [];
112
137
  for (const entry of approved) {
113
138
  const outcome = this.deleteOne(repoRoot, verb, entry, retention);
114
139
  if (outcome.ok)
115
140
  reaped.push(outcome);
141
+ else if (outcome.alreadyGone)
142
+ alreadyGone.push(outcome);
116
143
  else
117
144
  failed.push(outcome);
118
145
  }
119
- return new ReapResult(reaped, failed, []);
146
+ return new ReapResult(reaped, failed, [], alreadyGone);
120
147
  }
121
148
  reapBranches(repoRoot, verb, targets, retention, verdicts) {
122
149
  const reaped = [];
123
150
  const failed = [];
151
+ const alreadyGone = [];
124
152
  for (const entry of targets) {
125
153
  const outcome = this.deleteOne(repoRoot, verb, entry, retention);
126
154
  if (outcome.ok)
127
155
  reaped.push(outcome);
156
+ else if (outcome.alreadyGone)
157
+ alreadyGone.push(outcome);
128
158
  else
129
159
  failed.push(outcome);
130
160
  }
161
+ // Only genuine failures stay in the cache's `deletable`. An already-gone branch is not there
162
+ // to delete on the next pass, and leaving it in would make the cap keep counting a ghost.
131
163
  this.rewriteCache(repoRoot, verdicts, failed);
132
- return new ReapResult(reaped, failed, verdicts.keep);
164
+ return new ReapResult(reaped, failed, verdicts.keep, alreadyGone);
133
165
  }
134
166
  /**
135
167
  * One branch: ARCHIVE the tip as a tag, then one `git branch -D`. Never the multi-name form the old
@@ -146,6 +178,12 @@ let BranchReaper = class BranchReaper {
146
178
  // audit line is that it records what was destroyed while it still exists.
147
179
  const resolved = this.capture(repoRoot, ['rev-parse', entry.branch]);
148
180
  const sha = resolved.ok ? resolved.out : '';
181
+ // It does not resolve ⇒ it is already gone, reaped by whoever won the race (see
182
+ // ReapedBranch.alreadyGone). Report that outcome honestly instead of letting it fall through
183
+ // to the archive, which fails on the same missing ref and calls a successful cleanup a
184
+ // failure. Nothing to archive and nothing to delete: the winner did both, and logged them.
185
+ if (!resolved.ok)
186
+ return this.alreadyGone(repoRoot, verb, entry);
149
187
  const archive = retention === branch_archiver_1.BRANCH_RETENTION_ARCHIVE_TAG
150
188
  ? this.archiver.archive(repoRoot, entry.branch)
151
189
  : new branch_archiver_1.ArchiveResult('', sha, true, '');
@@ -162,6 +200,22 @@ let BranchReaper = class BranchReaper {
162
200
  this.mutationLog.logBranchMutation(repoRoot, event);
163
201
  return result;
164
202
  }
203
+ /**
204
+ * The branch vanished between the verdict and the reap — a concurrent reaper got it.
205
+ *
206
+ * Logged like every other outcome (a mutation nobody logs is a mutation nobody can audit), but
207
+ * as ALREADY_GONE rather than SKIPPED/FAILED, and carried in its own bucket so the report can
208
+ * say "already gone" instead of warning about a branch that is exactly as dead as intended.
209
+ */
210
+ alreadyGone(repoRoot, verb, entry) {
211
+ const event = new branch_mutation_log_1.BranchMutationEvent(verb, 'REAP');
212
+ event.fromBranch = entry.branch;
213
+ event.outcome = 'ALREADY GONE (a concurrent reap deleted and archived it first)';
214
+ this.mutationLog.logBranchMutation(repoRoot, event);
215
+ const result = new ReapedBranch(entry.branch, '', entry.reason, entry.pr, false, 'already gone — a concurrent reap deleted and archived it first');
216
+ result.alreadyGone = true;
217
+ return result;
218
+ }
165
219
  // Archiving failed ⇒ the branch is NOT deleted. Reported as a failure with git's own words, so the
166
220
  // human sees a branch that survived and why, rather than a silent skip.
167
221
  archiveFailed(repoRoot, verb, entry, sha, archive) {
@@ -1 +1 @@
1
- {"version":3,"file":"branch-reaper.js","sourceRoot":"","sources":["../../../../../packages/tooling/rules-config/src/branch-reaper.ts"],"names":[],"mappings":"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","sourcesContent":["import { spawnSync } from 'child_process';\nimport { injectable, bindingScopeValues } from 'inversify';\n\nimport {\n ArchiveResult,\n BranchArchiver,\n BRANCH_RETENTION_ARCHIVE_TAG,\n BRANCH_RETENTION_KEEP,\n} from './branch-archiver';\nimport { BranchMutationEvent, BranchMutationLog, MutationVerb } from './branch-mutation-log';\nimport { DeletableBranch, MergedBranchesCache, MergedBranchesService } from './merged-branches';\n\n/**\n * The EXECUTOR for the dead-branch verdicts that merged-branches.ts computes.\n *\n * WHY this exists: for a long time nothing in the tooling ever deleted a branch. The reap was only\n * ever a `git branch -D a b c` STRING embedded in a guard's fix hint, handed to an AI agent — which\n * reads a raw `-D` as destructive, asks \"may I clean this up?\", and stops. So the verdicts were\n * computed correctly on every hook call and then acted on by nobody, and local branches grew without\n * bound. The fix is not better wording; it is a thing that does the deleting.\n *\n * WHY deleting here is safe: every entry in `deletable` earned its place by one of exactly three\n * proofs — a MERGED PR (the work is in main), a squash-merge BACKUP of a merged branch, or ZERO\n * commits of its own (there is no work). merged-branches.ts also guarantees `main` is never in the\n * list and that a branch checked out in ANY worktree lands in `keep` instead, so we can never delete\n * the branch someone is standing on. On top of that, every delete is logged with the branch's\n * pre-delete SHA and a literal recover command, so no reap is unrecoverable.\n */\n\n// Data-only (per CLAUDE.md, classes for data). One branch and what happened to it.\nexport class ReapedBranch {\n branch: string;\n // The commit the branch pointed at BEFORE deletion — captured first, precisely so a delete is\n // always undoable via `git branch <branch> <sha>`. Empty only if rev-parse itself failed.\n sha: string;\n reason: string;\n pr: number;\n ok: boolean;\n // git's own stderr when ok=false. Kept verbatim: a failed delete is a thing a human must read.\n error: string;\n /**\n * The `archive/<date>/<branch>` tag written immediately BEFORE the delete, or '' when the retention\n * policy is 'delete'. Non-empty means the branch is restorable by NAME rather than by remembering a\n * sha — `git checkout -b <branch> <tag>` restores the exact objects, and the tag survives `gc` and\n * reflog expiry. Field-with-default so every existing `new ReapedBranch(...)` call site still builds.\n */\n archiveTag: string = '';\n\n // eslint-disable-next-line @typescript-eslint/max-params\n constructor(branch: string, sha: string, reason: string, pr: number, ok: boolean, error: string) {\n this.branch = branch;\n this.sha = sha;\n this.reason = reason;\n this.pr = pr;\n this.ok = ok;\n this.error = error;\n }\n}\n\n/**\n * The outcome of one reap. `spared` is carried alongside deliberately: a cleanup that silently says\n * nothing about the branches it did NOT touch reads as \"there was nothing else\", when in fact those\n * are exactly the branches only a human can rule on.\n */\nexport class ReapResult {\n reaped: ReapedBranch[];\n failed: ReapedBranch[];\n spared: DeletableBranch[];\n\n constructor(reaped: ReapedBranch[], failed: ReapedBranch[], spared: DeletableBranch[]) {\n this.reaped = reaped;\n this.failed = failed;\n this.spared = spared;\n }\n}\n\n// Result of a captured git invocation. `err` carries stderr so a failed delete can be reported.\ninterface CmdCapture {\n ok: boolean;\n out: string;\n err: string;\n}\n\n@injectable(bindingScopeValues.Singleton)\nexport class BranchReaper {\n // Defaulted so the non-DI call sites (the detached refresher in sync-main.ts) can just\n // `new BranchReaper()`, while inversify still injects the singletons when resolved from a\n // container. Mirrors how MergedBranchesService defaults its WorktreeService.\n constructor(\n private readonly mergedBranches: MergedBranchesService = new MergedBranchesService(),\n private readonly mutationLog: BranchMutationLog = new BranchMutationLog(),\n private readonly archiver: BranchArchiver = new BranchArchiver(),\n ) {}\n\n /**\n * Delete every branch the verdicts call dead, one command at a time.\n *\n * `cache` is an ALREADY-FRESH set of verdicts (the refresher just computed them, so re-running\n * the `gh` lookup would be pure waste). Pass nothing — as `wp-cleanup` does — and we recompute\n * from scratch. That distinction is load-bearing: the cache file on disk is DELIBERATELY allowed\n * to go stale, which is fine for blocking a branch creation but is not fine for deleting, since a\n * branch may have gained commits since it was written. Deleting never reads the stale file.\n */\n reap(\n repoRoot: string,\n verb: MutationVerb,\n cache: MergedBranchesCache | null = null,\n retention: string = BRANCH_RETENTION_ARCHIVE_TAG,\n ): ReapResult {\n const verdicts = cache ?? this.mergedBranches.computeMergedBranches(repoRoot);\n // 'keep' means \"never delete anything\" — the reap becomes a pure report. Everything still lands\n // in `spared` so the human sees exactly what WOULD have been reaped under the other policies.\n if (retention === BRANCH_RETENTION_KEEP) {\n return new ReapResult([], [], [...verdicts.deletable, ...verdicts.keep]);\n }\n return this.reapBranches(repoRoot, verb, verdicts.deletable, retention, verdicts);\n }\n\n /**\n * Delete a CALLER-SUPPLIED list of branches — the branches a human just said yes to at wp-cleanup's\n * classification prompt. Separate from `reap` because these are NOT provably dead: they earned their\n * deletion from an explicit human answer, not from a verdict, so nothing here may ever run unattended.\n * Archiving still happens first, which is precisely what makes that yes low-stakes.\n */\n reapApproved(\n repoRoot: string,\n verb: MutationVerb,\n approved: DeletableBranch[],\n retention: string = BRANCH_RETENTION_ARCHIVE_TAG,\n ): ReapResult {\n if (retention === BRANCH_RETENTION_KEEP) return new ReapResult([], [], approved);\n const reaped: ReapedBranch[] = [];\n const failed: ReapedBranch[] = [];\n for (const entry of approved) {\n const outcome = this.deleteOne(repoRoot, verb, entry, retention);\n if (outcome.ok) reaped.push(outcome);\n else failed.push(outcome);\n }\n return new ReapResult(reaped, failed, []);\n }\n\n private reapBranches(\n repoRoot: string,\n verb: MutationVerb,\n targets: DeletableBranch[],\n retention: string,\n verdicts: MergedBranchesCache,\n ): ReapResult {\n const reaped: ReapedBranch[] = [];\n const failed: ReapedBranch[] = [];\n for (const entry of targets) {\n const outcome = this.deleteOne(repoRoot, verb, entry, retention);\n if (outcome.ok) reaped.push(outcome);\n else failed.push(outcome);\n }\n\n this.rewriteCache(repoRoot, verdicts, failed);\n return new ReapResult(reaped, failed, verdicts.keep);\n }\n\n /**\n * One branch: ARCHIVE the tip as a tag, then one `git branch -D`. Never the multi-name form the old\n * fix hint used: git aborts the whole command on the first branch it refuses, which would strand\n * every branch after it in the list. One invocation each means one failure costs exactly one branch.\n *\n * The archive comes FIRST and, when it fails, the delete does NOT happen. A branch we could not\n * archive is a branch whose only remaining copy is the reflog, and the entire point of this change is\n * to stop relying on the reflog. Refusing to delete is the fail-safe direction: the worst case is a\n * branch that survives one more cleanup cycle.\n */\n private deleteOne(\n repoRoot: string,\n verb: MutationVerb,\n entry: DeletableBranch,\n retention: string,\n ): ReapedBranch {\n // SHA first — after the delete there is no branch left to resolve, and the whole point of the\n // audit line is that it records what was destroyed while it still exists.\n const resolved = this.capture(repoRoot, ['rev-parse', entry.branch]);\n const sha = resolved.ok ? resolved.out : '';\n\n const archive = retention === BRANCH_RETENTION_ARCHIVE_TAG\n ? this.archiver.archive(repoRoot, entry.branch)\n : new ArchiveResult('', sha, true, '');\n if (!archive.ok) return this.archiveFailed(repoRoot, verb, entry, sha, archive);\n\n const deleted = this.capture(repoRoot, ['branch', '-D', entry.branch]);\n const result = new ReapedBranch(\n entry.branch, sha, entry.reason, entry.pr, deleted.ok, deleted.ok ? '' : deleted.err);\n result.archiveTag = archive.tag;\n\n const event = new BranchMutationEvent(verb, 'REAP');\n event.fromBranch = entry.branch;\n event.sha = sha;\n event.archiveTag = archive.tag;\n event.outcome = deleted.ok ? `deleted (${entry.reason})` : `FAILED (${deleted.err})`;\n this.mutationLog.logBranchMutation(repoRoot, event);\n\n return result;\n }\n\n // Archiving failed ⇒ the branch is NOT deleted. Reported as a failure with git's own words, so the\n // human sees a branch that survived and why, rather than a silent skip.\n private archiveFailed(\n repoRoot: string, verb: MutationVerb, entry: DeletableBranch, sha: string, archive: ArchiveResult,\n ): ReapedBranch {\n const error = `not deleted — could not archive it first: ${archive.error}`;\n const event = new BranchMutationEvent(verb, 'REAP');\n event.fromBranch = entry.branch;\n event.sha = sha;\n event.outcome = `SKIPPED (${error})`;\n this.mutationLog.logBranchMutation(repoRoot, event);\n return new ReapedBranch(entry.branch, sha, entry.reason, entry.pr, false, error);\n }\n\n /**\n * Write the verdicts back with the reaped branches removed, so the branch-creation-guard's cap\n * sees the post-reap truth on its very next call instead of continuing to block against branches\n * that no longer exist. Anything that FAILED to delete stays in `deletable` — it is still there,\n * and still dead.\n */\n private rewriteCache(repoRoot: string, verdicts: MergedBranchesCache, failed: ReapedBranch[]): void {\n const stillDead = new Set(failed.map((entry: ReapedBranch): string => entry.branch));\n const remaining = verdicts.deletable.filter(\n (entry: DeletableBranch): boolean => stillDead.has(entry.branch));\n this.mergedBranches.writeMergedBranches(\n repoRoot,\n new MergedBranchesCache(verdicts.timestamp, remaining, verdicts.keep, verdicts.worktrees),\n );\n }\n\n // Run a git command capturing trimmed stdout/stderr; ok=false on spawn failure or non-zero exit.\n private capture(repoRoot: string, args: string[]): CmdCapture {\n const result = spawnSync('git', args, { cwd: repoRoot, encoding: 'utf8' });\n const err = typeof result.stderr === 'string' ? result.stderr.trim() : '';\n if (result.status !== 0 || typeof result.stdout !== 'string') {\n return { ok: false, out: '', err: err !== '' ? err : 'git command failed' };\n }\n return { ok: true, out: result.stdout.trim(), err };\n }\n}\n"]}
1
+ {"version":3,"file":"branch-reaper.js","sourceRoot":"","sources":["../../../../../packages/tooling/rules-config/src/branch-reaper.ts"],"names":[],"mappings":"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","sourcesContent":["import { spawnSync } from 'child_process';\nimport { injectable, bindingScopeValues } from 'inversify';\n\nimport {\n ArchiveResult,\n BranchArchiver,\n BRANCH_RETENTION_ARCHIVE_TAG,\n BRANCH_RETENTION_KEEP,\n} from './branch-archiver';\nimport { BranchMutationEvent, BranchMutationLog, MutationVerb } from './branch-mutation-log';\nimport { DeletableBranch, MergedBranchesCache, MergedBranchesService } from './merged-branches';\n\n/**\n * The EXECUTOR for the dead-branch verdicts that merged-branches.ts computes.\n *\n * WHY this exists: for a long time nothing in the tooling ever deleted a branch. The reap was only\n * ever a `git branch -D a b c` STRING embedded in a guard's fix hint, handed to an AI agent — which\n * reads a raw `-D` as destructive, asks \"may I clean this up?\", and stops. So the verdicts were\n * computed correctly on every hook call and then acted on by nobody, and local branches grew without\n * bound. The fix is not better wording; it is a thing that does the deleting.\n *\n * WHY deleting here is safe: every entry in `deletable` earned its place by one of exactly three\n * proofs — a MERGED PR (the work is in main), a squash-merge BACKUP of a merged branch, or ZERO\n * commits of its own (there is no work). merged-branches.ts also guarantees `main` is never in the\n * list and that a branch checked out in ANY worktree lands in `keep` instead, so we can never delete\n * the branch someone is standing on. On top of that, every delete is logged with the branch's\n * pre-delete SHA and a literal recover command, so no reap is unrecoverable.\n */\n\n// Data-only (per CLAUDE.md, classes for data). One branch and what happened to it.\nexport class ReapedBranch {\n branch: string;\n // The commit the branch pointed at BEFORE deletion — captured first, precisely so a delete is\n // always undoable via `git branch <branch> <sha>`. Empty only if rev-parse itself failed.\n sha: string;\n reason: string;\n pr: number;\n ok: boolean;\n // git's own stderr when ok=false. Kept verbatim: a failed delete is a thing a human must read.\n error: string;\n /**\n * The `archive/<date>/<branch>` tag written immediately BEFORE the delete, or '' when the retention\n * policy is 'delete'. Non-empty means the branch is restorable by NAME rather than by remembering a\n * sha — `git checkout -b <branch> <tag>` restores the exact objects, and the tag survives `gc` and\n * reflog expiry. Field-with-default so every existing `new ReapedBranch(...)` call site still builds.\n */\n archiveTag: string = '';\n /**\n * The branch was ALREADY GONE when we reached it — someone else reaped it first.\n *\n * Not a failure, and the distinction is not cosmetic. `wp-cleanup` and the detached refresher's\n * `auto-reap` both act on their own fresh verdicts, and the hooks kick off the refresher on the\n * same commands a human runs wp-cleanup from, so the two race by design. Observed 2026-08-04:\n * auto-reap deleted both dead branches at 14:30:58.207 and 14:30:59.035, wp-cleanup reached them\n * 300ms later and reported \"⚠️ 2 branch(es) could not be deleted — could not archive it first:\n * unknown revision\", i.e. it announced a failure for the exact outcome it wanted. Whoever wins,\n * the branch is gone, archived and logged by the winner.\n *\n * Field-with-default so every existing `new ReapedBranch(...)` call site still builds.\n */\n alreadyGone: boolean = false;\n\n // eslint-disable-next-line @typescript-eslint/max-params\n constructor(branch: string, sha: string, reason: string, pr: number, ok: boolean, error: string) {\n this.branch = branch;\n this.sha = sha;\n this.reason = reason;\n this.pr = pr;\n this.ok = ok;\n this.error = error;\n }\n}\n\n/**\n * The outcome of one reap. `spared` is carried alongside deliberately: a cleanup that silently says\n * nothing about the branches it did NOT touch reads as \"there was nothing else\", when in fact those\n * are exactly the branches only a human can rule on.\n */\nexport class ReapResult {\n reaped: ReapedBranch[];\n failed: ReapedBranch[];\n spared: DeletableBranch[];\n /**\n * Branches a CONCURRENT reaper got to first (see ReapedBranch.alreadyGone). A separate bucket\n * because they belong in neither of the other two: counting them as `reaped` would credit this\n * run with a delete and an archive tag it did not write, and counting them as `failed` is the bug\n * this fixes — a red warning about branches that are, in fact, gone.\n *\n * Optional so every existing `new ReapResult(...)` call site still builds.\n */\n alreadyGone: ReapedBranch[];\n\n constructor(\n reaped: ReapedBranch[],\n failed: ReapedBranch[],\n spared: DeletableBranch[],\n alreadyGone: ReapedBranch[] = [],\n ) {\n this.reaped = reaped;\n this.failed = failed;\n this.spared = spared;\n this.alreadyGone = alreadyGone;\n }\n}\n\n// Result of a captured git invocation. `err` carries stderr so a failed delete can be reported.\ninterface CmdCapture {\n ok: boolean;\n out: string;\n err: string;\n}\n\n@injectable(bindingScopeValues.Singleton)\nexport class BranchReaper {\n // Defaulted so the non-DI call sites (the detached refresher in sync-main.ts) can just\n // `new BranchReaper()`, while inversify still injects the singletons when resolved from a\n // container. Mirrors how MergedBranchesService defaults its WorktreeService.\n constructor(\n private readonly mergedBranches: MergedBranchesService = new MergedBranchesService(),\n private readonly mutationLog: BranchMutationLog = new BranchMutationLog(),\n private readonly archiver: BranchArchiver = new BranchArchiver(),\n ) {}\n\n /**\n * Delete every branch the verdicts call dead, one command at a time.\n *\n * `cache` is an ALREADY-FRESH set of verdicts (the refresher just computed them, so re-running\n * the `gh` lookup would be pure waste). Pass nothing — as `wp-cleanup` does — and we recompute\n * from scratch. That distinction is load-bearing: the cache file on disk is DELIBERATELY allowed\n * to go stale, which is fine for blocking a branch creation but is not fine for deleting, since a\n * branch may have gained commits since it was written. Deleting never reads the stale file.\n */\n reap(\n repoRoot: string,\n verb: MutationVerb,\n cache: MergedBranchesCache | null = null,\n retention: string = BRANCH_RETENTION_ARCHIVE_TAG,\n ): ReapResult {\n const verdicts = cache ?? this.mergedBranches.computeMergedBranches(repoRoot);\n // 'keep' means \"never delete anything\" — the reap becomes a pure report. Everything still lands\n // in `spared` so the human sees exactly what WOULD have been reaped under the other policies.\n if (retention === BRANCH_RETENTION_KEEP) {\n return new ReapResult([], [], [...verdicts.deletable, ...verdicts.keep]);\n }\n return this.reapBranches(repoRoot, verb, verdicts.deletable, retention, verdicts);\n }\n\n /**\n * Delete a CALLER-SUPPLIED list of branches — the branches a human just said yes to at wp-cleanup's\n * classification prompt. Separate from `reap` because these are NOT provably dead: they earned their\n * deletion from an explicit human answer, not from a verdict, so nothing here may ever run unattended.\n * Archiving still happens first, which is precisely what makes that yes low-stakes.\n */\n reapApproved(\n repoRoot: string,\n verb: MutationVerb,\n approved: DeletableBranch[],\n retention: string = BRANCH_RETENTION_ARCHIVE_TAG,\n ): ReapResult {\n if (retention === BRANCH_RETENTION_KEEP) return new ReapResult([], [], approved);\n const reaped: ReapedBranch[] = [];\n const failed: ReapedBranch[] = [];\n const alreadyGone: ReapedBranch[] = [];\n for (const entry of approved) {\n const outcome = this.deleteOne(repoRoot, verb, entry, retention);\n if (outcome.ok) reaped.push(outcome);\n else if (outcome.alreadyGone) alreadyGone.push(outcome);\n else failed.push(outcome);\n }\n return new ReapResult(reaped, failed, [], alreadyGone);\n }\n\n private reapBranches(\n repoRoot: string,\n verb: MutationVerb,\n targets: DeletableBranch[],\n retention: string,\n verdicts: MergedBranchesCache,\n ): ReapResult {\n const reaped: ReapedBranch[] = [];\n const failed: ReapedBranch[] = [];\n const alreadyGone: ReapedBranch[] = [];\n for (const entry of targets) {\n const outcome = this.deleteOne(repoRoot, verb, entry, retention);\n if (outcome.ok) reaped.push(outcome);\n else if (outcome.alreadyGone) alreadyGone.push(outcome);\n else failed.push(outcome);\n }\n\n // Only genuine failures stay in the cache's `deletable`. An already-gone branch is not there\n // to delete on the next pass, and leaving it in would make the cap keep counting a ghost.\n this.rewriteCache(repoRoot, verdicts, failed);\n return new ReapResult(reaped, failed, verdicts.keep, alreadyGone);\n }\n\n /**\n * One branch: ARCHIVE the tip as a tag, then one `git branch -D`. Never the multi-name form the old\n * fix hint used: git aborts the whole command on the first branch it refuses, which would strand\n * every branch after it in the list. One invocation each means one failure costs exactly one branch.\n *\n * The archive comes FIRST and, when it fails, the delete does NOT happen. A branch we could not\n * archive is a branch whose only remaining copy is the reflog, and the entire point of this change is\n * to stop relying on the reflog. Refusing to delete is the fail-safe direction: the worst case is a\n * branch that survives one more cleanup cycle.\n */\n private deleteOne(\n repoRoot: string,\n verb: MutationVerb,\n entry: DeletableBranch,\n retention: string,\n ): ReapedBranch {\n // SHA first — after the delete there is no branch left to resolve, and the whole point of the\n // audit line is that it records what was destroyed while it still exists.\n const resolved = this.capture(repoRoot, ['rev-parse', entry.branch]);\n const sha = resolved.ok ? resolved.out : '';\n\n // It does not resolve ⇒ it is already gone, reaped by whoever won the race (see\n // ReapedBranch.alreadyGone). Report that outcome honestly instead of letting it fall through\n // to the archive, which fails on the same missing ref and calls a successful cleanup a\n // failure. Nothing to archive and nothing to delete: the winner did both, and logged them.\n if (!resolved.ok) return this.alreadyGone(repoRoot, verb, entry);\n\n const archive = retention === BRANCH_RETENTION_ARCHIVE_TAG\n ? this.archiver.archive(repoRoot, entry.branch)\n : new ArchiveResult('', sha, true, '');\n if (!archive.ok) return this.archiveFailed(repoRoot, verb, entry, sha, archive);\n\n const deleted = this.capture(repoRoot, ['branch', '-D', entry.branch]);\n const result = new ReapedBranch(\n entry.branch, sha, entry.reason, entry.pr, deleted.ok, deleted.ok ? '' : deleted.err);\n result.archiveTag = archive.tag;\n\n const event = new BranchMutationEvent(verb, 'REAP');\n event.fromBranch = entry.branch;\n event.sha = sha;\n event.archiveTag = archive.tag;\n event.outcome = deleted.ok ? `deleted (${entry.reason})` : `FAILED (${deleted.err})`;\n this.mutationLog.logBranchMutation(repoRoot, event);\n\n return result;\n }\n\n /**\n * The branch vanished between the verdict and the reap — a concurrent reaper got it.\n *\n * Logged like every other outcome (a mutation nobody logs is a mutation nobody can audit), but\n * as ALREADY_GONE rather than SKIPPED/FAILED, and carried in its own bucket so the report can\n * say \"already gone\" instead of warning about a branch that is exactly as dead as intended.\n */\n private alreadyGone(repoRoot: string, verb: MutationVerb, entry: DeletableBranch): ReapedBranch {\n const event = new BranchMutationEvent(verb, 'REAP');\n event.fromBranch = entry.branch;\n event.outcome = 'ALREADY GONE (a concurrent reap deleted and archived it first)';\n this.mutationLog.logBranchMutation(repoRoot, event);\n\n const result = new ReapedBranch(\n entry.branch, '', entry.reason, entry.pr, false,\n 'already gone — a concurrent reap deleted and archived it first');\n result.alreadyGone = true;\n return result;\n }\n\n // Archiving failed ⇒ the branch is NOT deleted. Reported as a failure with git's own words, so the\n // human sees a branch that survived and why, rather than a silent skip.\n private archiveFailed(\n repoRoot: string, verb: MutationVerb, entry: DeletableBranch, sha: string, archive: ArchiveResult,\n ): ReapedBranch {\n const error = `not deleted — could not archive it first: ${archive.error}`;\n const event = new BranchMutationEvent(verb, 'REAP');\n event.fromBranch = entry.branch;\n event.sha = sha;\n event.outcome = `SKIPPED (${error})`;\n this.mutationLog.logBranchMutation(repoRoot, event);\n return new ReapedBranch(entry.branch, sha, entry.reason, entry.pr, false, error);\n }\n\n /**\n * Write the verdicts back with the reaped branches removed, so the branch-creation-guard's cap\n * sees the post-reap truth on its very next call instead of continuing to block against branches\n * that no longer exist. Anything that FAILED to delete stays in `deletable` — it is still there,\n * and still dead.\n */\n private rewriteCache(repoRoot: string, verdicts: MergedBranchesCache, failed: ReapedBranch[]): void {\n const stillDead = new Set(failed.map((entry: ReapedBranch): string => entry.branch));\n const remaining = verdicts.deletable.filter(\n (entry: DeletableBranch): boolean => stillDead.has(entry.branch));\n this.mergedBranches.writeMergedBranches(\n repoRoot,\n new MergedBranchesCache(verdicts.timestamp, remaining, verdicts.keep, verdicts.worktrees),\n );\n }\n\n // Run a git command capturing trimmed stdout/stderr; ok=false on spawn failure or non-zero exit.\n private capture(repoRoot: string, args: string[]): CmdCapture {\n const result = spawnSync('git', args, { cwd: repoRoot, encoding: 'utf8' });\n const err = typeof result.stderr === 'string' ? result.stderr.trim() : '';\n if (result.status !== 0 || typeof result.stdout !== 'string') {\n return { ok: false, out: '', err: err !== '' ? err : 'git command failed' };\n }\n return { ok: true, out: result.stdout.trim(), err };\n }\n}\n"]}