@webpieces/pr-gate 0.4.495 → 0.4.497

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/pr-gate",
3
- "version": "0.4.495",
3
+ "version": "0.4.497",
4
4
  "description": "Gated PR system: 3-point squash-merge, merge validation gate, and red/yellow/green PR dashboard. Standalone scripts, no Nx dependency required.",
5
5
  "type": "commonjs",
6
6
  "main": "./src/index.js",
@@ -25,7 +25,7 @@
25
25
  "directory": "packages/tooling/pr-gate"
26
26
  },
27
27
  "dependencies": {
28
- "@webpieces/rules-config": "0.4.495",
28
+ "@webpieces/rules-config": "0.4.497",
29
29
  "@inversifyjs/binding-decorators": "1.1.5",
30
30
  "inversify": "7.10.4",
31
31
  "reflect-metadata": "0.2.2"
@@ -1,6 +1,7 @@
1
- import { BranchReaper, RepoRootFinder } from '@webpieces/rules-config';
1
+ import { BranchArchiver, BranchReaper, RepoRootFinder } from '@webpieces/rules-config';
2
2
  /**
3
- * wp-cleanup: delete the local branches whose PR is already merged (or that hold no commits).
3
+ * wp-cleanup: delete the local branches whose PR is already merged (or that hold no commits), then ASK
4
+ * about the ones that are merely probably-dead.
4
5
  *
5
6
  * WHY a named command instead of the `git branch -D a b c` the guards used to print: an AI agent
6
7
  * reads a raw `-D` as destructive, so it asks permission and stops — which is exactly why branches
@@ -8,15 +9,36 @@ import { BranchReaper, RepoRootFinder } from '@webpieces/rules-config';
8
9
  * boring, allowlistable verb whose safety is a property of the command itself rather than of the
9
10
  * agent's judgement about a git flag.
10
11
  *
11
- * All the danger lives in the verdicts, not here see BranchReaper for why every deleted branch is
12
- * provably dead and recoverable by hash.
12
+ * WHY IT NOW PROMPTS: sparing silently was the other half of the same problem. Every spared branch
13
+ * reported the identical `no merged PR found — a human must decide`, so the human could not decide,
14
+ * so nothing got deleted, so the pile grew until branch-creation-guard refused to make the next branch
15
+ * and an agent went looking for a config knob to loosen. Shown a real classification with unique-commit
16
+ * counts, the human in that session answered in five words: "these should all be delete branches".
17
+ * The prompt is cheap because archiving happens FIRST — a yes costs a tag, not the history.
18
+ *
19
+ * All the danger still lives in the verdicts, not here — see BranchReaper for why every AUTOMATICALLY
20
+ * deleted branch is provably dead and recoverable, and note that nothing in the prompted group is ever
21
+ * deleted without an explicit typed answer.
13
22
  */
14
23
  export declare class CleanupCommand {
15
24
  private readonly repoRootFinder;
16
25
  private readonly branchReaper;
17
- constructor(repoRootFinder: RepoRootFinder, branchReaper: BranchReaper);
26
+ private readonly archiver;
27
+ constructor(repoRootFinder: RepoRootFinder, branchReaper: BranchReaper, archiver: BranchArchiver);
18
28
  run(): Promise<void>;
19
29
  private report;
20
30
  private reapedLine;
21
- private sparedBlock;
31
+ private promptable;
32
+ private classifiedBlock;
33
+ /**
34
+ * Ask which of the classified branches to delete. Answers: `all`, `none` (default), or a
35
+ * comma/space-separated list of the numbers shown.
36
+ *
37
+ * NON-INTERACTIVE (no TTY — CI, a hook, a piped agent shell) answers NONE and says so. A prompt
38
+ * nobody can see must never be read as consent, and this is the one place in the tooling where a
39
+ * deletion is not backed by a proof.
40
+ */
41
+ private askWhichToDelete;
42
+ private pickByNumber;
43
+ protected question(prompt: string): Promise<string>;
22
44
  }
@@ -2,11 +2,24 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.CleanupCommand = void 0;
4
4
  const tslib_1 = require("tslib");
5
+ const readline = tslib_1.__importStar(require("readline"));
5
6
  const rules_config_1 = require("@webpieces/rules-config");
6
7
  const inversify_1 = require("inversify");
7
8
  const SEP = '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n';
9
+ // One line of human-facing explanation per promptable classification, ordered most-safe first. These
10
+ // replace the single string `no merged PR found — a human must decide`, which covered all three of
11
+ // these situations identically and so told a human nothing they could act on.
12
+ const CLASSIFICATION_HEADINGS = {
13
+ [rules_config_1.CLASSIFICATION_SUPERSEDED]: 'SUPERSEDED — the PR was closed WITHOUT merging and later PRs have merged since. Near-certainly\n'
14
+ + ' the abandoned first attempt at work that landed under a different number. Safest group to delete.',
15
+ [rules_config_1.CLASSIFICATION_CONTENT_IN_MAIN]: 'CONTENT ALREADY IN MAIN — every commit has a patch-equivalent in origin/main (git cherry).\n'
16
+ + ' The work is not unique to this branch; only the commit objects are.',
17
+ [rules_config_1.CLASSIFICATION_NEVER_PROPOSED]: 'NEVER PROPOSED — no PR was ever opened, and these commits may be the ONLY copy in existence.\n'
18
+ + ' Read the unique-commit counts before answering. This is the group to say no to if unsure.',
19
+ };
8
20
  /**
9
- * wp-cleanup: delete the local branches whose PR is already merged (or that hold no commits).
21
+ * wp-cleanup: delete the local branches whose PR is already merged (or that hold no commits), then ASK
22
+ * about the ones that are merely probably-dead.
10
23
  *
11
24
  * WHY a named command instead of the `git branch -D a b c` the guards used to print: an AI agent
12
25
  * reads a raw `-D` as destructive, so it asks permission and stops — which is exactly why branches
@@ -14,27 +27,48 @@ const SEP = '━━━━━━━━━━━━━━━━━━━━━━
14
27
  * boring, allowlistable verb whose safety is a property of the command itself rather than of the
15
28
  * agent's judgement about a git flag.
16
29
  *
17
- * All the danger lives in the verdicts, not here see BranchReaper for why every deleted branch is
18
- * provably dead and recoverable by hash.
30
+ * WHY IT NOW PROMPTS: sparing silently was the other half of the same problem. Every spared branch
31
+ * reported the identical `no merged PR found — a human must decide`, so the human could not decide,
32
+ * so nothing got deleted, so the pile grew until branch-creation-guard refused to make the next branch
33
+ * and an agent went looking for a config knob to loosen. Shown a real classification with unique-commit
34
+ * counts, the human in that session answered in five words: "these should all be delete branches".
35
+ * The prompt is cheap because archiving happens FIRST — a yes costs a tag, not the history.
36
+ *
37
+ * All the danger still lives in the verdicts, not here — see BranchReaper for why every AUTOMATICALLY
38
+ * deleted branch is provably dead and recoverable, and note that nothing in the prompted group is ever
39
+ * deleted without an explicit typed answer.
19
40
  */
20
41
  let CleanupCommand = class CleanupCommand {
21
42
  repoRootFinder;
22
43
  branchReaper;
23
- constructor(repoRootFinder, branchReaper) {
44
+ archiver;
45
+ constructor(repoRootFinder, branchReaper, archiver) {
24
46
  this.repoRootFinder = repoRootFinder;
25
47
  this.branchReaper = branchReaper;
48
+ this.archiver = archiver;
26
49
  }
27
- run() {
50
+ async run() {
28
51
  const repoRoot = this.repoRootFinder.resolveRepoRoot(process.cwd());
52
+ const retention = (0, rules_config_1.loadAndValidate)(repoRoot).prGate.landPr.branchRetention;
29
53
  // No cache argument: wp-cleanup recomputes the verdicts itself. The file on disk is allowed to
30
54
  // go stale, and stale evidence is fine for BLOCKING but never for DELETING.
31
- const result = this.branchReaper.reap(repoRoot, 'wp-cleanup');
55
+ const result = this.branchReaper.reap(repoRoot, 'wp-cleanup', null, retention);
32
56
  process.stdout.write(this.report(result));
33
- return Promise.resolve();
57
+ const promptable = this.promptable(result.spared);
58
+ if (promptable.length === 0)
59
+ return;
60
+ process.stdout.write(this.classifiedBlock(promptable));
61
+ const approved = await this.askWhichToDelete(promptable);
62
+ if (approved.length === 0) {
63
+ process.stdout.write('\nNothing deleted — the branches above were kept.\n');
64
+ return;
65
+ }
66
+ const second = this.branchReaper.reapApproved(repoRoot, 'wp-cleanup', approved, retention);
67
+ process.stdout.write(this.report(second));
34
68
  }
35
69
  report(result) {
36
70
  if (result.reaped.length === 0 && result.failed.length === 0) {
37
- return '\n✅ Nothing to clean up — no local branch is provably dead.\n' + this.sparedBlock(result.spared);
71
+ return '\n✅ Nothing to clean up — no local branch is provably dead.\n';
38
72
  }
39
73
  let out = '\n' + SEP + `🧹 Cleaned up ${String(result.reaped.length)} dead local branch(es)\n` + SEP + '\n';
40
74
  for (const entry of result.reaped)
@@ -48,27 +82,95 @@ let CleanupCommand = class CleanupCommand {
48
82
  // blindly, and the whole argument for auto-cleanup is that they never have to.
49
83
  out += '\nEvery deletion is logged with its pre-delete SHA in .webpieces/hooks/branch-mutations.log —\n'
50
84
  + 'recover any of them with the `recover=` command on its line.\n';
51
- return out + this.sparedBlock(result.spared);
85
+ return out;
52
86
  }
53
87
  reapedLine(entry) {
54
88
  const sha = entry.sha !== '' ? ` (was ${entry.sha.slice(0, 8)})` : '';
55
- return ` ✓ ${entry.branch}${sha} ${entry.reason}\n`;
89
+ // The archive tag is printed inline because it is the ONE thing that makes this delete casually
90
+ // reversible — a name a human can type, rather than a sha they have to go dig out of a log.
91
+ const archived = entry.archiveTag !== ''
92
+ ? `\n archived → ${entry.archiveTag} (restore: ${this.archiver.restoreCommand(entry.branch, entry.archiveTag)})`
93
+ : '';
94
+ return ` ✓ ${entry.branch}${sha} — ${entry.reason}${archived}\n`;
95
+ }
96
+ // The spared branches a human can meaningfully rule on, grouped and ordered most-safe first.
97
+ // Branches spared for a MECHANICAL reason (checked out in a worktree) are deliberately excluded:
98
+ // there is no judgement to make, git would simply refuse.
99
+ promptable(spared) {
100
+ const out = [];
101
+ for (const classification of rules_config_1.PROMPTABLE_CLASSIFICATIONS) {
102
+ for (const entry of spared) {
103
+ if (entry.classification === classification)
104
+ out.push(entry);
105
+ }
106
+ }
107
+ return out;
56
108
  }
57
- // The branches we refused to touch, with reasons. Silence here would read as "there was nothing
58
- // else", when these are precisely the ones only a human can rule on.
59
- sparedBlock(spared) {
60
- if (spared.length === 0)
61
- return '';
62
- let out = `\nSpared ${String(spared.length)} branch(es) a human decides on these:\n`;
63
- for (const entry of spared)
64
- out += ` • ${entry.branch} ${entry.reason}\n`;
109
+ // The classification table: what each group means, and per branch its unique-commit count the one
110
+ // number that says whether a yes costs nothing or costs the only copy of somebody's work.
111
+ classifiedBlock(promptable) {
112
+ let out = '\n' + SEP + `🤔 ${String(promptable.length)} branch(es) are probably dead — your call\n` + SEP;
113
+ let current = '';
114
+ for (let i = 0; i < promptable.length; i += 1) {
115
+ const entry = promptable[i];
116
+ if (entry.classification !== current) {
117
+ current = entry.classification;
118
+ out += `\n${CLASSIFICATION_HEADINGS[current] ?? current}\n\n`;
119
+ }
120
+ const commits = entry.commits >= 0 ? `${String(entry.commits)} unique commit(s)` : 'unique commits unknown';
121
+ out += ` [${String(i + 1)}] ${entry.branch}\n ${commits} — ${entry.reason}\n`;
122
+ }
65
123
  return out;
66
124
  }
125
+ /**
126
+ * Ask which of the classified branches to delete. Answers: `all`, `none` (default), or a
127
+ * comma/space-separated list of the numbers shown.
128
+ *
129
+ * NON-INTERACTIVE (no TTY — CI, a hook, a piped agent shell) answers NONE and says so. A prompt
130
+ * nobody can see must never be read as consent, and this is the one place in the tooling where a
131
+ * deletion is not backed by a proof.
132
+ */
133
+ async askWhichToDelete(promptable) {
134
+ if (process.stdin.isTTY !== true) {
135
+ process.stdout.write('\nNot a terminal — nothing was deleted and nothing was assumed.\n'
136
+ + 'Run `pnpm wp-cleanup` in an interactive shell to answer, or delete individually.\n');
137
+ return [];
138
+ }
139
+ const answer = (await this.question(`\nDelete which? [all / none / e.g. "1,3"] (default none): `)).trim().toLowerCase();
140
+ if (answer === '' || answer === 'none' || answer === 'n')
141
+ return [];
142
+ if (answer === 'all' || answer === 'a')
143
+ return promptable;
144
+ return this.pickByNumber(promptable, answer);
145
+ }
146
+ // Parse `1,3` / `1 3` into branches, ignoring anything out of range. An unparseable answer selects
147
+ // nothing, which is the fail-safe direction for a question about deleting.
148
+ pickByNumber(promptable, answer) {
149
+ const out = [];
150
+ for (const token of answer.split(/[\s,]+/)) {
151
+ const index = Number(token);
152
+ if (!Number.isInteger(index) || index < 1 || index > promptable.length)
153
+ continue;
154
+ out.push(promptable[index - 1]);
155
+ }
156
+ return out;
157
+ }
158
+ // Seam: overridden in the spec so the prompt parsing is testable with no terminal.
159
+ question(prompt) {
160
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
161
+ return new Promise((resolve) => {
162
+ rl.question(prompt, (answer) => {
163
+ rl.close();
164
+ resolve(answer);
165
+ });
166
+ });
167
+ }
67
168
  };
68
169
  exports.CleanupCommand = CleanupCommand;
69
170
  exports.CleanupCommand = CleanupCommand = tslib_1.__decorate([
70
171
  (0, inversify_1.injectable)(inversify_1.bindingScopeValues.Singleton),
71
172
  tslib_1.__metadata("design:paramtypes", [rules_config_1.RepoRootFinder,
72
- rules_config_1.BranchReaper])
173
+ rules_config_1.BranchReaper,
174
+ rules_config_1.BranchArchiver])
73
175
  ], CleanupCommand);
74
176
  //# sourceMappingURL=cleanup-command.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"cleanup-command.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/pr-gate/src/scripts/commands/cleanup-command.ts"],"names":[],"mappings":";;;;AAAA,0DAMiC;AACjC,yCAA2D;AAE3D,MAAM,GAAG,GAAG,0DAA0D,CAAC;AAEvE;;;;;;;;;;;GAWG;AAEI,IAAM,cAAc,GAApB,MAAM,cAAc;IAEF;IACA;IAFrB,YACqB,cAA8B,EAC9B,YAA0B;QAD1B,mBAAc,GAAd,cAAc,CAAgB;QAC9B,iBAAY,GAAZ,YAAY,CAAc;IAC5C,CAAC;IAEJ,GAAG;QACC,MAAM,QAAQ,GAAG,IAAI,CAAC,cAAc,CAAC,eAAe,CAAC,OAAO,CAAC,GAAG,EAAE,CAAC,CAAC;QACpE,+FAA+F;QAC/F,4EAA4E;QAC5E,MAAM,MAAM,GAAG,IAAI,CAAC,YAAY,CAAC,IAAI,CAAC,QAAQ,EAAE,YAAY,CAAC,CAAC;QAC9D,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC;QAC1C,OAAO,OAAO,CAAC,OAAO,EAAE,CAAC;IAC7B,CAAC;IAEO,MAAM,CAAC,MAAkB;QAC7B,IAAI,MAAM,CAAC,MAAM,CAAC,MAAM,KAAK,CAAC,IAAI,MAAM,CAAC,MAAM,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YAC3D,OAAO,+DAA+D,GAAG,IAAI,CAAC,WAAW,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC;QAC7G,CAAC;QAED,IAAI,GAAG,GAAG,IAAI,GAAG,GAAG,GAAG,iBAAiB,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,0BAA0B,GAAG,GAAG,GAAG,IAAI,CAAC;QAC5G,KAAK,MAAM,KAAK,IAAI,MAAM,CAAC,MAAM;YAAE,GAAG,IAAI,IAAI,CAAC,UAAU,CAAC,KAAK,CAAC,CAAC;QAEjE,IAAI,MAAM,CAAC,MAAM,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;YAC3B,GAAG,IAAI,SAAS,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,qCAAqC,CAAC;YAClF,KAAK,MAAM,KAAK,IAAI,MAAM,CAAC,MAAM;gBAAE,GAAG,IAAI,OAAO,KAAK,CAAC,MAAM,MAAM,KAAK,CAAC,KAAK,IAAI,CAAC;QACvF,CAAC;QAED,6FAA6F;QAC7F,+EAA+E;QAC/E,GAAG,IAAI,iGAAiG;cAClG,gEAAgE,CAAC;QACvE,OAAO,GAAG,GAAG,IAAI,CAAC,WAAW,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC;IACjD,CAAC;IAEO,UAAU,CAAC,KAAmB;QAClC,MAAM,GAAG,GAAG,KAAK,CAAC,GAAG,KAAK,EAAE,CAAC,CAAC,CAAC,SAAS,KAAK,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC;QACtE,OAAO,OAAO,KAAK,CAAC,MAAM,GAAG,GAAG,MAAM,KAAK,CAAC,MAAM,IAAI,CAAC;IAC3D,CAAC;IAED,gGAAgG;IAChG,qEAAqE;IAC7D,WAAW,CAAC,MAAyB;QACzC,IAAI,MAAM,CAAC,MAAM,KAAK,CAAC;YAAE,OAAO,EAAE,CAAC;QACnC,IAAI,GAAG,GAAG,YAAY,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,2CAA2C,CAAC;QACvF,KAAK,MAAM,KAAK,IAAI,MAAM;YAAE,GAAG,IAAI,OAAO,KAAK,CAAC,MAAM,MAAM,KAAK,CAAC,MAAM,IAAI,CAAC;QAC7E,OAAO,GAAG,CAAC;IACf,CAAC;CACJ,CAAA;AAhDY,wCAAc;yBAAd,cAAc;IAD1B,IAAA,sBAAU,EAAC,8BAAkB,CAAC,SAAS,CAAC;6CAGA,6BAAc;QAChB,2BAAY;GAHtC,cAAc,CAgD1B","sourcesContent":["import {\n BranchReaper,\n DeletableBranch,\n ReapResult,\n ReapedBranch,\n RepoRootFinder,\n} from '@webpieces/rules-config';\nimport { injectable, bindingScopeValues } from 'inversify';\n\nconst SEP = '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\\n';\n\n/**\n * wp-cleanup: delete the local branches whose PR is already merged (or that hold no commits).\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 * All the danger lives in the verdicts, not here — see BranchReaper for why every deleted branch is\n * provably dead and recoverable by hash.\n */\n@injectable(bindingScopeValues.Singleton)\nexport class CleanupCommand {\n constructor(\n private readonly repoRootFinder: RepoRootFinder,\n private readonly branchReaper: BranchReaper,\n ) {}\n\n run(): Promise<void> {\n const repoRoot = this.repoRootFinder.resolveRepoRoot(process.cwd());\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');\n process.stdout.write(this.report(result));\n return Promise.resolve();\n }\n\n private report(result: ReapResult): string {\n if (result.reaped.length === 0 && result.failed.length === 0) {\n return '\\n✅ Nothing to clean up — no local branch is provably dead.\\n' + this.sparedBlock(result.spared);\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 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/hooks/branch-mutations.log —\\n'\n + 'recover any of them with the `recover=` command on its line.\\n';\n return out + this.sparedBlock(result.spared);\n }\n\n private reapedLine(entry: ReapedBranch): string {\n const sha = entry.sha !== '' ? ` (was ${entry.sha.slice(0, 8)})` : '';\n return ` ✓ ${entry.branch}${sha} — ${entry.reason}\\n`;\n }\n\n // The branches we refused to touch, with reasons. Silence here would read as \"there was nothing\n // else\", when these are precisely the ones only a human can rule on.\n private sparedBlock(spared: DeletableBranch[]): string {\n if (spared.length === 0) return '';\n let out = `\\nSpared ${String(spared.length)} branch(es) — a human decides on these:\\n`;\n for (const entry of spared) out += ` • ${entry.branch} — ${entry.reason}\\n`;\n return out;\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 ReapResult,\n ReapedBranch,\n RepoRootFinder,\n loadAndValidate,\n CLASSIFICATION_SUPERSEDED,\n CLASSIFICATION_CONTENT_IN_MAIN,\n CLASSIFICATION_NEVER_PROPOSED,\n PROMPTABLE_CLASSIFICATIONS,\n} from '@webpieces/rules-config';\nimport { injectable, bindingScopeValues } from 'inversify';\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};\n\n/**\n * wp-cleanup: delete the local branches whose PR is already merged (or that hold no commits), then ASK\n * about the ones that are merely probably-dead.\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 ) {}\n\n async run(): Promise<void> {\n const repoRoot = this.repoRootFinder.resolveRepoRoot(process.cwd());\n const retention = loadAndValidate(repoRoot).prGate.landPr.branchRetention;\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);\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 private report(result: ReapResult): string {\n if (result.reaped.length === 0 && result.failed.length === 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 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/hooks/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 // The spared branches a human can meaningfully rule on, grouped and ordered most-safe first.\n // Branches spared for a MECHANICAL reason (checked out in a worktree) are deliberately excluded:\n // there is no judgement to make, git would simply refuse.\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, a piped agent shell) answers NONE and says so. A prompt\n * nobody can see must never be read as consent, and this is the one place in the tooling where a\n * deletion is not backed by a proof.\n */\n private async askWhichToDelete(promptable: DeletableBranch[]): Promise<DeletableBranch[]> {\n if (process.stdin.isTTY !== true) {\n process.stdout.write(\n '\\nNot a terminal — nothing was deleted and nothing was assumed.\\n'\n + 'Run `pnpm wp-cleanup` in an interactive shell to answer, or delete individually.\\n',\n );\n return [];\n }\n const answer = (await this.question(\n `\\nDelete which? [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 // 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(promptable: DeletableBranch[], answer: string): DeletableBranch[] {\n const out: DeletableBranch[] = [];\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,6 +1,7 @@
1
- import { RepoRootFinder } from '@webpieces/rules-config';
1
+ import { RepoRootFinder, BranchArchiver } from '@webpieces/rules-config';
2
2
  import { AiBranchName } from '../workflow/git-readAiBranchName';
3
3
  import { BranchNaming } from '../workflow/branch-naming';
4
+ import { MergeInfoIndex } from '../workflow/merge-info-index';
4
5
  import { PrMerger } from '../workflow/pr-merger';
5
6
  /**
6
7
  * `wp-land-pr`: squash-merge THIS branch's already-posted PR into main with the compact commit body.
@@ -22,7 +23,23 @@ export declare class LandPrCommand {
22
23
  private readonly aiBranchName;
23
24
  private readonly branchNaming;
24
25
  private readonly prMerger;
25
- constructor(repoRootFinder: RepoRootFinder, aiBranchName: AiBranchName, branchNaming: BranchNaming, prMerger: PrMerger);
26
+ private readonly archiver;
27
+ private readonly mergeInfoIndex;
28
+ constructor(repoRootFinder: RepoRootFinder, aiBranchName: AiBranchName, branchNaming: BranchNaming, prMerger: PrMerger, archiver: BranchArchiver, mergeInfoIndex: MergeInfoIndex);
26
29
  run(): Promise<void>;
30
+ /**
31
+ * The landed branch's post-merge bookkeeping, in one place:
32
+ * 1. ARCHIVE the pre-squash tip as `archive/<date>/<branch>` — the tag makes the original history
33
+ * permanently restorable (`git checkout -b <branch> <tag>` gives back the exact objects) while
34
+ * costing one ref, so the branch itself no longer has to survive as a `*PreMerge` husk that
35
+ * counts toward the branch cap. See BranchArchiver for why a tag beats a patch or the reflog.
36
+ * 2. PROMOTE `merge-info/staged/<feature>/` to `merge-info/merged/<feature>/` and rebuild
37
+ * `index.json`, so `staged/` holds only branches that are still in flight.
38
+ *
39
+ * Never throws: the PR is already merged by the time we get here, and failing the command after a
40
+ * successful merge would report a landed PR as a failure. Problems are reported in the recap.
41
+ */
42
+ private archiveAndPromote;
43
+ private revParse;
27
44
  private prNumberAndTitle;
28
45
  }
@@ -9,6 +9,7 @@ const rules_config_1 = require("@webpieces/rules-config");
9
9
  const inversify_1 = require("inversify");
10
10
  const git_readAiBranchName_1 = require("../workflow/git-readAiBranchName");
11
11
  const branch_naming_1 = require("../workflow/branch-naming");
12
+ const merge_info_index_1 = require("../workflow/merge-info-index");
12
13
  const pr_merger_1 = require("../workflow/pr-merger");
13
14
  const SEP = '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n';
14
15
  /**
@@ -31,11 +32,15 @@ let LandPrCommand = class LandPrCommand {
31
32
  aiBranchName;
32
33
  branchNaming;
33
34
  prMerger;
34
- constructor(repoRootFinder, aiBranchName, branchNaming, prMerger) {
35
+ archiver;
36
+ mergeInfoIndex;
37
+ constructor(repoRootFinder, aiBranchName, branchNaming, prMerger, archiver, mergeInfoIndex) {
35
38
  this.repoRootFinder = repoRootFinder;
36
39
  this.aiBranchName = aiBranchName;
37
40
  this.branchNaming = branchNaming;
38
41
  this.prMerger = prMerger;
42
+ this.archiver = archiver;
43
+ this.mergeInfoIndex = mergeInfoIndex;
39
44
  }
40
45
  async run() {
41
46
  const repoRoot = this.repoRootFinder.resolveRepoRoot(process.cwd());
@@ -65,15 +70,56 @@ let LandPrCommand = class LandPrCommand {
65
70
  // MERGE_MODE_AUTO is passed explicitly: running this command IS the intent to merge, so it is
66
71
  // not gated on pr-gate.mergeMode (a NONE repo runs this precisely to land one PR by hand).
67
72
  const outcome = this.prMerger.merge(base, `${ref.title} (#${ref.number})`, mergeBodyFile, rules_config_1.MERGE_MODE_AUTO);
68
- const policy = (0, rules_config_1.loadAndValidate)(repoRoot).prGate.mergeMode;
73
+ const config = (0, rules_config_1.loadAndValidate)(repoRoot).prGate;
74
+ const policy = config.mergeMode;
75
+ const archived = outcome.merged
76
+ ? this.archiveAndPromote(repoRoot, base, ref, config.landPr.branchRetention)
77
+ : '';
69
78
  process.stdout.write('\n' + SEP + (outcome.merged ? '✅ Landed\n' : 'ℹ️ Not landed yet\n') + SEP + '\n' +
70
79
  ` ${outcome.message}\n` +
80
+ archived +
71
81
  (outcome.merged ? ' Next: `pnpm wp-cleanup` to delete the merged branch.\n' : '') +
72
82
  (policy === rules_config_1.MERGE_MODE_AUTO
73
83
  ? ''
74
84
  : ` (pr-gate.mergeMode is ${policy} — wp-finish-upsert-pr will keep leaving PRs for a human.)\n`) +
75
85
  '\n');
76
86
  }
87
+ /**
88
+ * The landed branch's post-merge bookkeeping, in one place:
89
+ * 1. ARCHIVE the pre-squash tip as `archive/<date>/<branch>` — the tag makes the original history
90
+ * permanently restorable (`git checkout -b <branch> <tag>` gives back the exact objects) while
91
+ * costing one ref, so the branch itself no longer has to survive as a `*PreMerge` husk that
92
+ * counts toward the branch cap. See BranchArchiver for why a tag beats a patch or the reflog.
93
+ * 2. PROMOTE `merge-info/staged/<feature>/` to `merge-info/merged/<feature>/` and rebuild
94
+ * `index.json`, so `staged/` holds only branches that are still in flight.
95
+ *
96
+ * Never throws: the PR is already merged by the time we get here, and failing the command after a
97
+ * successful merge would report a landed PR as a failure. Problems are reported in the recap.
98
+ */
99
+ archiveAndPromote(repoRoot, base, ref, retention) {
100
+ if (retention === rules_config_1.BRANCH_RETENTION_KEEP)
101
+ return ' Branch retention is "keep" — nothing archived.\n';
102
+ let line = '';
103
+ let tag = '';
104
+ if (retention === rules_config_1.BRANCH_RETENTION_ARCHIVE_TAG) {
105
+ const archive = this.archiver.archive(repoRoot, base);
106
+ tag = archive.tag;
107
+ line = archive.ok
108
+ ? ` Archived ${base} → ${archive.tag} (restore: ${this.archiver.restoreCommand(base, archive.tag)})\n`
109
+ : ` ⚠️ Could not archive ${base}: ${archive.error} — the branch was left alone.\n`;
110
+ }
111
+ const feature = this.aiBranchName.getFeatureName();
112
+ const record = new merge_info_index_1.ArchiveRecord(tag, this.revParse(repoRoot, base), this.revParse(repoRoot, 'origin/main'), Number(ref.number), new Date().toISOString());
113
+ if (this.mergeInfoIndex.promoteToMerged(repoRoot, feature, record)) {
114
+ line += ` merge-info: staged/${feature} → merged/${feature} (index.json rebuilt)\n`;
115
+ }
116
+ return line;
117
+ }
118
+ // Best-effort sha of a ref — '' when it cannot resolve. Recorded in archive.json for provenance.
119
+ revParse(repoRoot, ref) {
120
+ const result = (0, child_process_1.spawnSync)('git', ['rev-parse', ref], { cwd: repoRoot, encoding: 'utf8' });
121
+ return result.status === 0 ? (result.stdout ?? '').trim() : '';
122
+ }
77
123
  // The open PR's number + title for this head branch, or null when there is none. The TITLE comes
78
124
  // from the PR itself, not review.json, so the squash subject matches what a reviewer approved even
79
125
  // if review.json was edited afterwards.
@@ -93,7 +139,9 @@ exports.LandPrCommand = LandPrCommand = tslib_1.__decorate([
93
139
  tslib_1.__metadata("design:paramtypes", [rules_config_1.RepoRootFinder,
94
140
  git_readAiBranchName_1.AiBranchName,
95
141
  branch_naming_1.BranchNaming,
96
- pr_merger_1.PrMerger])
142
+ pr_merger_1.PrMerger,
143
+ rules_config_1.BranchArchiver,
144
+ merge_info_index_1.MergeInfoIndex])
97
145
  ], LandPrCommand);
