@webpieces/rules-config 0.4.572 → 0.4.574

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.572",
3
+ "version": "0.4.574",
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"]}
package/src/skip-rule.js CHANGED
@@ -5,11 +5,27 @@ exports.shouldSkipRule = shouldSkipRule;
5
5
  const child_process_1 = require("child_process");
6
6
  const inform_ai_error_1 = require("./inform-ai-error");
7
7
  const to_error_1 = require("./to-error");
8
- // The actual checked-out branch. Always uses git directly env vars (BRANCH_NAME, GIT_BRANCH,
9
- // GITHUB_*, CI_COMMIT_BRANCH, …) were intentionally REMOVED: a stray GIT_BRANCH=main locally made
10
- // this return "main" on a feature branch, which (a) mislabeled the main-sync cache and (b) silently
11
- // disabled merged-PR detection (detectMergedPr skips "main"). git rev-parse is the source of truth.
8
+ // The actual checked-out branch. The grab bag of ambient env vars (BRANCH_NAME, GIT_BRANCH,
9
+ // CI_COMMIT_BRANCH, …) was intentionally REMOVED and must stay removed: a stray GIT_BRANCH=main
10
+ // locally made this return "main" on a feature branch, which (a) mislabeled the main-sync cache and
11
+ // (b) silently disabled merged-PR detection (detectMergedPr skips "main").
12
+ //
13
+ // The two vars below are NOT that. They are consulted BEFORE git because git cannot answer at all in
14
+ // the case they cover — a `pull_request` checkout leaves HEAD detached on refs/pull/<N>/merge, where
15
+ // `git rev-parse --abbrev-ref HEAD` returns the literal string "HEAD" and no branch hatch can match.
16
+ // Neither can go stale the way GIT_BRANCH did:
17
+ // GITHUB_HEAD_REF — set by the GitHub runner ONLY on pull_request/pull_request_target, and it IS
18
+ // the source branch name. Absent on push, so the fallthrough stays safe. (Not
19
+ // GITHUB_REF_NAME: on pull_request that is "<N>/merge", not a branch.)
20
+ // WEBPIECES_BRANCH — one documented opt-in override for CI systems not special-cased here
21
+ // (GitLab, CircleCI, Buildkite). Nobody sets it by accident.
12
22
  function getCurrentBranch() {
23
+ const prBranch = process.env['GITHUB_HEAD_REF'];
24
+ if (prBranch)
25
+ return prBranch;
26
+ const override = process.env['WEBPIECES_BRANCH'];
27
+ if (override)
28
+ return override;
13
29
  // webpieces-disable no-unmanaged-exceptions -- rethrow as InformAiError so global catch surfaces readable message to AI
14
30
  // eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- rethrow as InformAiError so global catch surfaces readable message to AI
15
31
  try {
@@ -26,6 +42,23 @@ function shouldSkipRule(epoch,
26
42
  branchPattern) {
27
43
  if (branchPattern) {
28
44
  const current = getCurrentBranch();
45
+ // ONLY inside `if (branchPattern)`. A detached HEAD with turnOffRuleWhileOnBranch=null (the
46
+ // overwhelmingly common value) is a non-event and must stay silent, or every tag build,
47
+ // bisect and `gh pr checkout --detach` starts failing for no reason. The fault reported here
48
+ // is "you asked for branch scoping where no branch exists", never "HEAD is detached".
49
+ if (current === 'HEAD' || current === '') {
50
+ throw new inform_ai_error_1.InformAiError(`turnOffRuleWhileOnBranch: "${branchPattern}" is configured, but the current branch ` +
51
+ `cannot be determined — HEAD is detached, which is what a CI checkout of a merge ref ` +
52
+ `looks like. The hatch would silently NOT apply, so this fails now rather than passing ` +
53
+ `on your machine and failing in CI with an unrelated-looking error.\n\n` +
54
+ `WORKAROUNDS, in order of preference:\n` +
55
+ ` 1. Upgrade to a webpieces that reads GITHUB_HEAD_REF (no workflow change needed).\n` +
56
+ ` 2. Set WEBPIECES_BRANCH in the workflow so the branch can be resolved.\n` +
57
+ ` 3. Use turnOffRuleUntilEpoch instead — it is TIME based, so it survives any checkout ` +
58
+ `including a detached one, and it is the only hatch that works in CI today. Set it to a ` +
59
+ `SHORT date (a few days): unlike branch scoping it is repo-wide while it lasts, so it ` +
60
+ `also shelters unrelated work that lands in the same window.`);
61
+ }
29
62
  if (current === branchPattern) {
30
63
  return { skip: true, reason: `on branch "${branchPattern}"` };
31
64
  }
@@ -1 +1 @@
1
- {"version":3,"file":"skip-rule.js","sourceRoot":"","sources":["../../../../../packages/tooling/rules-config/src/skip-rule.ts"],"names":[],"mappings":";;AAmBA,4CASC;AAED,wCAoBC;AAlDD,iDAAyC;AAEzC,uDAAkD;AAClD,yCAAqC;AAYrC,+FAA+F;AAC/F,kGAAkG;AAClG,oGAAoG;AACpG,oGAAoG;AACpG,SAAgB,gBAAgB;IAC5B,wHAAwH;IACxH,0IAA0I;IAC1I,IAAI,CAAC;QACD,OAAO,IAAA,wBAAQ,EAAC,iCAAiC,EAAE,EAAE,QAAQ,EAAE,MAAM,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC;IACpF,CAAC;IAAC,OAAO,GAAY,EAAE,CAAC;QACpB,MAAM,KAAK,GAAG,IAAA,kBAAO,EAAC,GAAG,CAAC,CAAC;QAC3B,MAAM,IAAI,+BAAa,CAAC,2CAA2C,KAAK,CAAC,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE,CAAC,CAAC;IAC1G,CAAC;AACL,CAAC;AAED,SAAgB,cAAc,CAC1B,KAAyB;AACzB,+FAA+F;AAC/F,0FAA0F;AAC1F,aAAwC;IAExC,IAAI,aAAa,EAAE,CAAC;QAChB,MAAM,OAAO,GAAG,gBAAgB,EAAE,CAAC;QACnC,IAAI,OAAO,KAAK,aAAa,EAAE,CAAC;YAC5B,OAAO,EAAE,IAAI,EAAE,IAAI,EAAE,MAAM,EAAE,cAAc,aAAa,GAAG,EAAE,CAAC;QAClE,CAAC;IACL,CAAC;IACD,IAAI,KAAK,KAAK,SAAS,EAAE,CAAC;QACtB,MAAM,UAAU,GAAG,IAAI,CAAC,GAAG,EAAE,GAAG,IAAI,CAAC;QACrC,IAAI,UAAU,GAAG,KAAK,EAAE,CAAC;YACrB,MAAM,WAAW,GAAG,IAAI,IAAI,CAAC,KAAK,GAAG,IAAI,CAAC,CAAC,WAAW,EAAE,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC;YACvE,OAAO,EAAE,IAAI,EAAE,IAAI,EAAE,MAAM,EAAE,0CAA0C,WAAW,EAAE,EAAE,CAAC;QAC3F,CAAC;IACL,CAAC;IACD,OAAO,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC;AAC3B,CAAC","sourcesContent":["import { execSync } from 'child_process';\n\nimport { InformAiError } from './inform-ai-error';\nimport { toError } from './to-error';\n\n// Universal \"should this rule be skipped right now?\" logic, shared by code-rules,\n// ai-hook-rules and the Nx executors so every rule honors the same two escape\n// hatches: turnOffRuleWhileOnBranch (skip while on a named branch) and\n// turnOffRuleUntilEpoch (skip until an epoch passes).\n\nexport interface SkipRuleResult {\n skip: boolean;\n reason?: string;\n}\n\n// The actual checked-out branch. Always uses git directly env vars (BRANCH_NAME, GIT_BRANCH,\n// GITHUB_*, CI_COMMIT_BRANCH, …) were intentionally REMOVED: a stray GIT_BRANCH=main locally made\n// this return \"main\" on a feature branch, which (a) mislabeled the main-sync cache and (b) silently\n// disabled merged-PR detection (detectMergedPr skips \"main\"). git rev-parse is the source of truth.\nexport function getCurrentBranch(): string {\n // webpieces-disable no-unmanaged-exceptions -- rethrow as InformAiError so global catch surfaces readable message to AI\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- rethrow as InformAiError so global catch surfaces readable message to AI\n try {\n return execSync('git rev-parse --abbrev-ref HEAD', { encoding: 'utf8' }).trim();\n } catch (err: unknown) {\n const error = toError(err);\n throw new InformAiError(`Failed to determine current git branch: ${error.message}`, { cause: error });\n }\n}\n\nexport function shouldSkipRule(\n epoch: number | undefined,\n // null (the \"no branch / always on\" value of turnOffRuleWhileOnBranch) is treated exactly like\n // undefined — no branch scoping. Only a non-empty branch name activates the branch hatch.\n branchPattern: string | undefined | null\n): SkipRuleResult {\n if (branchPattern) {\n const current = getCurrentBranch();\n if (current === branchPattern) {\n return { skip: true, reason: `on branch \"${branchPattern}\"` };\n }\n }\n if (epoch !== undefined) {\n const nowSeconds = Date.now() / 1000;\n if (nowSeconds < epoch) {\n const expiresDate = new Date(epoch * 1000).toISOString().split('T')[0];\n return { skip: true, reason: `turnOffRuleUntilEpoch active, expires: ${expiresDate}` };\n }\n }\n return { skip: false };\n}\n"]}
1
+ {"version":3,"file":"skip-rule.js","sourceRoot":"","sources":["../../../../../packages/tooling/rules-config/src/skip-rule.ts"],"names":[],"mappings":";;AA6BA,4CAeC;AAED,wCAuCC;AArFD,iDAAyC;AAEzC,uDAAkD;AAClD,yCAAqC;AAYrC,4FAA4F;AAC5F,gGAAgG;AAChG,oGAAoG;AACpG,2EAA2E;AAC3E,EAAE;AACF,qGAAqG;AACrG,qGAAqG;AACrG,qGAAqG;AACrG,+CAA+C;AAC/C,oGAAoG;AACpG,mGAAmG;AACnG,4FAA4F;AAC5F,4FAA4F;AAC5F,kFAAkF;AAClF,SAAgB,gBAAgB;IAC5B,MAAM,QAAQ,GAAG,OAAO,CAAC,GAAG,CAAC,iBAAiB,CAAC,CAAC;IAChD,IAAI,QAAQ;QAAE,OAAO,QAAQ,CAAC;IAE9B,MAAM,QAAQ,GAAG,OAAO,CAAC,GAAG,CAAC,kBAAkB,CAAC,CAAC;IACjD,IAAI,QAAQ;QAAE,OAAO,QAAQ,CAAC;IAE9B,wHAAwH;IACxH,0IAA0I;IAC1I,IAAI,CAAC;QACD,OAAO,IAAA,wBAAQ,EAAC,iCAAiC,EAAE,EAAE,QAAQ,EAAE,MAAM,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC;IACpF,CAAC;IAAC,OAAO,GAAY,EAAE,CAAC;QACpB,MAAM,KAAK,GAAG,IAAA,kBAAO,EAAC,GAAG,CAAC,CAAC;QAC3B,MAAM,IAAI,+BAAa,CAAC,2CAA2C,KAAK,CAAC,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE,CAAC,CAAC;IAC1G,CAAC;AACL,CAAC;AAED,SAAgB,cAAc,CAC1B,KAAyB;AACzB,+FAA+F;AAC/F,0FAA0F;AAC1F,aAAwC;IAExC,IAAI,aAAa,EAAE,CAAC;QAChB,MAAM,OAAO,GAAG,gBAAgB,EAAE,CAAC;QACnC,4FAA4F;QAC5F,wFAAwF;QACxF,6FAA6F;QAC7F,sFAAsF;QACtF,IAAI,OAAO,KAAK,MAAM,IAAI,OAAO,KAAK,EAAE,EAAE,CAAC;YACvC,MAAM,IAAI,+BAAa,CACnB,8BAA8B,aAAa,0CAA0C;gBACjF,sFAAsF;gBACtF,wFAAwF;gBACxF,wEAAwE;gBACxE,wCAAwC;gBACxC,uFAAuF;gBACvF,4EAA4E;gBAC5E,yFAAyF;gBACzF,yFAAyF;gBACzF,uFAAuF;gBACvF,6DAA6D,CACpE,CAAC;QACN,CAAC;QACD,IAAI,OAAO,KAAK,aAAa,EAAE,CAAC;YAC5B,OAAO,EAAE,IAAI,EAAE,IAAI,EAAE,MAAM,EAAE,cAAc,aAAa,GAAG,EAAE,CAAC;QAClE,CAAC;IACL,CAAC;IACD,IAAI,KAAK,KAAK,SAAS,EAAE,CAAC;QACtB,MAAM,UAAU,GAAG,IAAI,CAAC,GAAG,EAAE,GAAG,IAAI,CAAC;QACrC,IAAI,UAAU,GAAG,KAAK,EAAE,CAAC;YACrB,MAAM,WAAW,GAAG,IAAI,IAAI,CAAC,KAAK,GAAG,IAAI,CAAC,CAAC,WAAW,EAAE,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC;YACvE,OAAO,EAAE,IAAI,EAAE,IAAI,EAAE,MAAM,EAAE,0CAA0C,WAAW,EAAE,EAAE,CAAC;QAC3F,CAAC;IACL,CAAC;IACD,OAAO,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC;AAC3B,CAAC","sourcesContent":["import { execSync } from 'child_process';\n\nimport { InformAiError } from './inform-ai-error';\nimport { toError } from './to-error';\n\n// Universal \"should this rule be skipped right now?\" logic, shared by code-rules,\n// ai-hook-rules and the Nx executors so every rule honors the same two escape\n// hatches: turnOffRuleWhileOnBranch (skip while on a named branch) and\n// turnOffRuleUntilEpoch (skip until an epoch passes).\n\nexport interface SkipRuleResult {\n skip: boolean;\n reason?: string;\n}\n\n// The actual checked-out branch. The grab bag of ambient env vars (BRANCH_NAME, GIT_BRANCH,\n// CI_COMMIT_BRANCH, …) was intentionally REMOVED and must stay removed: a stray GIT_BRANCH=main\n// locally made this return \"main\" on a feature branch, which (a) mislabeled the main-sync cache and\n// (b) silently disabled merged-PR detection (detectMergedPr skips \"main\").\n//\n// The two vars below are NOT that. They are consulted BEFORE git because git cannot answer at all in\n// the case they cover — a `pull_request` checkout leaves HEAD detached on refs/pull/<N>/merge, where\n// `git rev-parse --abbrev-ref HEAD` returns the literal string \"HEAD\" and no branch hatch can match.\n// Neither can go stale the way GIT_BRANCH did:\n// GITHUB_HEAD_REF — set by the GitHub runner ONLY on pull_request/pull_request_target, and it IS\n// the source branch name. Absent on push, so the fallthrough stays safe. (Not\n// GITHUB_REF_NAME: on pull_request that is \"<N>/merge\", not a branch.)\n// WEBPIECES_BRANCH — one documented opt-in override for CI systems not special-cased here\n// (GitLab, CircleCI, Buildkite). Nobody sets it by accident.\nexport function getCurrentBranch(): string {\n const prBranch = process.env['GITHUB_HEAD_REF'];\n if (prBranch) return prBranch;\n\n const override = process.env['WEBPIECES_BRANCH'];\n if (override) return override;\n\n // webpieces-disable no-unmanaged-exceptions -- rethrow as InformAiError so global catch surfaces readable message to AI\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- rethrow as InformAiError so global catch surfaces readable message to AI\n try {\n return execSync('git rev-parse --abbrev-ref HEAD', { encoding: 'utf8' }).trim();\n } catch (err: unknown) {\n const error = toError(err);\n throw new InformAiError(`Failed to determine current git branch: ${error.message}`, { cause: error });\n }\n}\n\nexport function shouldSkipRule(\n epoch: number | undefined,\n // null (the \"no branch / always on\" value of turnOffRuleWhileOnBranch) is treated exactly like\n // undefined — no branch scoping. Only a non-empty branch name activates the branch hatch.\n branchPattern: string | undefined | null\n): SkipRuleResult {\n if (branchPattern) {\n const current = getCurrentBranch();\n // ONLY inside `if (branchPattern)`. A detached HEAD with turnOffRuleWhileOnBranch=null (the\n // overwhelmingly common value) is a non-event and must stay silent, or every tag build,\n // bisect and `gh pr checkout --detach` starts failing for no reason. The fault reported here\n // is \"you asked for branch scoping where no branch exists\", never \"HEAD is detached\".\n if (current === 'HEAD' || current === '') {\n throw new InformAiError(\n `turnOffRuleWhileOnBranch: \"${branchPattern}\" is configured, but the current branch ` +\n `cannot be determined — HEAD is detached, which is what a CI checkout of a merge ref ` +\n `looks like. The hatch would silently NOT apply, so this fails now rather than passing ` +\n `on your machine and failing in CI with an unrelated-looking error.\\n\\n` +\n `WORKAROUNDS, in order of preference:\\n` +\n ` 1. Upgrade to a webpieces that reads GITHUB_HEAD_REF (no workflow change needed).\\n` +\n ` 2. Set WEBPIECES_BRANCH in the workflow so the branch can be resolved.\\n` +\n ` 3. Use turnOffRuleUntilEpoch instead — it is TIME based, so it survives any checkout ` +\n `including a detached one, and it is the only hatch that works in CI today. Set it to a ` +\n `SHORT date (a few days): unlike branch scoping it is repo-wide while it lasts, so it ` +\n `also shelters unrelated work that lands in the same window.`\n );\n }\n if (current === branchPattern) {\n return { skip: true, reason: `on branch \"${branchPattern}\"` };\n }\n }\n if (epoch !== undefined) {\n const nowSeconds = Date.now() / 1000;\n if (nowSeconds < epoch) {\n const expiresDate = new Date(epoch * 1000).toISOString().split('T')[0];\n return { skip: true, reason: `turnOffRuleUntilEpoch active, expires: ${expiresDate}` };\n }\n }\n return { skip: false };\n}\n"]}