@webpieces/pr-gate 0.4.479 → 0.4.481
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 +2 -2
- package/src/scripts/commands/finish-update-command.js +2 -1
- package/src/scripts/commands/finish-update-command.js.map +1 -1
- package/src/scripts/commands/finish-upsert-pr-command.d.ts +1 -0
- package/src/scripts/commands/finish-upsert-pr-command.js +12 -6
- package/src/scripts/commands/finish-upsert-pr-command.js.map +1 -1
- package/src/scripts/commands/start-update-command.js +3 -1
- package/src/scripts/commands/start-update-command.js.map +1 -1
- package/src/scripts/commands/start-upsert-pr-command.d.ts +5 -1
- package/src/scripts/commands/start-upsert-pr-command.js +42 -19
- package/src/scripts/commands/start-upsert-pr-command.js.map +1 -1
- package/src/scripts/workflow/checklist-notice.d.ts +34 -0
- package/src/scripts/workflow/checklist-notice.js +86 -0
- package/src/scripts/workflow/checklist-notice.js.map +1 -0
- package/src/scripts/workflow/gated-pr-publisher.d.ts +6 -6
- package/src/scripts/workflow/gated-pr-publisher.js +6 -6
- package/src/scripts/workflow/gated-pr-publisher.js.map +1 -1
- package/src/scripts/workflow/merge-end.d.ts +24 -5
- package/src/scripts/workflow/merge-end.js +95 -25
- package/src/scripts/workflow/merge-end.js.map +1 -1
- package/src/scripts/workflow/run-update.d.ts +1 -1
- package/src/scripts/workflow/run-update.js +9 -6
- package/src/scripts/workflow/run-update.js.map +1 -1
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ChecklistNotice = void 0;
|
|
4
|
+
const tslib_1 = require("tslib");
|
|
5
|
+
const inversify_1 = require("inversify");
|
|
6
|
+
/**
|
|
7
|
+
* Builds the message `wp-start-upsert-pr` prints when a diff matched ZERO review checklists.
|
|
8
|
+
*
|
|
9
|
+
* ZERO IS A VALID, SUPPORTED STATE — it is NOT an error and NEVER blocks the flow. This exists only
|
|
10
|
+
* because the previous behavior was to print nothing at all, which is indistinguishable from "the
|
|
11
|
+
* checklist ran and passed". Silence is the bug; refusing would be a worse one.
|
|
12
|
+
*
|
|
13
|
+
* The three empty cases need different fixes, so they get different text:
|
|
14
|
+
* - NONE CONFIGURED — the repo has no checklists.doc. Perfectly fine; mention that a human MAY add
|
|
15
|
+
* checklist *.md docs if they want reviews, and move on.
|
|
16
|
+
* - MISCONFIGURED — checklists.doc is set but missing/malformed. This one is worth shouting about:
|
|
17
|
+
* the tolerant loader returns [] for it, so a broken doc silently enforces
|
|
18
|
+
* NOTHING while looking configured.
|
|
19
|
+
* - NONE MATCHED — checklists exist and are valid, but none of their patterns hit this diff.
|
|
20
|
+
* Also fine; report the count so a human can judge whether that is expected.
|
|
21
|
+
*
|
|
22
|
+
* Pure string building, no I/O, so it is unit-testable without git or a repo.
|
|
23
|
+
* `@injectable(bindingScopeValues.Singleton)` so it is injected by type and drawn in the DI design.
|
|
24
|
+
*/
|
|
25
|
+
let ChecklistNotice = class ChecklistNotice {
|
|
26
|
+
/**
|
|
27
|
+
* @param docRel `prGate.checklistDoc` ('' when the repo configured none)
|
|
28
|
+
* @param manifestErrors `ChecklistManifestService.validate()` output ([] when valid or unconfigured)
|
|
29
|
+
* @param definedCount how many checklists the manifest defines
|
|
30
|
+
* @param finishCommand the command to continue with, named in every branch
|
|
31
|
+
*/
|
|
32
|
+
// eslint-disable-next-line @typescript-eslint/max-params
|
|
33
|
+
build(docRel, manifestErrors, definedCount, finishCommand) {
|
|
34
|
+
return `${this.reason(docRel, manifestErrors, definedCount)}\n${this.continueLine(finishCommand)}`;
|
|
35
|
+
}
|
|
36
|
+
reason(docRel, manifestErrors, definedCount) {
|
|
37
|
+
if (manifestErrors.length > 0)
|
|
38
|
+
return this.misconfigured(docRel, manifestErrors);
|
|
39
|
+
if (docRel.trim() === '')
|
|
40
|
+
return this.noneConfigured();
|
|
41
|
+
if (definedCount === 0)
|
|
42
|
+
return this.emptyManifest(docRel);
|
|
43
|
+
return this.noneMatched(docRel, definedCount);
|
|
44
|
+
}
|
|
45
|
+
// Every branch ends here: 0 checklists is OK, keep going. Stated plainly so neither the AI nor the
|
|
46
|
+
// human reads the notice as a gate that needs satisfying before finishing.
|
|
47
|
+
continueLine(finishCommand) {
|
|
48
|
+
return (`\n✅ Zero checklists is a perfectly valid state — this is INFORMATION, not a blocker, and\n` +
|
|
49
|
+
` nothing here needs fixing before you continue. Carry on and run: pnpm ${finishCommand}\n`);
|
|
50
|
+
}
|
|
51
|
+
noneConfigured() {
|
|
52
|
+
return ('📋 Review checklists: NONE CONFIGURED (0 ran) — that is fine, this repo simply has none.\n' +
|
|
53
|
+
'\n' +
|
|
54
|
+
' FYI for the human: if you ever want per-area reviews enforced on PRs that touch certain\n' +
|
|
55
|
+
' paths, add checklist *.md docs and point webpieces.config.json at an index doc:\n' +
|
|
56
|
+
' "commands": { "pr-gate": { "checklists": { "doc": ".claude/review/index.md" } } }\n' +
|
|
57
|
+
' That index doc carries the manifest naming each checklist and which paths trigger it:\n' +
|
|
58
|
+
' <!-- webpieces:checklists\n' +
|
|
59
|
+
' [ { "subagent": "db-migration-reviewer", "doc": "db-migrations.md",\n' +
|
|
60
|
+
' "patterns": ["**/migrations/**", "**/*.sql"] } ]\n' +
|
|
61
|
+
' -->\n' +
|
|
62
|
+
' Each entry needs its OWN reviewer subagent — that is how independent review is enforced.');
|
|
63
|
+
}
|
|
64
|
+
emptyManifest(docRel) {
|
|
65
|
+
return (`📋 Review checklists: 0 defined — "${docRel}" is readable but its manifest lists no usable\n` +
|
|
66
|
+
' checklists (every entry needs a non-empty "subagent"). Fine to proceed; worth a look if\n' +
|
|
67
|
+
' you expected some to run.');
|
|
68
|
+
}
|
|
69
|
+
// The one case that deserves volume: it LOOKS configured but enforces nothing.
|
|
70
|
+
misconfigured(docRel, manifestErrors) {
|
|
71
|
+
return (`⚠️ Review checklists: 0 ran because "${docRel}" is MISCONFIGURED — so this PR is getting NO\n` +
|
|
72
|
+
' checklist review even though this repo asked for one. Not fatal, but almost certainly not\n' +
|
|
73
|
+
' what you want:\n\n' +
|
|
74
|
+
manifestErrors.map((e) => ` • ${e}`).join('\n'));
|
|
75
|
+
}
|
|
76
|
+
noneMatched(docRel, definedCount) {
|
|
77
|
+
return (`📋 Review checklists: ${definedCount} defined in "${docRel}", 0 matched this diff — none of their\n` +
|
|
78
|
+
' path patterns hit a changed file. Expected for changes outside those areas; if you thought\n' +
|
|
79
|
+
' one should have run, check its "patterns" against the changed-file list above.');
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
exports.ChecklistNotice = ChecklistNotice;
|
|
83
|
+
exports.ChecklistNotice = ChecklistNotice = tslib_1.__decorate([
|
|
84
|
+
(0, inversify_1.injectable)(inversify_1.bindingScopeValues.Singleton)
|
|
85
|
+
], ChecklistNotice);
|
|
86
|
+
//# sourceMappingURL=checklist-notice.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"checklist-notice.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/pr-gate/src/scripts/workflow/checklist-notice.ts"],"names":[],"mappings":";;;;AAAA,yCAA2D;AAE3D;;;;;;;;;;;;;;;;;;GAkBG;AAEI,IAAM,eAAe,GAArB,MAAM,eAAe;IACxB;;;;;OAKG;IACH,yDAAyD;IACzD,KAAK,CAAC,MAAc,EAAE,cAAiC,EAAE,YAAoB,EAAE,aAAqB;QAChG,OAAO,GAAG,IAAI,CAAC,MAAM,CAAC,MAAM,EAAE,cAAc,EAAE,YAAY,CAAC,KAAK,IAAI,CAAC,YAAY,CAAC,aAAa,CAAC,EAAE,CAAC;IACvG,CAAC;IAEO,MAAM,CAAC,MAAc,EAAE,cAAiC,EAAE,YAAoB;QAClF,IAAI,cAAc,CAAC,MAAM,GAAG,CAAC;YAAE,OAAO,IAAI,CAAC,aAAa,CAAC,MAAM,EAAE,cAAc,CAAC,CAAC;QACjF,IAAI,MAAM,CAAC,IAAI,EAAE,KAAK,EAAE;YAAE,OAAO,IAAI,CAAC,cAAc,EAAE,CAAC;QACvD,IAAI,YAAY,KAAK,CAAC;YAAE,OAAO,IAAI,CAAC,aAAa,CAAC,MAAM,CAAC,CAAC;QAC1D,OAAO,IAAI,CAAC,WAAW,CAAC,MAAM,EAAE,YAAY,CAAC,CAAC;IAClD,CAAC;IAED,mGAAmG;IACnG,2EAA2E;IACnE,YAAY,CAAC,aAAqB;QACtC,OAAO,CACH,4FAA4F;YAC5F,6EAA6E,aAAa,IAAI,CACjG,CAAC;IACN,CAAC;IAEO,cAAc;QAClB,OAAO,CACH,4FAA4F;YAC5F,IAAI;YACJ,8FAA8F;YAC9F,sFAAsF;YACtF,0FAA0F;YAC1F,4FAA4F;YAC5F,kCAAkC;YAClC,4EAA4E;YAC5E,6DAA6D;YAC7D,YAAY;YACZ,6FAA6F,CAChG,CAAC;IACN,CAAC;IAEO,aAAa,CAAC,MAAc;QAChC,OAAO,CACH,sCAAsC,MAAM,kDAAkD;YAC9F,8FAA8F;YAC9F,8BAA8B,CACjC,CAAC;IACN,CAAC;IAED,+EAA+E;IACvE,aAAa,CAAC,MAAc,EAAE,cAAiC;QACnE,OAAO,CACH,yCAAyC,MAAM,iDAAiD;YAChG,gGAAgG;YAChG,uBAAuB;YACvB,cAAc,CAAC,GAAG,CAAC,CAAC,CAAS,EAAU,EAAE,CAAC,UAAU,CAAC,EAAE,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CACtE,CAAC;IACN,CAAC;IAEO,WAAW,CAAC,MAAc,EAAE,YAAoB;QACpD,OAAO,CACH,yBAAyB,YAAY,gBAAgB,MAAM,0CAA0C;YACrG,iGAAiG;YACjG,mFAAmF,CACtF,CAAC;IACN,CAAC;CACJ,CAAA;AArEY,0CAAe;0BAAf,eAAe;IAD3B,IAAA,sBAAU,EAAC,8BAAkB,CAAC,SAAS,CAAC;GAC5B,eAAe,CAqE3B","sourcesContent":["import { injectable, bindingScopeValues } from 'inversify';\n\n/**\n * Builds the message `wp-start-upsert-pr` prints when a diff matched ZERO review checklists.\n *\n * ZERO IS A VALID, SUPPORTED STATE — it is NOT an error and NEVER blocks the flow. This exists only\n * because the previous behavior was to print nothing at all, which is indistinguishable from \"the\n * checklist ran and passed\". Silence is the bug; refusing would be a worse one.\n *\n * The three empty cases need different fixes, so they get different text:\n * - NONE CONFIGURED — the repo has no checklists.doc. Perfectly fine; mention that a human MAY add\n * checklist *.md docs if they want reviews, and move on.\n * - MISCONFIGURED — checklists.doc is set but missing/malformed. This one is worth shouting about:\n * the tolerant loader returns [] for it, so a broken doc silently enforces\n * NOTHING while looking configured.\n * - NONE MATCHED — checklists exist and are valid, but none of their patterns hit this diff.\n * Also fine; report the count so a human can judge whether that is expected.\n *\n * Pure string building, no I/O, so it is unit-testable without git or a repo.\n * `@injectable(bindingScopeValues.Singleton)` so it is injected by type and drawn in the DI design.\n */\n@injectable(bindingScopeValues.Singleton)\nexport class ChecklistNotice {\n /**\n * @param docRel `prGate.checklistDoc` ('' when the repo configured none)\n * @param manifestErrors `ChecklistManifestService.validate()` output ([] when valid or unconfigured)\n * @param definedCount how many checklists the manifest defines\n * @param finishCommand the command to continue with, named in every branch\n */\n // eslint-disable-next-line @typescript-eslint/max-params\n build(docRel: string, manifestErrors: readonly string[], definedCount: number, finishCommand: string): string {\n return `${this.reason(docRel, manifestErrors, definedCount)}\\n${this.continueLine(finishCommand)}`;\n }\n\n private reason(docRel: string, manifestErrors: readonly string[], definedCount: number): string {\n if (manifestErrors.length > 0) return this.misconfigured(docRel, manifestErrors);\n if (docRel.trim() === '') return this.noneConfigured();\n if (definedCount === 0) return this.emptyManifest(docRel);\n return this.noneMatched(docRel, definedCount);\n }\n\n // Every branch ends here: 0 checklists is OK, keep going. Stated plainly so neither the AI nor the\n // human reads the notice as a gate that needs satisfying before finishing.\n private continueLine(finishCommand: string): string {\n return (\n `\\n✅ Zero checklists is a perfectly valid state — this is INFORMATION, not a blocker, and\\n` +\n ` nothing here needs fixing before you continue. Carry on and run: pnpm ${finishCommand}\\n`\n );\n }\n\n private noneConfigured(): string {\n return (\n '📋 Review checklists: NONE CONFIGURED (0 ran) — that is fine, this repo simply has none.\\n' +\n '\\n' +\n ' FYI for the human: if you ever want per-area reviews enforced on PRs that touch certain\\n' +\n ' paths, add checklist *.md docs and point webpieces.config.json at an index doc:\\n' +\n ' \"commands\": { \"pr-gate\": { \"checklists\": { \"doc\": \".claude/review/index.md\" } } }\\n' +\n ' That index doc carries the manifest naming each checklist and which paths trigger it:\\n' +\n ' <!-- webpieces:checklists\\n' +\n ' [ { \"subagent\": \"db-migration-reviewer\", \"doc\": \"db-migrations.md\",\\n' +\n ' \"patterns\": [\"**/migrations/**\", \"**/*.sql\"] } ]\\n' +\n ' -->\\n' +\n ' Each entry needs its OWN reviewer subagent — that is how independent review is enforced.'\n );\n }\n\n private emptyManifest(docRel: string): string {\n return (\n `📋 Review checklists: 0 defined — \"${docRel}\" is readable but its manifest lists no usable\\n` +\n ' checklists (every entry needs a non-empty \"subagent\"). Fine to proceed; worth a look if\\n' +\n ' you expected some to run.'\n );\n }\n\n // The one case that deserves volume: it LOOKS configured but enforces nothing.\n private misconfigured(docRel: string, manifestErrors: readonly string[]): string {\n return (\n `⚠️ Review checklists: 0 ran because \"${docRel}\" is MISCONFIGURED — so this PR is getting NO\\n` +\n ' checklist review even though this repo asked for one. Not fatal, but almost certainly not\\n' +\n ' what you want:\\n\\n' +\n manifestErrors.map((e: string): string => ` • ${e}`).join('\\n')\n );\n }\n\n private noneMatched(docRel: string, definedCount: number): string {\n return (\n `📋 Review checklists: ${definedCount} defined in \"${docRel}\", 0 matched this diff — none of their\\n` +\n ' path patterns hit a changed file. Expected for changes outside those areas; if you thought\\n' +\n ' one should have run, check its \"patterns\" against the changed-file list above.'\n );\n }\n}\n"]}
|
|
@@ -20,12 +20,12 @@ export declare class PublishedPr {
|
|
|
20
20
|
* right token. A brand-new PR is unaffected either way — `gh pr create` composes the body after the push,
|
|
21
21
|
* so the `opened` event already sees the final body.
|
|
22
22
|
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
23
|
+
* THIS IS THE ONLY PUSH IN THE PR FLOW. `wp-start-upsert-pr` used to push twice (its own `ensurePushed`,
|
|
24
|
+
* and the force-push inside the 3-point merge finalize), which put code on the remote before review.json
|
|
25
|
+
* and the checklists had even run, and fired `synchronize` against a PR body still carrying the previous
|
|
26
|
+
* run's token. Both are gone — the merge finalize now takes `MergeEndOptions.pushRemote=false` in the PR
|
|
27
|
+
* flow. So the single `synchronize` of a cycle arrives strictly after the body edit below, and can only
|
|
28
|
+
* ever read the correct token.
|
|
29
29
|
*
|
|
30
30
|
* `@injectable(bindingScopeValues.Singleton)` so it is drawn in the DI design and injected by type.
|
|
31
31
|
*/
|
|
@@ -34,12 +34,12 @@ exports.PublishedPr = PublishedPr;
|
|
|
34
34
|
* right token. A brand-new PR is unaffected either way — `gh pr create` composes the body after the push,
|
|
35
35
|
* so the `opened` event already sees the final body.
|
|
36
36
|
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
37
|
+
* THIS IS THE ONLY PUSH IN THE PR FLOW. `wp-start-upsert-pr` used to push twice (its own `ensurePushed`,
|
|
38
|
+
* and the force-push inside the 3-point merge finalize), which put code on the remote before review.json
|
|
39
|
+
* and the checklists had even run, and fired `synchronize` against a PR body still carrying the previous
|
|
40
|
+
* run's token. Both are gone — the merge finalize now takes `MergeEndOptions.pushRemote=false` in the PR
|
|
41
|
+
* flow. So the single `synchronize` of a cycle arrives strictly after the body edit below, and can only
|
|
42
|
+
* ever read the correct token.
|
|
43
43
|
*
|
|
44
44
|
* `@injectable(bindingScopeValues.Singleton)` so it is drawn in the DI design and injected by type.
|
|
45
45
|
*/
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"gated-pr-publisher.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/pr-gate/src/scripts/workflow/gated-pr-publisher.ts"],"names":[],"mappings":"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","sourcesContent":["import { spawnSync } from 'child_process';\nimport { CliExitError } from '@webpieces/rules-config';\nimport { injectable, bindingScopeValues } from 'inversify';\nimport { GitExec } from './git-exec';\n\n// Which PR the gated body landed on. Data-only.\nexport class PublishedPr {\n // The PR that already existed and was edited; '' when this run had to create one.\n number: string;\n // True only on the create path, when `gh pr create` itself failed — there is no PR to merge.\n createFailed: boolean;\n\n constructor(number: string, createFailed: boolean) {\n this.number = number;\n this.createFailed = createFailed;\n }\n}\n\n/**\n * Owns the ONE ordering constraint that lets the webpieces gate be a single required check: the PR body\n * — which carries `HMAC(gateSalt, HEAD_sha)` — is written BEFORE the push.\n *\n * WHY the order matters. Pushing first fires `pull_request:synchronize`, and CI (`wp-check-pr`) reads the\n * PR body it finds at that instant. If the body edit has not landed yet, that read sees the PREVIOUS\n * run's token, which is bound to the parent sha, and the check goes red on a timing coin-flip. #485\n * papered over this by also posting a `webpieces/pr-gate` commit status, whose newest post supersedes an\n * older one — recovery rather than prevention, and it left consumers with two entries on every PR and no\n * way to tell which one to mark required.\n *\n * The sha is already known locally: nothing about minting `HMAC(gateSalt, git rev-parse HEAD)` needs the\n * remote to have the commit. So write the body first and the `synchronize` read can only ever see the\n * right token. A brand-new PR is unaffected either way — `gh pr create` composes the body after the push,\n * so the `opened` event already sees the final body.\n *\n *
|
|
1
|
+
{"version":3,"file":"gated-pr-publisher.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/pr-gate/src/scripts/workflow/gated-pr-publisher.ts"],"names":[],"mappings":"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","sourcesContent":["import { spawnSync } from 'child_process';\nimport { CliExitError } from '@webpieces/rules-config';\nimport { injectable, bindingScopeValues } from 'inversify';\nimport { GitExec } from './git-exec';\n\n// Which PR the gated body landed on. Data-only.\nexport class PublishedPr {\n // The PR that already existed and was edited; '' when this run had to create one.\n number: string;\n // True only on the create path, when `gh pr create` itself failed — there is no PR to merge.\n createFailed: boolean;\n\n constructor(number: string, createFailed: boolean) {\n this.number = number;\n this.createFailed = createFailed;\n }\n}\n\n/**\n * Owns the ONE ordering constraint that lets the webpieces gate be a single required check: the PR body\n * — which carries `HMAC(gateSalt, HEAD_sha)` — is written BEFORE the push.\n *\n * WHY the order matters. Pushing first fires `pull_request:synchronize`, and CI (`wp-check-pr`) reads the\n * PR body it finds at that instant. If the body edit has not landed yet, that read sees the PREVIOUS\n * run's token, which is bound to the parent sha, and the check goes red on a timing coin-flip. #485\n * papered over this by also posting a `webpieces/pr-gate` commit status, whose newest post supersedes an\n * older one — recovery rather than prevention, and it left consumers with two entries on every PR and no\n * way to tell which one to mark required.\n *\n * The sha is already known locally: nothing about minting `HMAC(gateSalt, git rev-parse HEAD)` needs the\n * remote to have the commit. So write the body first and the `synchronize` read can only ever see the\n * right token. A brand-new PR is unaffected either way — `gh pr create` composes the body after the push,\n * so the `opened` event already sees the final body.\n *\n * THIS IS THE ONLY PUSH IN THE PR FLOW. `wp-start-upsert-pr` used to push twice (its own `ensurePushed`,\n * and the force-push inside the 3-point merge finalize), which put code on the remote before review.json\n * and the checklists had even run, and fired `synchronize` against a PR body still carrying the previous\n * run's token. Both are gone — the merge finalize now takes `MergeEndOptions.pushRemote=false` in the PR\n * flow. So the single `synchronize` of a cycle arrives strictly after the body edit below, and can only\n * ever read the correct token.\n *\n * `@injectable(bindingScopeValues.Singleton)` so it is drawn in the DI design and injected by type.\n */\n@injectable(bindingScopeValues.Singleton)\nexport class GatedPrPublisher {\n constructor(private readonly gitExec: GitExec) {}\n\n /**\n * Land `bodyFile` on the PR for `baseBranch` and push, in the gate-safe order.\n *\n * @param bodyFile the rendered dashboard ALREADY carrying the gate token for the LOCAL head sha\n */\n publish(baseBranch: string, title: string, bodyFile: string): PublishedPr {\n const existing = this.findOpenPr(baseBranch);\n\n // 1. Body FIRST, so a `synchronize` read arriving the instant after the push finds the right token.\n if (existing !== '') {\n process.stdout.write(`Updating PR #${existing} (body before push, so CI cannot read a stale token)...\\n`);\n this.editPrOrAbort(existing, title, bodyFile);\n // Say the consequence BEFORE the push can fail: from here the PR advertises a token for the\n // local HEAD, so a failed push leaves the PR pointing at the old commit and the gate check goes\n // RED. That is the deliberate fail-CLOSED direction, and it self-corrects on the next good run.\n process.stdout.write(\n ' body updated. Pushing now — if this push FAILS, the PR advertises a token for a commit\\n' +\n ' the remote does not have, so the webpieces gate check goes RED (never falsely green)\\n' +\n ' until you fix the push and re-run pnpm wp-finish-upsert-pr.\\n',\n );\n }\n\n // 2. THEN push. Nothing was pushed above, so an edit failure left the remote wholly untouched.\n this.push(baseBranch);\n\n // 3. Only a brand-new PR is created, and only after the push — `gh pr create` needs the remote ref.\n if (existing !== '') return new PublishedPr(existing, false);\n process.stdout.write('Creating PR...\\n');\n return new PublishedPr('', !this.createPr(baseBranch, title, bodyFile));\n }\n\n // Edit the PR body/title, aborting BEFORE the push if `gh` failed. Warning-and-continuing here would\n // push code that the PR body does not vouch for; aborting leaves the PR on its previous body, whose\n // token is still valid for the sha the remote still has. Nothing is half-done either way.\n private editPrOrAbort(prNumber: string, title: string, bodyFile: string): void {\n if (this.editPr(prNumber, title, bodyFile)) return;\n throw new CliExitError(1,\n `❌ gh pr edit failed on PR #${prNumber} — NOTHING was pushed, so the PR and the remote branch are\\n` +\n ` both unchanged and still consistent. Fix gh (auth / network / permissions) and re-run\\n` +\n ` pnpm wp-finish-upsert-pr. The new body is in:\\n ${bodyFile}`);\n }\n\n // The `gh`/push seams, protected so the spec can drive the ordering with no gh, no network, no repo.\n\n // The open PR whose head is `baseBranch`, or '' when there is none (or `gh` failed — treated the same,\n // so the create path then fails loudly rather than silently editing the wrong PR).\n protected findOpenPr(baseBranch: string): string {\n const result = spawnSync('gh', ['pr', 'list', '--head', baseBranch, '--json', 'number', '--jq', '.[0].number'], { encoding: 'utf8' });\n return result.status === 0 ? (result.stdout ?? '').trim() : '';\n }\n\n protected editPr(prNumber: string, title: string, bodyFile: string): boolean {\n return spawnSync('gh', ['pr', 'edit', prNumber, '--title', title, '--body-file', bodyFile], { stdio: 'inherit' }).status === 0;\n }\n\n protected createPr(baseBranch: string, title: string, bodyFile: string): boolean {\n return spawnSync('gh', ['pr', 'create', '--head', baseBranch, '--base', 'main', '--title', title, '--body-file', bodyFile], { stdio: 'inherit' }).status === 0;\n }\n\n // Throws (CliExitError, via runGitChecked) when the push fails — deliberately NOT caught here: the\n // consequence is already printed above, and swallowing it would report a PR that was never updated.\n protected push(baseBranch: string): void {\n this.gitExec.ensurePushed(baseBranch);\n }\n}\n"]}
|
|
@@ -4,6 +4,23 @@ import { CleanTmp } from './cleanTmp';
|
|
|
4
4
|
import { GitExec } from './git-exec';
|
|
5
5
|
import { MergeContext } from './merge-start';
|
|
6
6
|
import { MergeState } from './merge-state';
|
|
7
|
+
export declare class MergeEndOptions {
|
|
8
|
+
conflictedFiles: string[] | null;
|
|
9
|
+
/**
|
|
10
|
+
* Whether finalizing may push the squashed branch to origin.
|
|
11
|
+
*
|
|
12
|
+
* FALSE for the PR flow (`wp-start-upsert-pr` / `wp-finish-upsert-pr`), which pushes EXACTLY ONCE,
|
|
13
|
+
* from GatedPrPublisher, after review.json + every BLOCK checklist + the authoritative build gate
|
|
14
|
+
* have passed and the gated PR body is already up. Code must not reach the remote ahead of its
|
|
15
|
+
* review, and an early push here is also what fires `pull_request:synchronize` against a PR body
|
|
16
|
+
* still carrying the previous run's gate token — the flap the whole gate redesign is removing.
|
|
17
|
+
*
|
|
18
|
+
* TRUE for the update-only flow (`wp-start-update` / `wp-finish-update`), which has no later push;
|
|
19
|
+
* finalizing IS the end of that flow, so not pushing would silently strand the rewrite locally.
|
|
20
|
+
*/
|
|
21
|
+
pushRemote: boolean;
|
|
22
|
+
constructor(conflictedFiles: string[] | null, pushRemote: boolean);
|
|
23
|
+
}
|
|
7
24
|
export declare class MergeEnd {
|
|
8
25
|
private readonly branchNaming;
|
|
9
26
|
private readonly cleanTmpService;
|
|
@@ -11,13 +28,15 @@ export declare class MergeEnd {
|
|
|
11
28
|
private readonly mergeState;
|
|
12
29
|
constructor(branchNaming: BranchNaming, cleanTmpService: CleanTmp, gitExec: GitExec, mergeState: MergeState);
|
|
13
30
|
/**
|
|
14
|
-
* Complete a 3-point squash merge.
|
|
15
|
-
*
|
|
16
|
-
* already committed (finalize only). Either way the merge ends fully finalized on the feature branch.
|
|
31
|
+
* Complete a 3-point squash merge. See {@link MergeEndOptions} for what `conflictedFiles` and
|
|
32
|
+
* `pushRemote` mean. Either way the merge ends fully finalized on the feature branch.
|
|
17
33
|
*/
|
|
18
|
-
mergeEnd(repoRoot: string, verb: MutationVerb, mergeDir: string, ctx: MergeContext,
|
|
34
|
+
mergeEnd(repoRoot: string, verb: MutationVerb, mergeDir: string, ctx: MergeContext, options: MergeEndOptions): Promise<void>;
|
|
19
35
|
private validateResolution;
|
|
20
|
-
|
|
36
|
+
protected localBranchExists(name: string): boolean;
|
|
21
37
|
private finalizeBranch;
|
|
38
|
+
private explainNoPush;
|
|
39
|
+
protected pushFinalized(ctx: MergeContext, base: string): boolean;
|
|
40
|
+
private remoteRecapLine;
|
|
22
41
|
private printSyncRecap;
|
|
23
42
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.MergeEnd = void 0;
|
|
3
|
+
exports.MergeEnd = exports.MergeEndOptions = void 0;
|
|
4
4
|
const tslib_1 = require("tslib");
|
|
5
5
|
const child_process_1 = require("child_process");
|
|
6
6
|
const fs = tslib_1.__importStar(require("fs"));
|
|
@@ -12,6 +12,45 @@ const cleanTmp_1 = require("./cleanTmp");
|
|
|
12
12
|
const git_exec_1 = require("./git-exec");
|
|
13
13
|
const merge_state_1 = require("./merge-state");
|
|
14
14
|
const SEP = '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n';
|
|
15
|
+
// What finalizing actually did, for the recap. Data-only. `pushed` and `pushDeferred` are distinct on
|
|
16
|
+
// purpose: "we chose not to push yet" must not be reported like "there was no remote to push to".
|
|
17
|
+
class SyncRecap {
|
|
18
|
+
pushed;
|
|
19
|
+
pushDeferred;
|
|
20
|
+
hadConflict;
|
|
21
|
+
backupKept;
|
|
22
|
+
constructor(pushed, pushDeferred, hadConflict, backupKept) {
|
|
23
|
+
this.pushed = pushed;
|
|
24
|
+
this.pushDeferred = pushDeferred;
|
|
25
|
+
this.hadConflict = hadConflict;
|
|
26
|
+
this.backupKept = backupKept;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
// How a merge should be finalized. Data-only (per CLAUDE.md, classes for data) — bundled rather than
|
|
30
|
+
// added as more positional params, which mergeEnd already has enough of.
|
|
31
|
+
class MergeEndOptions {
|
|
32
|
+
// Non-null means the AI resolved a conflict and it must be validated + committed before finalizing.
|
|
33
|
+
// Null means merge-START already committed a clean merge, so finalize only.
|
|
34
|
+
conflictedFiles;
|
|
35
|
+
/**
|
|
36
|
+
* Whether finalizing may push the squashed branch to origin.
|
|
37
|
+
*
|
|
38
|
+
* FALSE for the PR flow (`wp-start-upsert-pr` / `wp-finish-upsert-pr`), which pushes EXACTLY ONCE,
|
|
39
|
+
* from GatedPrPublisher, after review.json + every BLOCK checklist + the authoritative build gate
|
|
40
|
+
* have passed and the gated PR body is already up. Code must not reach the remote ahead of its
|
|
41
|
+
* review, and an early push here is also what fires `pull_request:synchronize` against a PR body
|
|
42
|
+
* still carrying the previous run's gate token — the flap the whole gate redesign is removing.
|
|
43
|
+
*
|
|
44
|
+
* TRUE for the update-only flow (`wp-start-update` / `wp-finish-update`), which has no later push;
|
|
45
|
+
* finalizing IS the end of that flow, so not pushing would silently strand the rewrite locally.
|
|
46
|
+
*/
|
|
47
|
+
pushRemote;
|
|
48
|
+
constructor(conflictedFiles, pushRemote) {
|
|
49
|
+
this.conflictedFiles = conflictedFiles;
|
|
50
|
+
this.pushRemote = pushRemote;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
exports.MergeEndOptions = MergeEndOptions;
|
|
15
54
|
// merge-END: the second half of the 3-point squash-merge lifecycle, symmetric with merge-START. Given
|
|
16
55
|
// the branch context, it (optionally) validates + commits the AI's conflict resolution, then ALWAYS
|
|
17
56
|
// finalizes — force-pushes `<branch>Squash` to the stable feature branch and renames it BACK to that
|
|
@@ -30,11 +69,11 @@ let MergeEnd = class MergeEnd {
|
|
|
30
69
|
this.mergeState = mergeState;
|
|
31
70
|
}
|
|
32
71
|
/**
|
|
33
|
-
* Complete a 3-point squash merge.
|
|
34
|
-
*
|
|
35
|
-
* already committed (finalize only). Either way the merge ends fully finalized on the feature branch.
|
|
72
|
+
* Complete a 3-point squash merge. See {@link MergeEndOptions} for what `conflictedFiles` and
|
|
73
|
+
* `pushRemote` mean. Either way the merge ends fully finalized on the feature branch.
|
|
36
74
|
*/
|
|
37
|
-
async mergeEnd(repoRoot, verb, mergeDir, ctx,
|
|
75
|
+
async mergeEnd(repoRoot, verb, mergeDir, ctx, options) {
|
|
76
|
+
const conflictedFiles = options.conflictedFiles;
|
|
38
77
|
if (conflictedFiles !== null) {
|
|
39
78
|
process.stdout.write('\n' + SEP + '🔎 Validating Merge Resolution\n' + SEP + '\n');
|
|
40
79
|
this.validateResolution(repoRoot, verb, mergeDir, conflictedFiles);
|
|
@@ -53,7 +92,7 @@ let MergeEnd = class MergeEnd {
|
|
|
53
92
|
// A marker in THIS run dir means the sync hit conflicts (marker is written only on hand-back).
|
|
54
93
|
// Read it BEFORE clearMergeMarker so finalize knows whether to keep the pre-merge snapshot.
|
|
55
94
|
const hadConflict = this.mergeState.readMergeMarker(mergeDir) !== null;
|
|
56
|
-
this.finalizeBranch(repoRoot, verb, ctx, hadConflict);
|
|
95
|
+
this.finalizeBranch(repoRoot, verb, ctx, hadConflict, options.pushRemote);
|
|
57
96
|
this.mergeState.clearMergeMarker(mergeDir);
|
|
58
97
|
await this.cleanTmpService.cleanTmp();
|
|
59
98
|
}
|
|
@@ -92,24 +131,20 @@ let MergeEnd = class MergeEnd {
|
|
|
92
131
|
}
|
|
93
132
|
process.stdout.write('✅ Merge explanations present for all resolved files.\n');
|
|
94
133
|
}
|
|
134
|
+
// Seam: overridden in the spec so the push/backup decisions are testable with no git and no repo.
|
|
95
135
|
localBranchExists(name) {
|
|
96
136
|
return (0, child_process_1.spawnSync)('git', ['show-ref', '--verify', '--quiet', `refs/heads/${name}`]).status === 0;
|
|
97
137
|
}
|
|
98
|
-
// Force-push the squash branch to the stable feature branch (where the single PR lives)
|
|
99
|
-
//
|
|
100
|
-
// disposable, so it's deleted at the
|
|
101
|
-
|
|
138
|
+
// Force-push the squash branch to the stable feature branch (where the single PR lives) — unless the
|
|
139
|
+
// caller owns the push (see MergeEndOptions.pushRemote) — then RENAME the local squash branch back to
|
|
140
|
+
// that SAME feature name. On a CLEAN sync the pre-merge snapshot is disposable, so it's deleted at the
|
|
141
|
+
// very end; a CONFLICT sync — or a sync we did not push — keeps it.
|
|
142
|
+
// eslint-disable-next-line @typescript-eslint/max-params
|
|
143
|
+
finalizeBranch(repoRoot, verb, ctx, hadConflict, pushRemote) {
|
|
102
144
|
process.stdout.write('\n' + SEP + '🗑️ Finalizing\n' + SEP + '\n');
|
|
103
145
|
const base = this.branchNaming.baseBranchName(ctx.currentBranch);
|
|
104
146
|
this.gitExec.runGitChecked(['branch', '-D', ctx.currentBranch], 'Failed to delete old feature branch');
|
|
105
|
-
const
|
|
106
|
-
if (remoteExists) {
|
|
107
|
-
process.stdout.write(ctx.prNumber ? `Updating PR #${ctx.prNumber} (force-with-lease)...\n` : 'Updating remote branch (force-with-lease)...\n');
|
|
108
|
-
this.gitExec.runGitChecked(['push', '-u', '--force-with-lease', 'origin', `${ctx.squashBranch}:${base}`], 'Failed to push to origin');
|
|
109
|
-
}
|
|
110
|
-
else {
|
|
111
|
-
process.stdout.write('No remote branch — local only.\n');
|
|
112
|
-
}
|
|
147
|
+
const pushed = pushRemote ? this.pushFinalized(ctx, base) : this.explainNoPush();
|
|
113
148
|
this.gitExec.runGitChecked(['checkout', ctx.squashBranch], 'Failed to checkout squash branch');
|
|
114
149
|
// Free the rename target: `base` is normally the branch we just deleted, but a backward-compat
|
|
115
150
|
// sync from a leftover `…wpN` can leave a separate stale `base` lingering — drop it too.
|
|
@@ -125,7 +160,10 @@ let MergeEnd = class MergeEnd {
|
|
|
125
160
|
// unblocks edits immediately (no wait for the async refresher).
|
|
126
161
|
(0, rules_config_1.stampCleanMainSyncStatus)(repoRoot);
|
|
127
162
|
// Clean sync → the pre-merge snapshot was never needed; delete it LAST. Conflict sync → keep it.
|
|
128
|
-
|
|
163
|
+
// ALSO keep it when we did not push: the squash rewrite then exists ONLY in this working copy, so
|
|
164
|
+
// deleting the snapshot would leave a history rewrite with no copy anywhere. It is disposable only
|
|
165
|
+
// once origin has the result, and in the PR flow that does not happen until wp-finish-upsert-pr.
|
|
166
|
+
const backupKept = hadConflict || !pushed;
|
|
129
167
|
if (!backupKept && this.localBranchExists(ctx.backupBranch)) {
|
|
130
168
|
this.gitExec.runGitChecked(['branch', '-D', ctx.backupBranch], 'Failed to delete clean-merge backup');
|
|
131
169
|
}
|
|
@@ -135,15 +173,47 @@ let MergeEnd = class MergeEnd {
|
|
|
135
173
|
finalizeEvent.outcome = 'finalized';
|
|
136
174
|
finalizeEvent.artifacts = [backupKept ? `backup=${ctx.backupBranch}` : `backupDeleted=${ctx.backupBranch}`, `remotePR=${base}`];
|
|
137
175
|
(0, rules_config_1.logBranchMutation)(repoRoot, finalizeEvent);
|
|
138
|
-
this.printSyncRecap(base, ctx.backupBranch, ctx.prNumber,
|
|
176
|
+
this.printSyncRecap(base, ctx.backupBranch, ctx.prNumber, new SyncRecap(pushed, !pushRemote, hadConflict, backupKept));
|
|
177
|
+
}
|
|
178
|
+
// Say WHY nothing was pushed, so an unpushed branch never looks like a failure. Always returns false
|
|
179
|
+
// (nothing was pushed), which also keeps the pre-merge snapshot alive — see the backupKept comment.
|
|
180
|
+
explainNoPush() {
|
|
181
|
+
process.stdout.write('Not pushing — the PR flow pushes exactly ONCE, from pnpm wp-finish-upsert-pr, after review.json\n' +
|
|
182
|
+
'and the build gate pass and the gated PR body is written. Your work stays local until then\n' +
|
|
183
|
+
'(the pre-merge snapshot below is kept as well, so nothing is only in one place).\n');
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
// Push the finalized squash branch to the stable feature branch. Returns whether a push actually
|
|
187
|
+
// happened — false when there is no remote branch yet (a brand-new branch is local until something
|
|
188
|
+
// creates it, which in the PR flow is `gh pr create` after GatedPrPublisher's push).
|
|
189
|
+
pushFinalized(ctx, base) {
|
|
190
|
+
const remoteExists = (0, child_process_1.spawnSync)('git', ['ls-remote', '--exit-code', '--heads', 'origin', base]).status === 0;
|
|
191
|
+
if (!remoteExists) {
|
|
192
|
+
process.stdout.write('No remote branch — local only.\n');
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
195
|
+
process.stdout.write(ctx.prNumber ? `Updating PR #${ctx.prNumber} (force-with-lease)...\n` : 'Updating remote branch (force-with-lease)...\n');
|
|
196
|
+
this.gitExec.runGitChecked(['push', '-u', '--force-with-lease', 'origin', `${ctx.squashBranch}:${base}`], 'Failed to push to origin');
|
|
197
|
+
return true;
|
|
198
|
+
}
|
|
199
|
+
// Where the branch ended up, in the recap's own words. Three distinct outcomes, and "deferred" must
|
|
200
|
+
// never read like the failure the other two could be mistaken for.
|
|
201
|
+
remoteRecapLine(feature, prNumber, recap) {
|
|
202
|
+
if (recap.pushed) {
|
|
203
|
+
return `landed back on ${feature} (== origin/${feature}${prNumber ? ` == PR #${prNumber}` : ''} — names match)`;
|
|
204
|
+
}
|
|
205
|
+
if (recap.pushDeferred) {
|
|
206
|
+
return `landed back on ${feature} (local — by design; pnpm wp-finish-upsert-pr does the one push)`;
|
|
207
|
+
}
|
|
208
|
+
return `landed back on ${feature} (local only — no remote branch yet)`;
|
|
139
209
|
}
|
|
140
210
|
// The explicit, numbered "here is exactly what I did" recap the AI (and human) reads after a sync.
|
|
141
|
-
printSyncRecap(feature, backupBranch, prNumber,
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
211
|
+
printSyncRecap(feature, backupBranch, prNumber, recap) {
|
|
212
|
+
const backupKept = recap.backupKept;
|
|
213
|
+
const remoteLine = this.remoteRecapLine(feature, prNumber, recap);
|
|
214
|
+
const keptReason = recap.hadConflict ? 'this merge had conflicts' : 'not pushed yet — wp-finish-upsert-pr will push';
|
|
145
215
|
const step1 = backupKept
|
|
146
|
-
? `snapshotted your pre-merge state → ${backupBranch} (kept —
|
|
216
|
+
? `snapshotted your pre-merge state → ${backupBranch} (kept — ${keptReason})`
|
|
147
217
|
: `snapshotted your pre-merge state → ${backupBranch} (auto-removed — clean merge, no undo needed)`;
|
|
148
218
|
const trailer = backupKept
|
|
149
219
|
? ` Pre-merge snapshot trail: git branch --list '${feature}PreMerge*'\n` +
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"merge-end.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/pr-gate/src/scripts/workflow/merge-end.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 MERGE_EXPLANATION_FILE, stampCleanMainSyncStatus, CliExitError,\n MutationVerb, BranchMutationEvent, logBranchMutation,\n} from '@webpieces/rules-config';\nimport { injectable, bindingScopeValues } from 'inversify';\nimport { BranchNaming } from './branch-naming';\nimport { CleanTmp } from './cleanTmp';\nimport { GitExec } from './git-exec';\nimport { MergeContext } from './merge-start';\nimport { MergeState } from './merge-state';\n\nconst SEP = '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\\n';\n\n// merge-END: the second half of the 3-point squash-merge lifecycle, symmetric with merge-START. Given\n// the branch context, it (optionally) validates + commits the AI's conflict resolution, then ALWAYS\n// finalizes — force-pushes `<branch>Squash` to the stable feature branch and renames it BACK to that\n// same feature name (local == remote == PR head), stamps a clean main-sync status, clears the marker,\n// and sweeps stale tmp. RunUpdate (clean path / validated resume), wp-finish-update, and\n// wp-finish-upsert-pr (conflict resolution) all call THIS, so finalization happens in exactly one place.\n@injectable(bindingScopeValues.Singleton)\nexport class MergeEnd {\n constructor(\n private readonly branchNaming: BranchNaming,\n private readonly cleanTmpService: CleanTmp,\n private readonly gitExec: GitExec,\n private readonly mergeState: MergeState,\n ) {}\n\n /**\n * Complete a 3-point squash merge. `conflictedFiles` non-null means a conflict was resolved by the\n * AI and must be validated + committed before finalizing; null means a clean merge that merge-START\n * already committed (finalize only). Either way the merge ends fully finalized on the feature branch.\n */\n async mergeEnd(\n repoRoot: string, verb: MutationVerb, mergeDir: string, ctx: MergeContext, conflictedFiles: string[] | null,\n ): Promise<void> {\n if (conflictedFiles !== null) {\n process.stdout.write('\\n' + SEP + '🔎 Validating Merge Resolution\\n' + SEP + '\\n');\n this.validateResolution(repoRoot, verb, mergeDir, conflictedFiles);\n // Stage the AI's resolved conflicts, but NEVER sweep untracked files into the squash commit.\n // Fail on untracked so the AI commits or deletes them explicitly; then `git add -u` stages\n // tracked resolutions only.\n this.gitExec.assertNoUntracked(repoRoot);\n this.gitExec.runGitChecked(['add', '-u'], 'Failed to stage resolved files');\n\n const nothingStaged = spawnSync('git', ['diff-index', '--quiet', '--cached', 'HEAD', '--']).status === 0;\n if (!nothingStaged) {\n this.gitExec.runGitChecked(\n ['commit', '-m', `Squash merge of ${ctx.currentBranch} (conflicts resolved)`],\n 'Failed to commit resolved merge',\n );\n }\n fs.writeFileSync(path.join(mergeDir, 'conflicts-resolved'), '');\n process.stdout.write('\\n✅ Merge validated and committed.\\n');\n }\n\n // A marker in THIS run dir means the sync hit conflicts (marker is written only on hand-back).\n // Read it BEFORE clearMergeMarker so finalize knows whether to keep the pre-merge snapshot.\n const hadConflict = this.mergeState.readMergeMarker(mergeDir) !== null;\n this.finalizeBranch(repoRoot, verb, ctx, hadConflict);\n this.mergeState.clearMergeMarker(mergeDir);\n await this.cleanTmpService.cleanTmp();\n }\n\n // Validate the AI's resolution of the conflicted files. Throws CliExitError with a fix instruction\n // on any failure; returns only when all three checks pass.\n // `verb` is the bin the AI actually ran (wp-finish-update or wp-finish-upsert-pr) — every \"re-run\"\n // instruction below is rendered from it. It used to be hardcoded to wp-finish-upsert-pr, which told\n // someone in the update-only flow to finish with the PR command: a pairing that does not exist.\n private validateResolution(repoRoot: string, verb: MutationVerb, mergeDir: string, conflictedFiles: string[]): void {\n const reRun = `pnpm ${verb}`;\n // 1. Scoped conflict-marker scan (only the conflicted files — O(conflicts), not O(repo)).\n const scan = this.mergeState.scanConflictMarkers(repoRoot, conflictedFiles);\n if (!scan.clean) {\n throw new CliExitError(1,\n '❌ Unresolved conflict markers (<<<<<<< / ======= / >>>>>>>) remain in:\\n' +\n scan.filesWithMarkers.map((file: string): string => ` - ${file}`).join('\\n') +\n `\\n\\nResolve them, then re-run: ${reRun}`,\n );\n }\n\n // 2. Ensure git itself has no remaining unmerged entries.\n const unmerged = execSync('git diff --name-only --diff-filter=U', { encoding: 'utf8' }).trim();\n if (unmerged !== '') {\n throw new CliExitError(1,\n '❌ Git still reports unmerged files:\\n' + unmerged +\n '\\n\\nResolve and `git add` them, then re-run: ' + reRun,\n );\n }\n process.stdout.write('✅ No conflict markers in resolved files.\\n');\n\n // 3. Explanation check — every conflicted file must have a non-empty merge-explanation.md in its\n // per-file context dir, proving the AI deliberately 3-point merged it (and recording how).\n const explanations = this.mergeState.scanMergeExplanations(mergeDir, conflictedFiles);\n if (!explanations.clean) {\n const missing = explanations.filesWithMarkers\n .map((file: string): string => ` - ${file}\\n → ${path.join(this.mergeState.perFileContextDir(mergeDir, file), MERGE_EXPLANATION_FILE)}`)\n .join('\\n');\n throw new CliExitError(1,\n `❌ Missing/empty merge explanation (${MERGE_EXPLANATION_FILE}) for:\\n` +\n missing +\n '\\n\\nWrite a few sentences on how you resolved each (which side, what you combined, why),\\n' +\n 'then re-run: ' + reRun,\n );\n }\n process.stdout.write('✅ Merge explanations present for all resolved files.\\n');\n }\n\n private localBranchExists(name: string): boolean {\n return spawnSync('git', ['show-ref', '--verify', '--quiet', `refs/heads/${name}`]).status === 0;\n }\n\n // Force-push the squash branch to the stable feature branch (where the single PR lives), then RENAME\n // the local squash branch back to that SAME feature name. On a CLEAN sync the pre-merge snapshot is\n // disposable, so it's deleted at the very end; a CONFLICT sync keeps it.\n private finalizeBranch(repoRoot: string, verb: MutationVerb, ctx: MergeContext, hadConflict: boolean): void {\n process.stdout.write('\\n' + SEP + '🗑️ Finalizing\\n' + SEP + '\\n');\n const base = this.branchNaming.baseBranchName(ctx.currentBranch);\n this.gitExec.runGitChecked(['branch', '-D', ctx.currentBranch], 'Failed to delete old feature branch');\n\n const remoteExists = spawnSync('git', ['ls-remote', '--exit-code', '--heads', 'origin', base]).status === 0;\n if (remoteExists) {\n process.stdout.write(ctx.prNumber ? `Updating PR #${ctx.prNumber} (force-with-lease)...\\n` : 'Updating remote branch (force-with-lease)...\\n');\n this.gitExec.runGitChecked(['push', '-u', '--force-with-lease', 'origin', `${ctx.squashBranch}:${base}`], 'Failed to push to origin');\n } else {\n process.stdout.write('No remote branch — local only.\\n');\n }\n this.gitExec.runGitChecked(['checkout', ctx.squashBranch], 'Failed to checkout squash branch');\n // Free the rename target: `base` is normally the branch we just deleted, but a backward-compat\n // sync from a leftover `…wpN` can leave a separate stale `base` lingering — drop it too.\n if (base !== ctx.currentBranch && this.localBranchExists(base)) {\n this.gitExec.runGitChecked(['branch', '-D', base], 'Failed to delete stale base branch');\n }\n this.gitExec.runGitChecked(['branch', '-m', base], 'Failed to rename squash branch to the feature name');\n const renameEvent = new BranchMutationEvent(verb, 'RENAME');\n renameEvent.fromBranch = ctx.currentBranch;\n renameEvent.toBranch = base;\n logBranchMutation(repoRoot, renameEvent);\n\n // Branch now contains origin/main — stamp a clean main-sync status so the feature-branch-guard\n // unblocks edits immediately (no wait for the async refresher).\n stampCleanMainSyncStatus(repoRoot);\n\n // Clean sync → the pre-merge snapshot was never needed; delete it LAST. Conflict sync → keep it.\n const backupKept = hadConflict;\n if (!backupKept && this.localBranchExists(ctx.backupBranch)) {\n this.gitExec.runGitChecked(['branch', '-D', ctx.backupBranch], 'Failed to delete clean-merge backup');\n }\n\n const finalizeEvent = new BranchMutationEvent(verb, 'FINALIZE');\n finalizeEvent.fromBranch = ctx.currentBranch;\n finalizeEvent.toBranch = base;\n finalizeEvent.outcome = 'finalized';\n finalizeEvent.artifacts = [backupKept ? `backup=${ctx.backupBranch}` : `backupDeleted=${ctx.backupBranch}`, `remotePR=${base}`];\n logBranchMutation(repoRoot, finalizeEvent);\n\n this.printSyncRecap(base, ctx.backupBranch, ctx.prNumber, remoteExists, backupKept);\n }\n\n // The explicit, numbered \"here is exactly what I did\" recap the AI (and human) reads after a sync.\n private printSyncRecap(feature: string, backupBranch: string, prNumber: string, pushed: boolean, backupKept: boolean): void {\n const remoteLine = pushed\n ? `landed back on ${feature} (== origin/${feature}${prNumber ? ` == PR #${prNumber}` : ''} — names match)`\n : `landed back on ${feature} (local only — no remote branch yet)`;\n const step1 = backupKept\n ? `snapshotted your pre-merge state → ${backupBranch} (kept — this merge had conflicts)`\n : `snapshotted your pre-merge state → ${backupBranch} (auto-removed — clean merge, no undo needed)`;\n const trailer = backupKept\n ? ` Pre-merge snapshot trail: git branch --list '${feature}PreMerge*'\\n` +\n ` Its conflict context lives in the paired merge-<n>/ folder under .webpieces/merge-info/\\n` +\n ` Prune this run's snapshot when safe: git branch -D ${backupBranch}\\n\\n`\n : '\\n';\n process.stdout.write(\n '\\n' + SEP + '✅ Sync complete — here is exactly what I did\\n' + SEP + '\\n' +\n ` 1. ${step1}\\n` +\n ` 2. pulled origin/main\\n` +\n ` 3. squash-merged your work onto main\\n` +\n ` 4. ${remoteLine}\\n\\n` +\n trailer,\n );\n }\n}\n"]}
|
|
1
|
+
{"version":3,"file":"merge-end.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/pr-gate/src/scripts/workflow/merge-end.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 MERGE_EXPLANATION_FILE, stampCleanMainSyncStatus, CliExitError,\n MutationVerb, BranchMutationEvent, logBranchMutation,\n} from '@webpieces/rules-config';\nimport { injectable, bindingScopeValues } from 'inversify';\nimport { BranchNaming } from './branch-naming';\nimport { CleanTmp } from './cleanTmp';\nimport { GitExec } from './git-exec';\nimport { MergeContext } from './merge-start';\nimport { MergeState } from './merge-state';\n\nconst SEP = '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\\n';\n\n// What finalizing actually did, for the recap. Data-only. `pushed` and `pushDeferred` are distinct on\n// purpose: \"we chose not to push yet\" must not be reported like \"there was no remote to push to\".\nclass SyncRecap {\n pushed: boolean;\n pushDeferred: boolean;\n hadConflict: boolean;\n backupKept: boolean;\n\n constructor(pushed: boolean, pushDeferred: boolean, hadConflict: boolean, backupKept: boolean) {\n this.pushed = pushed;\n this.pushDeferred = pushDeferred;\n this.hadConflict = hadConflict;\n this.backupKept = backupKept;\n }\n}\n\n// How a merge should be finalized. Data-only (per CLAUDE.md, classes for data) — bundled rather than\n// added as more positional params, which mergeEnd already has enough of.\nexport class MergeEndOptions {\n // Non-null means the AI resolved a conflict and it must be validated + committed before finalizing.\n // Null means merge-START already committed a clean merge, so finalize only.\n conflictedFiles: string[] | null;\n\n /**\n * Whether finalizing may push the squashed branch to origin.\n *\n * FALSE for the PR flow (`wp-start-upsert-pr` / `wp-finish-upsert-pr`), which pushes EXACTLY ONCE,\n * from GatedPrPublisher, after review.json + every BLOCK checklist + the authoritative build gate\n * have passed and the gated PR body is already up. Code must not reach the remote ahead of its\n * review, and an early push here is also what fires `pull_request:synchronize` against a PR body\n * still carrying the previous run's gate token — the flap the whole gate redesign is removing.\n *\n * TRUE for the update-only flow (`wp-start-update` / `wp-finish-update`), which has no later push;\n * finalizing IS the end of that flow, so not pushing would silently strand the rewrite locally.\n */\n pushRemote: boolean;\n\n constructor(conflictedFiles: string[] | null, pushRemote: boolean) {\n this.conflictedFiles = conflictedFiles;\n this.pushRemote = pushRemote;\n }\n}\n\n// merge-END: the second half of the 3-point squash-merge lifecycle, symmetric with merge-START. Given\n// the branch context, it (optionally) validates + commits the AI's conflict resolution, then ALWAYS\n// finalizes — force-pushes `<branch>Squash` to the stable feature branch and renames it BACK to that\n// same feature name (local == remote == PR head), stamps a clean main-sync status, clears the marker,\n// and sweeps stale tmp. RunUpdate (clean path / validated resume), wp-finish-update, and\n// wp-finish-upsert-pr (conflict resolution) all call THIS, so finalization happens in exactly one place.\n@injectable(bindingScopeValues.Singleton)\nexport class MergeEnd {\n constructor(\n private readonly branchNaming: BranchNaming,\n private readonly cleanTmpService: CleanTmp,\n private readonly gitExec: GitExec,\n private readonly mergeState: MergeState,\n ) {}\n\n /**\n * Complete a 3-point squash merge. See {@link MergeEndOptions} for what `conflictedFiles` and\n * `pushRemote` mean. Either way the merge ends fully finalized on the feature branch.\n */\n async mergeEnd(\n repoRoot: string, verb: MutationVerb, mergeDir: string, ctx: MergeContext, options: MergeEndOptions,\n ): Promise<void> {\n const conflictedFiles = options.conflictedFiles;\n if (conflictedFiles !== null) {\n process.stdout.write('\\n' + SEP + '🔎 Validating Merge Resolution\\n' + SEP + '\\n');\n this.validateResolution(repoRoot, verb, mergeDir, conflictedFiles);\n // Stage the AI's resolved conflicts, but NEVER sweep untracked files into the squash commit.\n // Fail on untracked so the AI commits or deletes them explicitly; then `git add -u` stages\n // tracked resolutions only.\n this.gitExec.assertNoUntracked(repoRoot);\n this.gitExec.runGitChecked(['add', '-u'], 'Failed to stage resolved files');\n\n const nothingStaged = spawnSync('git', ['diff-index', '--quiet', '--cached', 'HEAD', '--']).status === 0;\n if (!nothingStaged) {\n this.gitExec.runGitChecked(\n ['commit', '-m', `Squash merge of ${ctx.currentBranch} (conflicts resolved)`],\n 'Failed to commit resolved merge',\n );\n }\n fs.writeFileSync(path.join(mergeDir, 'conflicts-resolved'), '');\n process.stdout.write('\\n✅ Merge validated and committed.\\n');\n }\n\n // A marker in THIS run dir means the sync hit conflicts (marker is written only on hand-back).\n // Read it BEFORE clearMergeMarker so finalize knows whether to keep the pre-merge snapshot.\n const hadConflict = this.mergeState.readMergeMarker(mergeDir) !== null;\n this.finalizeBranch(repoRoot, verb, ctx, hadConflict, options.pushRemote);\n this.mergeState.clearMergeMarker(mergeDir);\n await this.cleanTmpService.cleanTmp();\n }\n\n // Validate the AI's resolution of the conflicted files. Throws CliExitError with a fix instruction\n // on any failure; returns only when all three checks pass.\n // `verb` is the bin the AI actually ran (wp-finish-update or wp-finish-upsert-pr) — every \"re-run\"\n // instruction below is rendered from it. It used to be hardcoded to wp-finish-upsert-pr, which told\n // someone in the update-only flow to finish with the PR command: a pairing that does not exist.\n private validateResolution(repoRoot: string, verb: MutationVerb, mergeDir: string, conflictedFiles: string[]): void {\n const reRun = `pnpm ${verb}`;\n // 1. Scoped conflict-marker scan (only the conflicted files — O(conflicts), not O(repo)).\n const scan = this.mergeState.scanConflictMarkers(repoRoot, conflictedFiles);\n if (!scan.clean) {\n throw new CliExitError(1,\n '❌ Unresolved conflict markers (<<<<<<< / ======= / >>>>>>>) remain in:\\n' +\n scan.filesWithMarkers.map((file: string): string => ` - ${file}`).join('\\n') +\n `\\n\\nResolve them, then re-run: ${reRun}`,\n );\n }\n\n // 2. Ensure git itself has no remaining unmerged entries.\n const unmerged = execSync('git diff --name-only --diff-filter=U', { encoding: 'utf8' }).trim();\n if (unmerged !== '') {\n throw new CliExitError(1,\n '❌ Git still reports unmerged files:\\n' + unmerged +\n '\\n\\nResolve and `git add` them, then re-run: ' + reRun,\n );\n }\n process.stdout.write('✅ No conflict markers in resolved files.\\n');\n\n // 3. Explanation check — every conflicted file must have a non-empty merge-explanation.md in its\n // per-file context dir, proving the AI deliberately 3-point merged it (and recording how).\n const explanations = this.mergeState.scanMergeExplanations(mergeDir, conflictedFiles);\n if (!explanations.clean) {\n const missing = explanations.filesWithMarkers\n .map((file: string): string => ` - ${file}\\n → ${path.join(this.mergeState.perFileContextDir(mergeDir, file), MERGE_EXPLANATION_FILE)}`)\n .join('\\n');\n throw new CliExitError(1,\n `❌ Missing/empty merge explanation (${MERGE_EXPLANATION_FILE}) for:\\n` +\n missing +\n '\\n\\nWrite a few sentences on how you resolved each (which side, what you combined, why),\\n' +\n 'then re-run: ' + reRun,\n );\n }\n process.stdout.write('✅ Merge explanations present for all resolved files.\\n');\n }\n\n // Seam: overridden in the spec so the push/backup decisions are testable with no git and no repo.\n protected localBranchExists(name: string): boolean {\n return spawnSync('git', ['show-ref', '--verify', '--quiet', `refs/heads/${name}`]).status === 0;\n }\n\n // Force-push the squash branch to the stable feature branch (where the single PR lives) — unless the\n // caller owns the push (see MergeEndOptions.pushRemote) — then RENAME the local squash branch back to\n // that SAME feature name. On a CLEAN sync the pre-merge snapshot is disposable, so it's deleted at the\n // very end; a CONFLICT sync — or a sync we did not push — keeps it.\n // eslint-disable-next-line @typescript-eslint/max-params\n private finalizeBranch(repoRoot: string, verb: MutationVerb, ctx: MergeContext, hadConflict: boolean, pushRemote: boolean): void {\n process.stdout.write('\\n' + SEP + '🗑️ Finalizing\\n' + SEP + '\\n');\n const base = this.branchNaming.baseBranchName(ctx.currentBranch);\n this.gitExec.runGitChecked(['branch', '-D', ctx.currentBranch], 'Failed to delete old feature branch');\n\n const pushed = pushRemote ? this.pushFinalized(ctx, base) : this.explainNoPush();\n this.gitExec.runGitChecked(['checkout', ctx.squashBranch], 'Failed to checkout squash branch');\n // Free the rename target: `base` is normally the branch we just deleted, but a backward-compat\n // sync from a leftover `…wpN` can leave a separate stale `base` lingering — drop it too.\n if (base !== ctx.currentBranch && this.localBranchExists(base)) {\n this.gitExec.runGitChecked(['branch', '-D', base], 'Failed to delete stale base branch');\n }\n this.gitExec.runGitChecked(['branch', '-m', base], 'Failed to rename squash branch to the feature name');\n const renameEvent = new BranchMutationEvent(verb, 'RENAME');\n renameEvent.fromBranch = ctx.currentBranch;\n renameEvent.toBranch = base;\n logBranchMutation(repoRoot, renameEvent);\n\n // Branch now contains origin/main — stamp a clean main-sync status so the feature-branch-guard\n // unblocks edits immediately (no wait for the async refresher).\n stampCleanMainSyncStatus(repoRoot);\n\n // Clean sync → the pre-merge snapshot was never needed; delete it LAST. Conflict sync → keep it.\n // ALSO keep it when we did not push: the squash rewrite then exists ONLY in this working copy, so\n // deleting the snapshot would leave a history rewrite with no copy anywhere. It is disposable only\n // once origin has the result, and in the PR flow that does not happen until wp-finish-upsert-pr.\n const backupKept = hadConflict || !pushed;\n if (!backupKept && this.localBranchExists(ctx.backupBranch)) {\n this.gitExec.runGitChecked(['branch', '-D', ctx.backupBranch], 'Failed to delete clean-merge backup');\n }\n\n const finalizeEvent = new BranchMutationEvent(verb, 'FINALIZE');\n finalizeEvent.fromBranch = ctx.currentBranch;\n finalizeEvent.toBranch = base;\n finalizeEvent.outcome = 'finalized';\n finalizeEvent.artifacts = [backupKept ? `backup=${ctx.backupBranch}` : `backupDeleted=${ctx.backupBranch}`, `remotePR=${base}`];\n logBranchMutation(repoRoot, finalizeEvent);\n\n this.printSyncRecap(base, ctx.backupBranch, ctx.prNumber, new SyncRecap(pushed, !pushRemote, hadConflict, backupKept));\n }\n\n // Say WHY nothing was pushed, so an unpushed branch never looks like a failure. Always returns false\n // (nothing was pushed), which also keeps the pre-merge snapshot alive — see the backupKept comment.\n private explainNoPush(): boolean {\n process.stdout.write(\n 'Not pushing — the PR flow pushes exactly ONCE, from pnpm wp-finish-upsert-pr, after review.json\\n' +\n 'and the build gate pass and the gated PR body is written. Your work stays local until then\\n' +\n '(the pre-merge snapshot below is kept as well, so nothing is only in one place).\\n',\n );\n return false;\n }\n\n // Push the finalized squash branch to the stable feature branch. Returns whether a push actually\n // happened — false when there is no remote branch yet (a brand-new branch is local until something\n // creates it, which in the PR flow is `gh pr create` after GatedPrPublisher's push).\n protected pushFinalized(ctx: MergeContext, base: string): boolean {\n const remoteExists = spawnSync('git', ['ls-remote', '--exit-code', '--heads', 'origin', base]).status === 0;\n if (!remoteExists) {\n process.stdout.write('No remote branch — local only.\\n');\n return false;\n }\n process.stdout.write(ctx.prNumber ? `Updating PR #${ctx.prNumber} (force-with-lease)...\\n` : 'Updating remote branch (force-with-lease)...\\n');\n this.gitExec.runGitChecked(['push', '-u', '--force-with-lease', 'origin', `${ctx.squashBranch}:${base}`], 'Failed to push to origin');\n return true;\n }\n\n // Where the branch ended up, in the recap's own words. Three distinct outcomes, and \"deferred\" must\n // never read like the failure the other two could be mistaken for.\n private remoteRecapLine(feature: string, prNumber: string, recap: SyncRecap): string {\n if (recap.pushed) {\n return `landed back on ${feature} (== origin/${feature}${prNumber ? ` == PR #${prNumber}` : ''} — names match)`;\n }\n if (recap.pushDeferred) {\n return `landed back on ${feature} (local — by design; pnpm wp-finish-upsert-pr does the one push)`;\n }\n return `landed back on ${feature} (local only — no remote branch yet)`;\n }\n\n // The explicit, numbered \"here is exactly what I did\" recap the AI (and human) reads after a sync.\n private printSyncRecap(feature: string, backupBranch: string, prNumber: string, recap: SyncRecap): void {\n const backupKept = recap.backupKept;\n const remoteLine = this.remoteRecapLine(feature, prNumber, recap);\n const keptReason = recap.hadConflict ? 'this merge had conflicts' : 'not pushed yet — wp-finish-upsert-pr will push';\n const step1 = backupKept\n ? `snapshotted your pre-merge state → ${backupBranch} (kept — ${keptReason})`\n : `snapshotted your pre-merge state → ${backupBranch} (auto-removed — clean merge, no undo needed)`;\n const trailer = backupKept\n ? ` Pre-merge snapshot trail: git branch --list '${feature}PreMerge*'\\n` +\n ` Its conflict context lives in the paired merge-<n>/ folder under .webpieces/merge-info/\\n` +\n ` Prune this run's snapshot when safe: git branch -D ${backupBranch}\\n\\n`\n : '\\n';\n process.stdout.write(\n '\\n' + SEP + '✅ Sync complete — here is exactly what I did\\n' + SEP + '\\n' +\n ` 1. ${step1}\\n` +\n ` 2. pulled origin/main\\n` +\n ` 3. squash-merged your work onto main\\n` +\n ` 4. ${remoteLine}\\n\\n` +\n trailer,\n );\n }\n}\n"]}
|
|
@@ -10,6 +10,6 @@ export declare class RunUpdate {
|
|
|
10
10
|
private readonly mergeStart;
|
|
11
11
|
private readonly mergeEnd;
|
|
12
12
|
constructor(aiBranchName: AiBranchName, mergeState: MergeState, mergeStart: MergeStart, mergeEnd: MergeEnd);
|
|
13
|
-
runUpdateFromMain(repoRoot: string, verb: MutationVerb, finishCommand: string): Promise<UpdateOutcome>;
|
|
13
|
+
runUpdateFromMain(repoRoot: string, verb: MutationVerb, finishCommand: string, pushRemote: boolean): Promise<UpdateOutcome>;
|
|
14
14
|
private runUpdate;
|
|
15
15
|
}
|
|
@@ -25,16 +25,19 @@ let RunUpdate = class RunUpdate {
|
|
|
25
25
|
}
|
|
26
26
|
// `finishCommand` is the command the AI is told to run after resolving conflicts (standalone passes
|
|
27
27
|
// `wp-finish-update`, PR flow passes `wp-finish-upsert-pr`). `verb` is the invoking bin, threaded so
|
|
28
|
-
// every branch mutation is recorded in `.webpieces/hooks/branch-mutations.log`.
|
|
29
|
-
|
|
28
|
+
// every branch mutation is recorded in `.webpieces/hooks/branch-mutations.log`. `pushRemote` is false
|
|
29
|
+
// for the PR flow, which pushes exactly once from wp-finish-upsert-pr (see MergeEndOptions).
|
|
30
|
+
// eslint-disable-next-line @typescript-eslint/max-params
|
|
31
|
+
async runUpdateFromMain(repoRoot, verb, finishCommand, pushRemote) {
|
|
30
32
|
(0, rules_config_1.logBranchMutation)(repoRoot, new rules_config_1.BranchMutationEvent(verb, 'START'));
|
|
31
|
-
const outcome = await this.runUpdate(repoRoot, verb, finishCommand);
|
|
33
|
+
const outcome = await this.runUpdate(repoRoot, verb, finishCommand, pushRemote);
|
|
32
34
|
const end = new rules_config_1.BranchMutationEvent(verb, 'END');
|
|
33
35
|
end.outcome = outcome;
|
|
34
36
|
(0, rules_config_1.logBranchMutation)(repoRoot, end);
|
|
35
37
|
return outcome;
|
|
36
38
|
}
|
|
37
|
-
|
|
39
|
+
// eslint-disable-next-line @typescript-eslint/max-params
|
|
40
|
+
async runUpdate(repoRoot, verb, finishCommand, pushRemote) {
|
|
38
41
|
const home = this.mergeState.mergeDirFor(repoRoot, this.aiBranchName.getFeatureName());
|
|
39
42
|
// Resume path: an in-progress merge is the `merge-<n>/` run dir that holds a marker.
|
|
40
43
|
const activeDir = this.mergeState.findActiveMergeRunDir(home);
|
|
@@ -43,7 +46,7 @@ let RunUpdate = class RunUpdate {
|
|
|
43
46
|
if (existing === null || !existing.validated)
|
|
44
47
|
return 'unvalidatedResume';
|
|
45
48
|
// Already validated → just finalize the branch swap (reads THIS run dir's marker).
|
|
46
|
-
await this.mergeEnd.mergeEnd(repoRoot, verb, activeDir, new merge_start_1.MergeContext(existing.currentBranch, existing.squashBranch, existing.backupBranch, existing.prNumber), null);
|
|
49
|
+
await this.mergeEnd.mergeEnd(repoRoot, verb, activeDir, new merge_start_1.MergeContext(existing.currentBranch, existing.squashBranch, existing.backupBranch, existing.prNumber), new merge_end_1.MergeEndOptions(null, pushRemote));
|
|
47
50
|
return 'finalized';
|
|
48
51
|
}
|
|
49
52
|
// Fresh update: mergeStart picks the slot number, creates its own `merge-<n>/` run dir, and
|
|
@@ -52,7 +55,7 @@ let RunUpdate = class RunUpdate {
|
|
|
52
55
|
if (result.status === 'conflict' || result.context === null) {
|
|
53
56
|
return 'conflict';
|
|
54
57
|
}
|
|
55
|
-
await this.mergeEnd.mergeEnd(repoRoot, verb, result.runDir, result.context, null);
|
|
58
|
+
await this.mergeEnd.mergeEnd(repoRoot, verb, result.runDir, result.context, new merge_end_1.MergeEndOptions(null, pushRemote));
|
|
56
59
|
return 'finalized';
|
|
57
60
|
}
|
|
58
61
|
};
|