98
146
  // The open PR's number + title, as read back from GitHub.
99
147
  class PrIdentity {
@@ -1 +1 @@
1
- {"version":3,"file":"land-pr-command.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/pr-gate/src/scripts/commands/land-pr-command.ts"],"names":[],"mappings":"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","sourcesContent":["import { execSync, spawnSync } from 'child_process';\nimport * as fs from 'fs';\nimport * as path from 'path';\nimport { prDirFor, InformAiError, RepoRootFinder, MERGE_MODE_AUTO, loadAndValidate } from '@webpieces/rules-config';\nimport { injectable, bindingScopeValues } from 'inversify';\nimport { AiBranchName } from '../workflow/git-readAiBranchName';\nimport { BranchNaming } from '../workflow/branch-naming';\nimport { PrMerger } from '../workflow/pr-merger';\n\nconst SEP = '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\\n';\n\n/**\n * `wp-land-pr`: squash-merge THIS branch's already-posted PR into main with the compact commit body.\n *\n * It exists because a merge clicked in the GitHub UI cannot produce that body. A UI merge is limited\n * to the repo's squash_merge_commit_title/message settings, and none of their values yields the\n * shortened risk/flags summary — only an explicit `gh pr merge --subject --body-file` does. So when a\n * merge has to happen outside `wp-finish-upsert-pr` (a mergeMode=NONE repo, or a PR whose checks were\n * still running when finish ran), this is the command that keeps main's history consistent.\n *\n * It deliberately does NOT re-run the build gate or re-render the dashboard: `wp-finish-upsert-pr`\n * already did both and left `merge-commit-body.md` on disk. Landing is a separate, later act, and\n * rebuilding here would mean a second authoritative gate whose result nobody reads. If that file is\n * missing, the honest answer is that finish has not run — so this fails and says so, rather than\n * inventing a body that would not match the PR.\n */\n@injectable(bindingScopeValues.Singleton)\nexport class LandPrCommand {\n constructor(\n private readonly repoRootFinder: RepoRootFinder,\n private readonly aiBranchName: AiBranchName,\n private readonly branchNaming: BranchNaming,\n private readonly prMerger: PrMerger,\n ) {}\n\n async run(): Promise<void> {\n const repoRoot = this.repoRootFinder.resolveRepoRoot(process.cwd());\n const base = this.branchNaming.baseBranchName(execSync('git branch --show-current', { encoding: 'utf8' }).trim());\n const prDir = prDirFor(repoRoot, this.aiBranchName.getFeatureName());\n const mergeBodyFile = path.join(prDir, 'merge-commit-body.md');\n\n if (!fs.existsSync(mergeBodyFile)) {\n throw new InformAiError(\n '\\n' + SEP + '❌ Nothing to land — no rendered merge body\\n' + SEP + '\\n' +\n `Expected the compact squash-commit body at:\\n ${mergeBodyFile}\\n\\n` +\n 'That file is written by `pnpm wp-finish-upsert-pr`, which is also what posts the PR.\\n' +\n 'Its absence means finish has not run on this branch, so there is no reviewed PR to\\n' +\n 'land. Run the gated flow first:\\n\\n' +\n ' pnpm wp-start-upsert-pr # update from main, push, build gate\\n' +\n ' # write .webpieces/review.json\\n' +\n ' pnpm wp-finish-upsert-pr # build gate, dashboard, create/update the PR\\n\\n' +\n 'Then re-run `pnpm wp-land-pr` if the PR still needs landing.\\n' + SEP,\n );\n }\n\n const ref = this.prNumberAndTitle(base);\n if (ref === null) {\n throw new InformAiError(\n '\\n' + SEP + '❌ No open PR found for this branch\\n' + SEP + '\\n' +\n `No open PR has head branch \"${base}\". Nothing to land.\\n` +\n 'If the PR is already merged, run `pnpm wp-cleanup`.\\n' + SEP,\n );\n }\n\n process.stdout.write('\\n' + SEP + `🚀 Landing PR #${ref.number}\\n` + SEP + '\\n');\n // Reuse the SAME merge logic wp-finish-upsert-pr uses, so a PR lands identically whichever\n // command lands it — including the auto-merge fallback when the checks are still running.\n // MERGE_MODE_AUTO is passed explicitly: running this command IS the intent to merge, so it is\n // not gated on pr-gate.mergeMode (a NONE repo runs this precisely to land one PR by hand).\n const outcome = this.prMerger.merge(base, `${ref.title} (#${ref.number})`, mergeBodyFile, MERGE_MODE_AUTO);\n\n const policy = loadAndValidate(repoRoot).prGate.mergeMode;\n process.stdout.write(\n '\\n' + SEP + (outcome.merged ? '✅ Landed\\n' : 'ℹ️ Not landed yet\\n') + SEP + '\\n' +\n ` ${outcome.message}\\n` +\n (outcome.merged ? ' Next: `pnpm wp-cleanup` to delete the merged branch.\\n' : '') +\n (policy === MERGE_MODE_AUTO\n ? ''\n : ` (pr-gate.mergeMode is ${policy} — wp-finish-upsert-pr will keep leaving PRs for a human.)\\n`) +\n '\\n',\n );\n }\n\n // The open PR's number + title for this head branch, or null when there is none. The TITLE comes\n // from the PR itself, not review.json, so the squash subject matches what a reviewer approved even\n // if review.json was edited afterwards.\n private prNumberAndTitle(baseBranch: string): PrIdentity | null {\n const result = spawnSync(\n 'gh', ['pr', 'view', baseBranch, '--json', 'number,title', '--jq', '\"\\\\(.number)\\\\t\\\\(.title)\"'],\n { encoding: 'utf8' },\n );\n if (result.status !== 0) return null;\n const parts = (result.stdout ?? '').trim().split('\\t');\n if ((parts[0] ?? '') === '') return null;\n return new PrIdentity(parts[0] ?? '', parts[1] ?? '');\n }\n}\n\n// The open PR's number + title, as read back from GitHub.\nclass PrIdentity {\n number: string;\n title: string;\n\n constructor(number: string, title: string) {\n this.number = number;\n this.title = title;\n }\n}\n"]}
1
+ {"version":3,"file":"land-pr-command.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/pr-gate/src/scripts/commands/land-pr-command.ts"],"names":[],"mappings":"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","sourcesContent":["import { execSync, spawnSync } from 'child_process';\nimport * as fs from 'fs';\nimport * as path from 'path';\nimport {\n prDirFor, InformAiError, RepoRootFinder, MERGE_MODE_AUTO, loadAndValidate,\n BranchArchiver, BRANCH_RETENTION_ARCHIVE_TAG, BRANCH_RETENTION_KEEP,\n} from '@webpieces/rules-config';\nimport { injectable, bindingScopeValues } from 'inversify';\nimport { AiBranchName } from '../workflow/git-readAiBranchName';\nimport { BranchNaming } from '../workflow/branch-naming';\nimport { ArchiveRecord, MergeInfoIndex } from '../workflow/merge-info-index';\nimport { PrMerger } from '../workflow/pr-merger';\n\nconst SEP = '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\\n';\n\n/**\n * `wp-land-pr`: squash-merge THIS branch's already-posted PR into main with the compact commit body.\n *\n * It exists because a merge clicked in the GitHub UI cannot produce that body. A UI merge is limited\n * to the repo's squash_merge_commit_title/message settings, and none of their values yields the\n * shortened risk/flags summary — only an explicit `gh pr merge --subject --body-file` does. So when a\n * merge has to happen outside `wp-finish-upsert-pr` (a mergeMode=NONE repo, or a PR whose checks were\n * still running when finish ran), this is the command that keeps main's history consistent.\n *\n * It deliberately does NOT re-run the build gate or re-render the dashboard: `wp-finish-upsert-pr`\n * already did both and left `merge-commit-body.md` on disk. Landing is a separate, later act, and\n * rebuilding here would mean a second authoritative gate whose result nobody reads. If that file is\n * missing, the honest answer is that finish has not run — so this fails and says so, rather than\n * inventing a body that would not match the PR.\n */\n@injectable(bindingScopeValues.Singleton)\nexport class LandPrCommand {\n constructor(\n private readonly repoRootFinder: RepoRootFinder,\n private readonly aiBranchName: AiBranchName,\n private readonly branchNaming: BranchNaming,\n private readonly prMerger: PrMerger,\n private readonly archiver: BranchArchiver,\n private readonly mergeInfoIndex: MergeInfoIndex,\n ) {}\n\n async run(): Promise<void> {\n const repoRoot = this.repoRootFinder.resolveRepoRoot(process.cwd());\n const base = this.branchNaming.baseBranchName(execSync('git branch --show-current', { encoding: 'utf8' }).trim());\n const prDir = prDirFor(repoRoot, this.aiBranchName.getFeatureName());\n const mergeBodyFile = path.join(prDir, 'merge-commit-body.md');\n\n if (!fs.existsSync(mergeBodyFile)) {\n throw new InformAiError(\n '\\n' + SEP + '❌ Nothing to land — no rendered merge body\\n' + SEP + '\\n' +\n `Expected the compact squash-commit body at:\\n ${mergeBodyFile}\\n\\n` +\n 'That file is written by `pnpm wp-finish-upsert-pr`, which is also what posts the PR.\\n' +\n 'Its absence means finish has not run on this branch, so there is no reviewed PR to\\n' +\n 'land. Run the gated flow first:\\n\\n' +\n ' pnpm wp-start-upsert-pr # update from main, push, build gate\\n' +\n ' # write .webpieces/review.json\\n' +\n ' pnpm wp-finish-upsert-pr # build gate, dashboard, create/update the PR\\n\\n' +\n 'Then re-run `pnpm wp-land-pr` if the PR still needs landing.\\n' + SEP,\n );\n }\n\n const ref = this.prNumberAndTitle(base);\n if (ref === null) {\n throw new InformAiError(\n '\\n' + SEP + '❌ No open PR found for this branch\\n' + SEP + '\\n' +\n `No open PR has head branch \"${base}\". Nothing to land.\\n` +\n 'If the PR is already merged, run `pnpm wp-cleanup`.\\n' + SEP,\n );\n }\n\n process.stdout.write('\\n' + SEP + `🚀 Landing PR #${ref.number}\\n` + SEP + '\\n');\n // Reuse the SAME merge logic wp-finish-upsert-pr uses, so a PR lands identically whichever\n // command lands it — including the auto-merge fallback when the checks are still running.\n // MERGE_MODE_AUTO is passed explicitly: running this command IS the intent to merge, so it is\n // not gated on pr-gate.mergeMode (a NONE repo runs this precisely to land one PR by hand).\n const outcome = this.prMerger.merge(base, `${ref.title} (#${ref.number})`, mergeBodyFile, MERGE_MODE_AUTO);\n\n const config = loadAndValidate(repoRoot).prGate;\n const policy = config.mergeMode;\n const archived = outcome.merged\n ? this.archiveAndPromote(repoRoot, base, ref, config.landPr.branchRetention)\n : '';\n process.stdout.write(\n '\\n' + SEP + (outcome.merged ? '✅ Landed\\n' : 'ℹ️ Not landed yet\\n') + SEP + '\\n' +\n ` ${outcome.message}\\n` +\n archived +\n (outcome.merged ? ' Next: `pnpm wp-cleanup` to delete the merged branch.\\n' : '') +\n (policy === MERGE_MODE_AUTO\n ? ''\n : ` (pr-gate.mergeMode is ${policy} — wp-finish-upsert-pr will keep leaving PRs for a human.)\\n`) +\n '\\n',\n );\n }\n\n /**\n * The landed branch's post-merge bookkeeping, in one place:\n * 1. ARCHIVE the pre-squash tip as `archive/<date>/<branch>` — the tag makes the original history\n * permanently restorable (`git checkout -b <branch> <tag>` gives back the exact objects) while\n * costing one ref, so the branch itself no longer has to survive as a `*PreMerge` husk that\n * counts toward the branch cap. See BranchArchiver for why a tag beats a patch or the reflog.\n * 2. PROMOTE `merge-info/staged/<feature>/` to `merge-info/merged/<feature>/` and rebuild\n * `index.json`, so `staged/` holds only branches that are still in flight.\n *\n * Never throws: the PR is already merged by the time we get here, and failing the command after a\n * successful merge would report a landed PR as a failure. Problems are reported in the recap.\n */\n private archiveAndPromote(repoRoot: string, base: string, ref: PrIdentity, retention: string): string {\n if (retention === BRANCH_RETENTION_KEEP) return ' Branch retention is \"keep\" — nothing archived.\\n';\n\n let line = '';\n let tag = '';\n if (retention === BRANCH_RETENTION_ARCHIVE_TAG) {\n const archive = this.archiver.archive(repoRoot, base);\n tag = archive.tag;\n line = archive.ok\n ? ` Archived ${base} → ${archive.tag} (restore: ${this.archiver.restoreCommand(base, archive.tag)})\\n`\n : ` ⚠️ Could not archive ${base}: ${archive.error} — the branch was left alone.\\n`;\n }\n\n const feature = this.aiBranchName.getFeatureName();\n const record = new ArchiveRecord(\n tag, this.revParse(repoRoot, base), this.revParse(repoRoot, 'origin/main'),\n Number(ref.number), new Date().toISOString(),\n );\n if (this.mergeInfoIndex.promoteToMerged(repoRoot, feature, record)) {\n line += ` merge-info: staged/${feature} → merged/${feature} (index.json rebuilt)\\n`;\n }\n return line;\n }\n\n // Best-effort sha of a ref — '' when it cannot resolve. Recorded in archive.json for provenance.\n private revParse(repoRoot: string, ref: string): string {\n const result = spawnSync('git', ['rev-parse', ref], { cwd: repoRoot, encoding: 'utf8' });\n return result.status === 0 ? (result.stdout ?? '').trim() : '';\n }\n\n // The open PR's number + title for this head branch, or null when there is none. The TITLE comes\n // from the PR itself, not review.json, so the squash subject matches what a reviewer approved even\n // if review.json was edited afterwards.\n private prNumberAndTitle(baseBranch: string): PrIdentity | null {\n const result = spawnSync(\n 'gh', ['pr', 'view', baseBranch, '--json', 'number,title', '--jq', '\"\\\\(.number)\\\\t\\\\(.title)\"'],\n { encoding: 'utf8' },\n );\n if (result.status !== 0) return null;\n const parts = (result.stdout ?? '').trim().split('\\t');\n if ((parts[0] ?? '') === '') return null;\n return new PrIdentity(parts[0] ?? '', parts[1] ?? '');\n }\n}\n\n// The open PR's number + title, as read back from GitHub.\nclass PrIdentity {\n number: string;\n title: string;\n\n constructor(number: string, title: string) {\n this.number = number;\n this.title = title;\n }\n}\n"]}
@@ -51,7 +51,10 @@ let CleanTmp = class CleanTmp {
51
51
  process.stdout.write('\n');
52
52
  const cutoffMs = CUTOFF_DAYS * 24 * 60 * 60 * 1000;
53
53
  const now = Date.now();
54
- // `true` => this is the root, which is swept into but never itself removed.
54
+ // `true` => this is the root, which is swept into but never itself removed. This generic
55
+ // depth-first sweep needs NO knowledge of the merge-info layout — including the new
56
+ // staged/<feature> and merged/<feature> split, which sits one level deeper than the old
57
+ // per-feature dirs. That is precisely why it replaced the per-home special-casing.
55
58
  const result = this.sweep(tmpBase, tmpBase, now, cutoffMs, true);
56
59
  if (result.files === 0 && result.dirs === 0) {
57
60
  process.stdout.write(` ✅ Nothing older than ${CUTOFF_DAYS} days; no empty directories\n`);
@@ -1 +1 @@
1
- {"version":3,"file":"cleanTmp.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/pr-gate/src/scripts/workflow/cleanTmp.ts"],"names":[],"mappings":"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","sourcesContent":["import * as fs from 'fs';\nimport * as path from 'path';\nimport { WEBPIECES_TMP_DIR, RepoRootFinder } from '@webpieces/rules-config';\nimport { injectable, bindingScopeValues } from 'inversify';\n\nconst CUTOFF_DAYS = 30;\nconst SEP = '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\\n';\n\n// Result of one sweep: how many files were reaped for age, and how many now-empty dirs were pruned.\nclass SweepResult {\n constructor(\n public files: number,\n public dirs: number,\n ) {}\n}\n\n/**\n * 30-day garbage collection of the whole `.webpieces` tree.\n *\n * Runs at the end of every merge/PR flow (see merge-end.ts). It walks `.webpieces` depth-first and:\n * 1. deletes ANY file whose mtime is older than the cutoff, anywhere in the tree, and\n * 2. removes ANY directory that is left empty afterwards (including dirs that were already empty).\n *\n * The `.webpieces` root itself is never removed. Everything the tooling still needs is rewritten on\n * each run under a fresh mtime (instruct-ai/ docs, the active hooks/ logs, the *-status.json state\n * files), and every writer `mkdirSync(..., { recursive: true })`s its target first — so pruning a\n * stale home dir is harmless; the next write recreates it. Stale per-feature merge-info/pr-review\n * subdirs and the retired legacy flat layout all fall out of this single pass with no special-casing.\n */\n@injectable(bindingScopeValues.Singleton)\nexport class CleanTmp {\n constructor(private readonly repoRootFinder: RepoRootFinder) {}\n\n async cleanTmp(): Promise<void> {\n const repoRoot = this.repoRootFinder.resolveRepoRoot(process.cwd());\n const tmpBase = path.join(repoRoot, WEBPIECES_TMP_DIR);\n\n if (!fs.existsSync(tmpBase)) {\n return;\n }\n\n process.stdout.write('\\n');\n process.stdout.write(SEP);\n process.stdout.write('🧹 Garbage-Collecting .webpieces\\n');\n process.stdout.write(SEP);\n process.stdout.write('\\n');\n process.stdout.write(`Location: ${tmpBase}\\n`);\n process.stdout.write(`Retention: ${CUTOFF_DAYS} days (older files reaped; empty dirs pruned)\\n`);\n process.stdout.write('\\n');\n\n const cutoffMs = CUTOFF_DAYS * 24 * 60 * 60 * 1000;\n const now = Date.now();\n\n // `true` => this is the root, which is swept into but never itself removed.\n const result = this.sweep(tmpBase, tmpBase, now, cutoffMs, true);\n\n if (result.files === 0 && result.dirs === 0) {\n process.stdout.write(` ✅ Nothing older than ${CUTOFF_DAYS} days; no empty directories\\n`);\n } else {\n const fileWord = result.files === 1 ? 'file' : 'files';\n const dirWord = result.dirs === 1 ? 'directory' : 'directories';\n process.stdout.write('\\n');\n process.stdout.write(` ✅ Reaped ${result.files} old ${fileWord} and pruned ${result.dirs} empty ${dirWord}\\n`);\n }\n\n process.stdout.write('\\n');\n process.stdout.write(SEP);\n process.stdout.write('\\n');\n }\n\n // Depth-first, post-order sweep of `dir` (rooted at `tmpBase`, only used for tidy relative logging).\n // Files older than the cutoff are deleted; a directory left empty once its children are processed is\n // pruned — except the root, which is kept even when empty so `.webpieces/` itself survives.\n private sweep(dir: string, tmpBase: string, now: number, cutoffMs: number, isRoot: boolean): SweepResult {\n let files = 0;\n let dirs = 0;\n\n for (const entry of fs.readdirSync(dir)) {\n const fullPath = path.join(dir, entry);\n // lstat, not stat: a symlink is treated as a leaf (aged out like a file), never followed —\n // so we can never wander outside `.webpieces` or delete a link target elsewhere.\n const stat = fs.lstatSync(fullPath);\n if (stat.isDirectory()) {\n const nested = this.sweep(fullPath, tmpBase, now, cutoffMs, false);\n files += nested.files;\n dirs += nested.dirs;\n } else if (now - stat.mtimeMs >= cutoffMs) {\n process.stdout.write(` 🗑️ file: ${path.relative(tmpBase, fullPath)}\\n`);\n fs.rmSync(fullPath, { force: true });\n files += 1;\n }\n }\n\n // Post-order: prune this dir if reaping its children (or nothing) left it empty.\n if (!isRoot && fs.readdirSync(dir).length === 0) {\n process.stdout.write(` 🗑️ dir: ${path.relative(tmpBase, dir)}/\\n`);\n fs.rmdirSync(dir);\n dirs += 1;\n }\n\n return new SweepResult(files, dirs);\n }\n}\n"]}
1
+ {"version":3,"file":"cleanTmp.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/pr-gate/src/scripts/workflow/cleanTmp.ts"],"names":[],"mappings":"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","sourcesContent":["import * as fs from 'fs';\nimport * as path from 'path';\nimport { WEBPIECES_TMP_DIR, RepoRootFinder } from '@webpieces/rules-config';\nimport { injectable, bindingScopeValues } from 'inversify';\n\nconst CUTOFF_DAYS = 30;\nconst SEP = '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\\n';\n\n// Result of one sweep: how many files were reaped for age, and how many now-empty dirs were pruned.\nclass SweepResult {\n constructor(\n public files: number,\n public dirs: number,\n ) {}\n}\n\n/**\n * 30-day garbage collection of the whole `.webpieces` tree.\n *\n * Runs at the end of every merge/PR flow (see merge-end.ts). It walks `.webpieces` depth-first and:\n * 1. deletes ANY file whose mtime is older than the cutoff, anywhere in the tree, and\n * 2. removes ANY directory that is left empty afterwards (including dirs that were already empty).\n *\n * The `.webpieces` root itself is never removed. Everything the tooling still needs is rewritten on\n * each run under a fresh mtime (instruct-ai/ docs, the active hooks/ logs, the *-status.json state\n * files), and every writer `mkdirSync(..., { recursive: true })`s its target first — so pruning a\n * stale home dir is harmless; the next write recreates it. Stale per-feature merge-info/pr-review\n * subdirs and the retired legacy flat layout all fall out of this single pass with no special-casing.\n */\n@injectable(bindingScopeValues.Singleton)\nexport class CleanTmp {\n constructor(private readonly repoRootFinder: RepoRootFinder) {}\n\n async cleanTmp(): Promise<void> {\n const repoRoot = this.repoRootFinder.resolveRepoRoot(process.cwd());\n const tmpBase = path.join(repoRoot, WEBPIECES_TMP_DIR);\n\n if (!fs.existsSync(tmpBase)) {\n return;\n }\n\n process.stdout.write('\\n');\n process.stdout.write(SEP);\n process.stdout.write('🧹 Garbage-Collecting .webpieces\\n');\n process.stdout.write(SEP);\n process.stdout.write('\\n');\n process.stdout.write(`Location: ${tmpBase}\\n`);\n process.stdout.write(`Retention: ${CUTOFF_DAYS} days (older files reaped; empty dirs pruned)\\n`);\n process.stdout.write('\\n');\n\n const cutoffMs = CUTOFF_DAYS * 24 * 60 * 60 * 1000;\n const now = Date.now();\n\n // `true` => this is the root, which is swept into but never itself removed. This generic\n // depth-first sweep needs NO knowledge of the merge-info layout — including the new\n // staged/<feature> and merged/<feature> split, which sits one level deeper than the old\n // per-feature dirs. That is precisely why it replaced the per-home special-casing.\n const result = this.sweep(tmpBase, tmpBase, now, cutoffMs, true);\n\n if (result.files === 0 && result.dirs === 0) {\n process.stdout.write(` ✅ Nothing older than ${CUTOFF_DAYS} days; no empty directories\\n`);\n } else {\n const fileWord = result.files === 1 ? 'file' : 'files';\n const dirWord = result.dirs === 1 ? 'directory' : 'directories';\n process.stdout.write('\\n');\n process.stdout.write(` ✅ Reaped ${result.files} old ${fileWord} and pruned ${result.dirs} empty ${dirWord}\\n`);\n }\n\n process.stdout.write('\\n');\n process.stdout.write(SEP);\n process.stdout.write('\\n');\n }\n\n // Depth-first, post-order sweep of `dir` (rooted at `tmpBase`, only used for tidy relative logging).\n // Files older than the cutoff are deleted; a directory left empty once its children are processed is\n // pruned — except the root, which is kept even when empty so `.webpieces/` itself survives.\n private sweep(dir: string, tmpBase: string, now: number, cutoffMs: number, isRoot: boolean): SweepResult {\n let files = 0;\n let dirs = 0;\n\n for (const entry of fs.readdirSync(dir)) {\n const fullPath = path.join(dir, entry);\n // lstat, not stat: a symlink is treated as a leaf (aged out like a file), never followed —\n // so we can never wander outside `.webpieces` or delete a link target elsewhere.\n const stat = fs.lstatSync(fullPath);\n if (stat.isDirectory()) {\n const nested = this.sweep(fullPath, tmpBase, now, cutoffMs, false);\n files += nested.files;\n dirs += nested.dirs;\n } else if (now - stat.mtimeMs >= cutoffMs) {\n process.stdout.write(` 🗑️ file: ${path.relative(tmpBase, fullPath)}\\n`);\n fs.rmSync(fullPath, { force: true });\n files += 1;\n }\n }\n\n // Post-order: prune this dir if reaping its children (or nothing) left it empty.\n if (!isRoot && fs.readdirSync(dir).length === 0) {\n process.stdout.write(` 🗑️ dir: ${path.relative(tmpBase, dir)}/\\n`);\n fs.rmdirSync(dir);\n dirs += 1;\n }\n\n return new SweepResult(files, dirs);\n }\n}\n"]}
@@ -0,0 +1,60 @@
1
+ import { MergeState } from './merge-state';
2
+ /**
3
+ * `.webpieces/merge-info/index.json` — the one answer to "which merges, across ALL branches, were
4
+ * 3-point?", which is the actual review question.
5
+ *
6
+ * WHY AN INDEX IS REQUIRED and a directory name will not do: a single branch can alternate
7
+ * clean → 3-point → clean → 3-point across its syncs. So the clean/3-point axis belongs to the MERGE,
8
+ * not the branch, and cannot be encoded in `merged/<branch>/`. With the index, one jq lists every
9
+ * 3-point merge in the repo and stays correct for alternating branches:
10
+ *
11
+ * jq -r '.merged | to_entries[] | .key as $b | .value.merges[] |
12
+ * select(.threeWay) | "\($b) merge-\(.n)"' .webpieces/merge-info/index.json
13
+ *
14
+ * WHY IT IS DERIVED, NOT INCREMENTALLY WRITTEN: every fact in it already exists on disk — `threeWay`
15
+ * is the presence of `conflicts.md` in the run dir, `conflicts` is that file's list, and the archive
16
+ * fields come from `archive.json`. Rebuilding by scanning `merged/` makes the index a pure projection
17
+ * that cannot drift from the directories, and makes a lost or corrupt index self-healing.
18
+ */
19
+ export declare class MergeRecord {
20
+ n: number;
21
+ threeWay: boolean;
22
+ conflicts: string[];
23
+ constructor(n: number, threeWay: boolean, conflicts: string[]);
24
+ }
25
+ export declare class ArchiveRecord {
26
+ archiveTag: string;
27
+ tipSha: string;
28
+ baseSha: string;
29
+ pr: number;
30
+ mergedAt: string;
31
+ constructor(archiveTag: string, tipSha: string, baseSha: string, pr: number, mergedAt: string);
32
+ }
33
+ export declare class MergedBranchEntry {
34
+ pr: number;
35
+ archiveTag: string;
36
+ merges: MergeRecord[];
37
+ constructor(pr: number, archiveTag: string, merges: MergeRecord[]);
38
+ }
39
+ export declare class MergeInfoIndexFile {
40
+ merged: Record<string, MergedBranchEntry>;
41
+ constructor(merged: Record<string, MergedBranchEntry>);
42
+ }
43
+ export declare class MergeInfoIndex {
44
+ private readonly mergeState;
45
+ constructor(mergeState: MergeState);
46
+ indexPath(repoRoot: string): string;
47
+ /**
48
+ * MOVE a landed feature's whole `staged/<feature>/` tree to `merged/<feature>/`, drop its
49
+ * `archive.json` in, and rebuild the index. This is what makes `staged/` self-cleaning: it holds
50
+ * only branches still in flight, instead of growing for the life of the repo.
51
+ *
52
+ * Returns false when there was nothing staged to promote (a branch that landed without ever syncing
53
+ * from main), which is a normal outcome and not an error.
54
+ */
55
+ promoteToMerged(repoRoot: string, featureName: string, archive: ArchiveRecord): boolean;
56
+ /** Rebuild `index.json` by scanning `merged/` from scratch. Pure projection of what is on disk. */
57
+ rebuildIndex(repoRoot: string): MergeInfoIndexFile;
58
+ private entryFor;
59
+ private readArchive;
60
+ }