@webpieces/pr-gate 0.4.516 → 0.4.518
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/workflow/build-artifact-gate.d.ts +10 -24
- package/src/scripts/workflow/build-artifact-gate.js +13 -50
- package/src/scripts/workflow/build-artifact-gate.js.map +1 -1
- package/src/scripts/workflow/diff-basis.d.ts +10 -1
- package/src/scripts/workflow/diff-basis.js +19 -17
- package/src/scripts/workflow/diff-basis.js.map +1 -1
- package/src/scripts/workflow/git-exec.d.ts +18 -8
- package/src/scripts/workflow/git-exec.js +24 -14
- package/src/scripts/workflow/git-exec.js.map +1 -1
- package/src/scripts/workflow/git-status.d.ts +54 -0
- package/src/scripts/workflow/git-status.js +170 -0
- package/src/scripts/workflow/git-status.js.map +1 -0
- package/src/scripts/workflow/merge-start.js +5 -0
- package/src/scripts/workflow/merge-start.js.map +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webpieces/pr-gate",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.518",
|
|
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",
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
"directory": "packages/tooling/pr-gate"
|
|
27
27
|
},
|
|
28
28
|
"dependencies": {
|
|
29
|
-
"@webpieces/rules-config": "0.4.
|
|
29
|
+
"@webpieces/rules-config": "0.4.518",
|
|
30
30
|
"@inversifyjs/binding-decorators": "1.1.5",
|
|
31
31
|
"inversify": "7.10.4",
|
|
32
32
|
"reflect-metadata": "0.2.2"
|
|
@@ -1,18 +1,7 @@
|
|
|
1
1
|
import { BuildAffected } from './build-affected';
|
|
2
2
|
import { GeneratedArtifactRegistry, GeneratedArtifacts } from './generated-artifact-registry';
|
|
3
3
|
import { GitExec } from './git-exec';
|
|
4
|
-
|
|
5
|
-
* One path `git status --porcelain` reported as NOT satisfying "committed OR staged", with the two
|
|
6
|
-
* porcelain status columns kept so a message can say why. Data-only (per CLAUDE.md).
|
|
7
|
-
*/
|
|
8
|
-
export declare class DirtyPath {
|
|
9
|
-
path: string;
|
|
10
|
-
indexStatus: string;
|
|
11
|
-
worktreeStatus: string;
|
|
12
|
-
constructor(dirtyPath: string, indexStatus: string, worktreeStatus: string);
|
|
13
|
-
/** `?? path` — untracked, i.e. the build created a brand-new file nobody has ever committed. */
|
|
14
|
-
isUntracked(): boolean;
|
|
15
|
-
}
|
|
4
|
+
import { GitStatusEntry } from './git-status';
|
|
16
5
|
/**
|
|
17
6
|
* What the repo-wide post-build tree check concluded, split into the ticket's two verdicts. Both lists
|
|
18
7
|
* can be non-empty at once, and when they are BOTH are reported — a stray artifact does not hide a
|
|
@@ -20,10 +9,10 @@ export declare class DirtyPath {
|
|
|
20
9
|
*/
|
|
21
10
|
export declare class BuildArtifactVerdict {
|
|
22
11
|
/** Verdict 1 — dirty, but the build is SUPPOSED to write it: you did not commit the regeneration. */
|
|
23
|
-
staleGenerated:
|
|
12
|
+
staleGenerated: GitStatusEntry[];
|
|
24
13
|
/** Verdict 2 — dirty and nobody declared it: the build is emitting uncommitted git artifacts. */
|
|
25
|
-
strayArtifacts:
|
|
26
|
-
constructor(staleGenerated:
|
|
14
|
+
strayArtifacts: GitStatusEntry[];
|
|
15
|
+
constructor(staleGenerated: GitStatusEntry[], strayArtifacts: GitStatusEntry[]);
|
|
27
16
|
isClean(): boolean;
|
|
28
17
|
}
|
|
29
18
|
/**
|
|
@@ -61,17 +50,14 @@ export declare class BuildArtifactGate {
|
|
|
61
50
|
*/
|
|
62
51
|
assertBuildLeftNothingUncommitted(repoRoot: string): void;
|
|
63
52
|
/**
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*/
|
|
67
|
-
classify(porcelain: string, artifacts: GeneratedArtifacts): BuildArtifactVerdict;
|
|
68
|
-
/**
|
|
69
|
-
* One porcelain line → a DirtyPath, or null when the line is blank or already satisfied.
|
|
53
|
+
* Sort every UNSATISFIED status entry into the two verdicts. Pure, so the specs drive it with
|
|
54
|
+
* literal porcelain text (run through `GitStatusParser`) instead of building repos.
|
|
70
55
|
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
56
|
+
* THE PREDICATE lives in {@link GitStatusEntry.isCommittedOrStaged} — a clean worktree column
|
|
57
|
+
* means the change is committed or staged, and `??` is excluded because its second column is not
|
|
58
|
+
* a worktree state at all.
|
|
73
59
|
*/
|
|
74
|
-
|
|
60
|
+
classify(entries: readonly GitStatusEntry[], artifacts: GeneratedArtifacts): BuildArtifactVerdict;
|
|
75
61
|
/** The full failure message — verdict 1 section, verdict 2 section, or both. */
|
|
76
62
|
private render;
|
|
77
63
|
private renderStale;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.BuildArtifactGate = exports.BuildArtifactVerdict =
|
|
3
|
+
exports.BuildArtifactGate = exports.BuildArtifactVerdict = void 0;
|
|
4
4
|
const tslib_1 = require("tslib");
|
|
5
5
|
const rules_config_1 = require("@webpieces/rules-config");
|
|
6
6
|
const inversify_1 = require("inversify");
|
|
@@ -8,25 +8,6 @@ const build_affected_1 = require("./build-affected");
|
|
|
8
8
|
const generated_artifact_registry_1 = require("./generated-artifact-registry");
|
|
9
9
|
const git_exec_1 = require("./git-exec");
|
|
10
10
|
const SEP = '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n';
|
|
11
|
-
/**
|
|
12
|
-
* One path `git status --porcelain` reported as NOT satisfying "committed OR staged", with the two
|
|
13
|
-
* porcelain status columns kept so a message can say why. Data-only (per CLAUDE.md).
|
|
14
|
-
*/
|
|
15
|
-
class DirtyPath {
|
|
16
|
-
path;
|
|
17
|
-
indexStatus; // porcelain column 1 — the INDEX (staged) state
|
|
18
|
-
worktreeStatus; // porcelain column 2 — the WORKING TREE state
|
|
19
|
-
constructor(dirtyPath, indexStatus, worktreeStatus) {
|
|
20
|
-
this.path = dirtyPath;
|
|
21
|
-
this.indexStatus = indexStatus;
|
|
22
|
-
this.worktreeStatus = worktreeStatus;
|
|
23
|
-
}
|
|
24
|
-
/** `?? path` — untracked, i.e. the build created a brand-new file nobody has ever committed. */
|
|
25
|
-
isUntracked() {
|
|
26
|
-
return this.indexStatus === '?';
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
exports.DirtyPath = DirtyPath;
|
|
30
11
|
/**
|
|
31
12
|
* What the repo-wide post-build tree check concluded, split into the ticket's two verdicts. Both lists
|
|
32
13
|
* can be non-empty at once, and when they are BOTH are reported — a stray artifact does not hide a
|
|
@@ -85,9 +66,9 @@ let BuildArtifactGate = class BuildArtifactGate {
|
|
|
85
66
|
*/
|
|
86
67
|
assertBuildLeftNothingUncommitted(repoRoot) {
|
|
87
68
|
const artifacts = this.registry.resolve(repoRoot);
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
const verdict = this.classify(this.gitExec.
|
|
69
|
+
// statusEntries, never a raw/trimmed string: the staged-vs-unstaged answer arrives as a boolean
|
|
70
|
+
// so no caller can re-derive it from column positions and get it backwards.
|
|
71
|
+
const verdict = this.classify(this.gitExec.statusEntries(repoRoot), artifacts);
|
|
91
72
|
if (verdict.isClean()) {
|
|
92
73
|
process.stdout.write('\n✅ Build left the tree committed — no uncommitted build artifacts.\n');
|
|
93
74
|
return;
|
|
@@ -95,15 +76,18 @@ let BuildArtifactGate = class BuildArtifactGate {
|
|
|
95
76
|
throw new rules_config_1.CliExitError(1, this.render(repoRoot, verdict, artifacts));
|
|
96
77
|
}
|
|
97
78
|
/**
|
|
98
|
-
*
|
|
99
|
-
*
|
|
79
|
+
* Sort every UNSATISFIED status entry into the two verdicts. Pure, so the specs drive it with
|
|
80
|
+
* literal porcelain text (run through `GitStatusParser`) instead of building repos.
|
|
81
|
+
*
|
|
82
|
+
* THE PREDICATE lives in {@link GitStatusEntry.isCommittedOrStaged} — a clean worktree column
|
|
83
|
+
* means the change is committed or staged, and `??` is excluded because its second column is not
|
|
84
|
+
* a worktree state at all.
|
|
100
85
|
*/
|
|
101
|
-
classify(
|
|
86
|
+
classify(entries, artifacts) {
|
|
102
87
|
const stale = [];
|
|
103
88
|
const stray = [];
|
|
104
|
-
for (const
|
|
105
|
-
|
|
106
|
-
if (entry === null)
|
|
89
|
+
for (const entry of entries) {
|
|
90
|
+
if (entry.isCommittedOrStaged())
|
|
107
91
|
continue;
|
|
108
92
|
if ((0, rules_config_1.matchesAnyGlob)(entry.path, artifacts.paths))
|
|
109
93
|
stale.push(entry);
|
|
@@ -112,27 +96,6 @@ let BuildArtifactGate = class BuildArtifactGate {
|
|
|
112
96
|
}
|
|
113
97
|
return new BuildArtifactVerdict(stale, stray);
|
|
114
98
|
}
|
|
115
|
-
/**
|
|
116
|
-
* One porcelain line → a DirtyPath, or null when the line is blank or already satisfied.
|
|
117
|
-
*
|
|
118
|
-
* Format is `XY <path>`, with `XY` = index+worktree status and `R old -> new` for renames (the NEW
|
|
119
|
-
* path is the one on disk, so that is the one classified). Untracked is `?? <path>`.
|
|
120
|
-
*/
|
|
121
|
-
parseLine(line) {
|
|
122
|
-
if (line.length < 4)
|
|
123
|
-
return null;
|
|
124
|
-
const indexStatus = line.charAt(0);
|
|
125
|
-
const worktreeStatus = line.charAt(1);
|
|
126
|
-
// THE PREDICATE. A clean worktree column means the change is committed or staged — satisfied.
|
|
127
|
-
// `??` is the one entry whose column 2 is not a worktree state, so it is excluded explicitly.
|
|
128
|
-
if (worktreeStatus === ' ' && indexStatus !== '?')
|
|
129
|
-
return null;
|
|
130
|
-
const raw = line.slice(3).trim();
|
|
131
|
-
const arrow = raw.indexOf(' -> ');
|
|
132
|
-
const filePath = arrow >= 0 ? raw.slice(arrow + 4) : raw;
|
|
133
|
-
const unquoted = filePath.startsWith('"') && filePath.endsWith('"') ? filePath.slice(1, -1) : filePath;
|
|
134
|
-
return unquoted === '' ? null : new DirtyPath(unquoted, indexStatus, worktreeStatus);
|
|
135
|
-
}
|
|
136
99
|
/** The full failure message — verdict 1 section, verdict 2 section, or both. */
|
|
137
100
|
render(repoRoot, verdict, artifacts) {
|
|
138
101
|
const buildCommand = this.buildAffected.resolveBuildCommand(repoRoot);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"build-artifact-gate.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/pr-gate/src/scripts/workflow/build-artifact-gate.ts"],"names":[],"mappings":"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","sourcesContent":["import { CliExitError, matchesAnyGlob } from '@webpieces/rules-config';\nimport { injectable, bindingScopeValues } from 'inversify';\nimport { BuildAffected } from './build-affected';\nimport { GeneratedArtifactRegistry, GeneratedArtifacts } from './generated-artifact-registry';\nimport { GitExec } from './git-exec';\n\nconst SEP = '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\\n';\n\n/**\n * One path `git status --porcelain` reported as NOT satisfying \"committed OR staged\", with the two\n * porcelain status columns kept so a message can say why. Data-only (per CLAUDE.md).\n */\nexport class DirtyPath {\n path: string;\n indexStatus: string; // porcelain column 1 — the INDEX (staged) state\n worktreeStatus: string; // porcelain column 2 — the WORKING TREE state\n\n constructor(dirtyPath: string, indexStatus: string, worktreeStatus: string) {\n this.path = dirtyPath;\n this.indexStatus = indexStatus;\n this.worktreeStatus = worktreeStatus;\n }\n\n /** `?? path` — untracked, i.e. the build created a brand-new file nobody has ever committed. */\n isUntracked(): boolean {\n return this.indexStatus === '?';\n }\n}\n\n/**\n * What the repo-wide post-build tree check concluded, split into the ticket's two verdicts. Both lists\n * can be non-empty at once, and when they are BOTH are reported — a stray artifact does not hide a\n * stale design file or vice versa.\n */\nexport class BuildArtifactVerdict {\n /** Verdict 1 — dirty, but the build is SUPPOSED to write it: you did not commit the regeneration. */\n staleGenerated: DirtyPath[];\n /** Verdict 2 — dirty and nobody declared it: the build is emitting uncommitted git artifacts. */\n strayArtifacts: DirtyPath[];\n\n constructor(staleGenerated: DirtyPath[], strayArtifacts: DirtyPath[]) {\n this.staleGenerated = staleGenerated;\n this.strayArtifacts = strayArtifacts;\n }\n\n isClean(): boolean {\n return this.staleGenerated.length === 0 && this.strayArtifacts.length === 0;\n }\n}\n\n/**\n * The repo-wide \"did the build leave anything uncommitted?\" gate, run in `wp-review-upsert-pr` right\n * after `buildCommand`.\n *\n * It REPLACES the per-project `validate-di-graph-unchanged` nx target, which asked the same question in\n * the wrong place. That target used `git status --porcelain` as its oracle while itself dirtying the\n * tree, so nx (which hashes by file CONTENTS) saw one hash produce both a pass and a fail and labelled\n * the task \"flaky\" — it was not flaky, it was stateful in a dimension nx cannot see. It also could not\n * pass mid-merge, where `merge-in-progress-guard` blocks the `git commit` it demanded.\n *\n * THE PREDICATE IS \"committed OR staged\", and that is the whole trick:\n *\n * porcelain column 1 = index state, column 2 = working-tree state.\n * Satisfied ⇔ column 2 is ' ' AND the entry is not `??`.\n *\n * A normal branch commits its regenerated design files → clean → passes. Mid-merge `git commit` is\n * blocked but `git add` is expected (and the gate itself commits the resolution) → staged → passes.\n * ONE rule covering both, with no merge special-case and no skip, because a special case is a second\n * rule and two rules for one invariant is how they drift.\n *\n * Coverage is strictly larger than the target it replaces: that one only ever looked at `design.*` in\n * one project, this looks at the WHOLE repo, so a build that quietly writes anywhere else is caught for\n * the first time.\n */\n@injectable(bindingScopeValues.Singleton)\nexport class BuildArtifactGate {\n constructor(\n private readonly gitExec: GitExec,\n private readonly registry: GeneratedArtifactRegistry,\n private readonly buildAffected: BuildAffected,\n ) {}\n\n /**\n * Run the check and throw CliExitError when the build left the tree dirty. Called immediately after\n * the build gate passes, while the tree still holds exactly what the build produced.\n */\n assertBuildLeftNothingUncommitted(repoRoot: string): void {\n const artifacts = this.registry.resolve(repoRoot);\n // porcelainStatus, NOT uncommittedFiles: the latter trims, which eats the leading space that IS\n // the index column, turning every unstaged \" M path\" into a staged-looking \"M path\".\n const verdict = this.classify(this.gitExec.porcelainStatus(repoRoot), artifacts);\n if (verdict.isClean()) {\n process.stdout.write('\\n✅ Build left the tree committed — no uncommitted build artifacts.\\n');\n return;\n }\n throw new CliExitError(1, this.render(repoRoot, verdict, artifacts));\n }\n\n /**\n * Parse `git status --porcelain` and sort every UNSATISFIED entry into the two verdicts. Pure, so\n * the specs drive it with literal porcelain text instead of building repos for every case.\n */\n classify(porcelain: string, artifacts: GeneratedArtifacts): BuildArtifactVerdict {\n const stale: DirtyPath[] = [];\n const stray: DirtyPath[] = [];\n for (const line of porcelain.split('\\n')) {\n const entry = this.parseLine(line);\n if (entry === null) continue;\n if (matchesAnyGlob(entry.path, artifacts.paths)) stale.push(entry);\n else stray.push(entry);\n }\n return new BuildArtifactVerdict(stale, stray);\n }\n\n /**\n * One porcelain line → a DirtyPath, or null when the line is blank or already satisfied.\n *\n * Format is `XY <path>`, with `XY` = index+worktree status and `R old -> new` for renames (the NEW\n * path is the one on disk, so that is the one classified). Untracked is `?? <path>`.\n */\n private parseLine(line: string): DirtyPath | null {\n if (line.length < 4) return null;\n const indexStatus = line.charAt(0);\n const worktreeStatus = line.charAt(1);\n // THE PREDICATE. A clean worktree column means the change is committed or staged — satisfied.\n // `??` is the one entry whose column 2 is not a worktree state, so it is excluded explicitly.\n if (worktreeStatus === ' ' && indexStatus !== '?') return null;\n const raw = line.slice(3).trim();\n const arrow = raw.indexOf(' -> ');\n const filePath = arrow >= 0 ? raw.slice(arrow + 4) : raw;\n const unquoted = filePath.startsWith('\"') && filePath.endsWith('\"') ? filePath.slice(1, -1) : filePath;\n return unquoted === '' ? null : new DirtyPath(unquoted, indexStatus, worktreeStatus);\n }\n\n /** The full failure message — verdict 1 section, verdict 2 section, or both. */\n private render(repoRoot: string, verdict: BuildArtifactVerdict, artifacts: GeneratedArtifacts): string {\n const buildCommand = this.buildAffected.resolveBuildCommand(repoRoot);\n const parts: string[] = ['\\n' + SEP + '❌ The build left uncommitted changes in the working tree\\n' + SEP];\n if (verdict.staleGenerated.length > 0) parts.push(this.renderStale(verdict, buildCommand));\n if (verdict.strayArtifacts.length > 0) parts.push(this.renderStray(verdict));\n parts.push(\n `\\nThe rule is one line: after the build, every changed path must be COMMITTED **or** STAGED.\\n` +\n `Staged counts, which is why this also works mid-3-point-merge, where \\`git commit\\` is blocked\\n` +\n `by merge-in-progress-guard but \\`git add\\` is exactly what you are supposed to do.\\n\\n` +\n `Known-generated paths came from: ${artifacts.source}\\n` + SEP);\n return parts.join('');\n }\n\n private renderStale(verdict: BuildArtifactVerdict, buildCommand: string): string {\n return `\\nYou did not run \\`${buildCommand}\\` and commit the regenerated design files, so the\\n` +\n `review cannot proceed. These are declared build outputs and the build just rewrote them:\\n\\n` +\n this.listing(verdict.staleGenerated) +\n `\\nFix it by running the build and committing (or staging) the result:\\n\\n` +\n ` ${buildCommand}\\n` +\n ` git add -A && git commit -m \"regenerate design files\"\\n`;\n }\n\n private renderStray(verdict: BuildArtifactVerdict): string {\n return `\\nYour build is generating uncommitted git artifacts, so I cannot continue the PR process.\\n` +\n `Either (1, best) write them to an output directory that is in \\`.gitignore\\`, or (2) add a new\\n` +\n `dir to \\`.gitignore\\`.\\n\\n` +\n `These paths are NOT declared as the output of any build target:\\n\\n` +\n this.listing(verdict.strayArtifacts);\n }\n\n private listing(entries: readonly DirtyPath[]): string {\n return entries\n .map((e: DirtyPath): string => ` ${e.indexStatus}${e.worktreeStatus} ${e.path}${e.isUntracked() ? ' (untracked — brand new)' : ''}\\n`)\n .join('');\n }\n}\n"]}
|
|
1
|
+
{"version":3,"file":"build-artifact-gate.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/pr-gate/src/scripts/workflow/build-artifact-gate.ts"],"names":[],"mappings":"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","sourcesContent":["import { CliExitError, matchesAnyGlob } from '@webpieces/rules-config';\nimport { injectable, bindingScopeValues } from 'inversify';\nimport { BuildAffected } from './build-affected';\nimport { GeneratedArtifactRegistry, GeneratedArtifacts } from './generated-artifact-registry';\nimport { GitExec } from './git-exec';\nimport { GitStatusEntry } from './git-status';\n\nconst SEP = '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\\n';\n\n/**\n * What the repo-wide post-build tree check concluded, split into the ticket's two verdicts. Both lists\n * can be non-empty at once, and when they are BOTH are reported — a stray artifact does not hide a\n * stale design file or vice versa.\n */\nexport class BuildArtifactVerdict {\n /** Verdict 1 — dirty, but the build is SUPPOSED to write it: you did not commit the regeneration. */\n staleGenerated: GitStatusEntry[];\n /** Verdict 2 — dirty and nobody declared it: the build is emitting uncommitted git artifacts. */\n strayArtifacts: GitStatusEntry[];\n\n constructor(staleGenerated: GitStatusEntry[], strayArtifacts: GitStatusEntry[]) {\n this.staleGenerated = staleGenerated;\n this.strayArtifacts = strayArtifacts;\n }\n\n isClean(): boolean {\n return this.staleGenerated.length === 0 && this.strayArtifacts.length === 0;\n }\n}\n\n/**\n * The repo-wide \"did the build leave anything uncommitted?\" gate, run in `wp-review-upsert-pr` right\n * after `buildCommand`.\n *\n * It REPLACES the per-project `validate-di-graph-unchanged` nx target, which asked the same question in\n * the wrong place. That target used `git status --porcelain` as its oracle while itself dirtying the\n * tree, so nx (which hashes by file CONTENTS) saw one hash produce both a pass and a fail and labelled\n * the task \"flaky\" — it was not flaky, it was stateful in a dimension nx cannot see. It also could not\n * pass mid-merge, where `merge-in-progress-guard` blocks the `git commit` it demanded.\n *\n * THE PREDICATE IS \"committed OR staged\", and that is the whole trick:\n *\n * porcelain column 1 = index state, column 2 = working-tree state.\n * Satisfied ⇔ column 2 is ' ' AND the entry is not `??`.\n *\n * A normal branch commits its regenerated design files → clean → passes. Mid-merge `git commit` is\n * blocked but `git add` is expected (and the gate itself commits the resolution) → staged → passes.\n * ONE rule covering both, with no merge special-case and no skip, because a special case is a second\n * rule and two rules for one invariant is how they drift.\n *\n * Coverage is strictly larger than the target it replaces: that one only ever looked at `design.*` in\n * one project, this looks at the WHOLE repo, so a build that quietly writes anywhere else is caught for\n * the first time.\n */\n@injectable(bindingScopeValues.Singleton)\nexport class BuildArtifactGate {\n constructor(\n private readonly gitExec: GitExec,\n private readonly registry: GeneratedArtifactRegistry,\n private readonly buildAffected: BuildAffected,\n ) {}\n\n /**\n * Run the check and throw CliExitError when the build left the tree dirty. Called immediately after\n * the build gate passes, while the tree still holds exactly what the build produced.\n */\n assertBuildLeftNothingUncommitted(repoRoot: string): void {\n const artifacts = this.registry.resolve(repoRoot);\n // statusEntries, never a raw/trimmed string: the staged-vs-unstaged answer arrives as a boolean\n // so no caller can re-derive it from column positions and get it backwards.\n const verdict = this.classify(this.gitExec.statusEntries(repoRoot), artifacts);\n if (verdict.isClean()) {\n process.stdout.write('\\n✅ Build left the tree committed — no uncommitted build artifacts.\\n');\n return;\n }\n throw new CliExitError(1, this.render(repoRoot, verdict, artifacts));\n }\n\n /**\n * Sort every UNSATISFIED status entry into the two verdicts. Pure, so the specs drive it with\n * literal porcelain text (run through `GitStatusParser`) instead of building repos.\n *\n * THE PREDICATE lives in {@link GitStatusEntry.isCommittedOrStaged} — a clean worktree column\n * means the change is committed or staged, and `??` is excluded because its second column is not\n * a worktree state at all.\n */\n classify(entries: readonly GitStatusEntry[], artifacts: GeneratedArtifacts): BuildArtifactVerdict {\n const stale: GitStatusEntry[] = [];\n const stray: GitStatusEntry[] = [];\n for (const entry of entries) {\n if (entry.isCommittedOrStaged()) continue;\n if (matchesAnyGlob(entry.path, artifacts.paths)) stale.push(entry);\n else stray.push(entry);\n }\n return new BuildArtifactVerdict(stale, stray);\n }\n\n /** The full failure message — verdict 1 section, verdict 2 section, or both. */\n private render(repoRoot: string, verdict: BuildArtifactVerdict, artifacts: GeneratedArtifacts): string {\n const buildCommand = this.buildAffected.resolveBuildCommand(repoRoot);\n const parts: string[] = ['\\n' + SEP + '❌ The build left uncommitted changes in the working tree\\n' + SEP];\n if (verdict.staleGenerated.length > 0) parts.push(this.renderStale(verdict, buildCommand));\n if (verdict.strayArtifacts.length > 0) parts.push(this.renderStray(verdict));\n parts.push(\n `\\nThe rule is one line: after the build, every changed path must be COMMITTED **or** STAGED.\\n` +\n `Staged counts, which is why this also works mid-3-point-merge, where \\`git commit\\` is blocked\\n` +\n `by merge-in-progress-guard but \\`git add\\` is exactly what you are supposed to do.\\n\\n` +\n `Known-generated paths came from: ${artifacts.source}\\n` + SEP);\n return parts.join('');\n }\n\n private renderStale(verdict: BuildArtifactVerdict, buildCommand: string): string {\n return `\\nYou did not run \\`${buildCommand}\\` and commit the regenerated design files, so the\\n` +\n `review cannot proceed. These are declared build outputs and the build just rewrote them:\\n\\n` +\n this.listing(verdict.staleGenerated) +\n `\\nFix it by running the build and committing (or staging) the result:\\n\\n` +\n ` ${buildCommand}\\n` +\n ` git add -A && git commit -m \"regenerate design files\"\\n`;\n }\n\n private renderStray(verdict: BuildArtifactVerdict): string {\n return `\\nYour build is generating uncommitted git artifacts, so I cannot continue the PR process.\\n` +\n `Either (1, best) write them to an output directory that is in \\`.gitignore\\`, or (2) add a new\\n` +\n `dir to \\`.gitignore\\`.\\n\\n` +\n `These paths are NOT declared as the output of any build target:\\n\\n` +\n this.listing(verdict.strayArtifacts);\n }\n\n private listing(entries: readonly GitStatusEntry[]): string {\n return entries\n .map((e: GitStatusEntry): string => ` ${e.indexStatus}${e.worktreeStatus} ${e.path}${e.isUntracked() ? ' (untracked — brand new)' : ''}\\n`)\n .join('');\n }\n}\n"]}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { ForkPoint } from './git-findForkPoint';
|
|
2
|
+
import { GitStatusParser } from './git-status';
|
|
2
3
|
/**
|
|
3
4
|
* The ONE resolved answer to "what exactly is this branch's diff, and what command reproduces it?"
|
|
4
5
|
*
|
|
@@ -47,7 +48,8 @@ export declare class DiffBasis {
|
|
|
47
48
|
*/
|
|
48
49
|
export declare class DiffBasisResolver {
|
|
49
50
|
private readonly forkPoint;
|
|
50
|
-
|
|
51
|
+
private readonly statusParser;
|
|
52
|
+
constructor(forkPoint: ForkPoint, statusParser: GitStatusParser);
|
|
51
53
|
/**
|
|
52
54
|
* Resolve the basis. The command shape is the whole point:
|
|
53
55
|
*
|
|
@@ -62,7 +64,14 @@ export declare class DiffBasisResolver {
|
|
|
62
64
|
* Every path that makes the tree dirty: tracked modifications/staged changes PLUS untracked files.
|
|
63
65
|
* Untracked must be included — `git status --porcelain` reports them as `??`, and they are exactly the
|
|
64
66
|
* files a brand-new migration or terraform rule arrives as, which is when a checklist most wants to fire.
|
|
67
|
+
*
|
|
68
|
+
* Parsed by {@link GitStatusParser} off the RAW (untrimmed) output. This used to hand-roll
|
|
69
|
+
* `line.slice(3).trim()` over `gitOut(...)`, which trims — and on a porcelain line the leading space
|
|
70
|
+
* IS the index column, so trimming shifted the FIRST line left by one and `slice(3)` then cut a
|
|
71
|
+
* character off the front of its path. It also left git's quoting on, so `"a b.txt"` was reported
|
|
72
|
+
* with the quotes still attached and matched no checklist glob.
|
|
65
73
|
*/
|
|
66
74
|
private dirtyPaths;
|
|
67
75
|
private gitOut;
|
|
76
|
+
private rawGitOut;
|
|
68
77
|
}
|
|
@@ -5,6 +5,7 @@ const tslib_1 = require("tslib");
|
|
|
5
5
|
const child_process_1 = require("child_process");
|
|
6
6
|
const inversify_1 = require("inversify");
|
|
7
7
|
const git_findForkPoint_1 = require("./git-findForkPoint");
|
|
8
|
+
const git_status_1 = require("./git-status");
|
|
8
9
|
/**
|
|
9
10
|
* The ONE resolved answer to "what exactly is this branch's diff, and what command reproduces it?"
|
|
10
11
|
*
|
|
@@ -79,8 +80,10 @@ exports.DiffBasis = DiffBasis;
|
|
|
79
80
|
*/
|
|
80
81
|
let DiffBasisResolver = class DiffBasisResolver {
|
|
81
82
|
forkPoint;
|
|
82
|
-
|
|
83
|
+
statusParser;
|
|
84
|
+
constructor(forkPoint, statusParser) {
|
|
83
85
|
this.forkPoint = forkPoint;
|
|
86
|
+
this.statusParser = statusParser;
|
|
84
87
|
}
|
|
85
88
|
/**
|
|
86
89
|
* Resolve the basis. The command shape is the whole point:
|
|
@@ -109,31 +112,30 @@ let DiffBasisResolver = class DiffBasisResolver {
|
|
|
109
112
|
* Every path that makes the tree dirty: tracked modifications/staged changes PLUS untracked files.
|
|
110
113
|
* Untracked must be included — `git status --porcelain` reports them as `??`, and they are exactly the
|
|
111
114
|
* files a brand-new migration or terraform rule arrives as, which is when a checklist most wants to fire.
|
|
115
|
+
*
|
|
116
|
+
* Parsed by {@link GitStatusParser} off the RAW (untrimmed) output. This used to hand-roll
|
|
117
|
+
* `line.slice(3).trim()` over `gitOut(...)`, which trims — and on a porcelain line the leading space
|
|
118
|
+
* IS the index column, so trimming shifted the FIRST line left by one and `slice(3)` then cut a
|
|
119
|
+
* character off the front of its path. It also left git's quoting on, so `"a b.txt"` was reported
|
|
120
|
+
* with the quotes still attached and matched no checklist glob.
|
|
112
121
|
*/
|
|
113
122
|
dirtyPaths(repoRoot) {
|
|
114
|
-
const out = this.
|
|
115
|
-
|
|
116
|
-
return [];
|
|
117
|
-
const paths = [];
|
|
118
|
-
for (const line of out.split('\n')) {
|
|
119
|
-
// Porcelain v1: 2 status chars, a space, then the path. A rename reads `R old -> new`; the new
|
|
120
|
-
// name is the one that exists on disk, so it is the one a reviewer can open.
|
|
121
|
-
const raw = line.slice(3).trim();
|
|
122
|
-
if (raw === '')
|
|
123
|
-
continue;
|
|
124
|
-
const arrow = raw.indexOf(' -> ');
|
|
125
|
-
paths.push(arrow === -1 ? raw : raw.slice(arrow + 4));
|
|
126
|
-
}
|
|
127
|
-
return paths;
|
|
123
|
+
const out = this.rawGitOut(repoRoot, ['status', '--porcelain', '--untracked-files=all']);
|
|
124
|
+
return this.statusParser.parse(out).map((entry) => entry.path);
|
|
128
125
|
}
|
|
129
126
|
gitOut(repoRoot, args) {
|
|
127
|
+
return this.rawGitOut(repoRoot, args).trim();
|
|
128
|
+
}
|
|
129
|
+
// Untrimmed — mandatory for `git status --porcelain`, where leading whitespace carries meaning.
|
|
130
|
+
rawGitOut(repoRoot, args) {
|
|
130
131
|
const result = (0, child_process_1.spawnSync)('git', args, { cwd: repoRoot, encoding: 'utf8' });
|
|
131
|
-
return result.status === 0 ? (result.stdout ?? '')
|
|
132
|
+
return result.status === 0 ? (result.stdout ?? '') : '';
|
|
132
133
|
}
|
|
133
134
|
};
|
|
134
135
|
exports.DiffBasisResolver = DiffBasisResolver;
|
|
135
136
|
exports.DiffBasisResolver = DiffBasisResolver = tslib_1.__decorate([
|
|
136
137
|
(0, inversify_1.injectable)(inversify_1.bindingScopeValues.Singleton),
|
|
137
|
-
tslib_1.__metadata("design:paramtypes", [git_findForkPoint_1.ForkPoint
|
|
138
|
+
tslib_1.__metadata("design:paramtypes", [git_findForkPoint_1.ForkPoint,
|
|
139
|
+
git_status_1.GitStatusParser])
|
|
138
140
|
], DiffBasisResolver);
|
|
139
141
|
//# sourceMappingURL=diff-basis.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"diff-basis.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/pr-gate/src/scripts/workflow/diff-basis.ts"],"names":[],"mappings":"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","sourcesContent":["import { spawnSync } from 'child_process';\nimport { injectable, bindingScopeValues } from 'inversify';\nimport { ForkPoint } from './git-findForkPoint';\n\n/**\n * The ONE resolved answer to \"what exactly is this branch's diff, and what command reproduces it?\"\n *\n * It exists because the two halves of the review flow used to disagree. `ChecklistScanner` computes the\n * changed-FILE set by calling `getChangedFiles` with NO head — base→WORKING TREE, unioning in untracked\n * files — while `ChecklistInstructionsService` printed `git diff <base> HEAD -- <file>` to every\n * reviewer. On a dirty tree those are different ranges, and the printed command returns NOTHING: a reviewer\n * is handed a file list and a command that shows it nothing. That is not hypothetical — a reviewer subagent\n * on monorepo-nx2 ran the printed command, got empty output, and had to guess its way to `git diff HEAD`.\n *\n * So the command is DERIVED from the same basis the file set is, and no caller writes one by hand.\n *\n * Data-only (per CLAUDE.md).\n */\nexport class DiffBasis {\n // The 3-point fork point of main. '' when neither origin/main nor main resolves.\n // Alias of {@link hashForkPoint} — see the hash-point trio below.\n base: string;\n // The REAL sha, never the literal string 'HEAD'. A recorded 'HEAD' is worthless the moment a commit\n // lands on top of it, and the stage receipt has to compare shas to know the tree moved under a review.\n // Alias of {@link hashFeatureHead}.\n headSha: string;\n /**\n * The SAME three hash points the 3-point merge records, under the SAME names\n * (`merge-info/<branch>/updatemain-hashes.json` → hashForkPoint / hashFeatureHead / hashMainHead).\n *\n * Two reasons this matters. First, vocabulary: the merge half of the system said \"hashForkPoint\"\n * while the review half said \"base\" for the identical sha, so nothing could be grepped across both.\n * Second, and the real gap: the review side recorded only TWO of the three. Without `hashMainHead`\n * nothing downstream can answer \"did main move while this branch was under review?\" — which is\n * exactly what you want to know when a review looks stale or a merge is about to surprise you.\n *\n * `hashMainHead` is '' when origin/main cannot be resolved offline; it is a REPORT, never a gate.\n */\n hashForkPoint: string;\n hashFeatureHead: string;\n hashMainHead: string;\n // True when the diff includes staged/unstaged/untracked work, i.e. the range is base→working tree.\n dirty: boolean;\n // Exactly WHICH paths make it dirty, so a message can name them instead of asserting dirtiness.\n dirtyFiles: string[];\n // The command that reproduces the WHOLE diff. Correct for both clean and dirty; see DiffBasisResolver.\n diffCommand: string;\n // The same command with a `-- <file>` tail — what every reviewer instruction prints.\n fileDiffCommand: string;\n\n // eslint-disable-next-line @typescript-eslint/max-params\n constructor(base = '', headSha = '', dirty = false, dirtyFiles: string[] = [], diffCommand = '', fileDiffCommand = '', hashMainHead = '') {\n this.base = base;\n this.headSha = headSha;\n this.dirty = dirty;\n this.dirtyFiles = dirtyFiles;\n this.diffCommand = diffCommand;\n this.fileDiffCommand = fileDiffCommand;\n // The trio is DERIVED from base/headSha rather than passed separately, so the two names for one\n // sha cannot drift apart into disagreeing values.\n this.hashForkPoint = base;\n this.hashFeatureHead = headSha;\n this.hashMainHead = hashMainHead;\n }\n\n // True when there is no usable base — callers must SAY so rather than print a command that cannot work.\n get unresolved(): boolean {\n return this.base.trim() === '';\n }\n}\n\n/**\n * Resolves {@link DiffBasis} for a repo. Injects {@link ForkPoint} so the base is the SAME fork-point\n * computation the checklist matching uses — never `DiffScope.resolveBase`, which overlays NX_BASE/NX_HEAD\n * from the environment and would make review coverage depend on an env var.\n *\n * `@injectable(bindingScopeValues.Singleton)` so it is injected by type and drawn in the DI design.\n */\n@injectable(bindingScopeValues.Singleton)\nexport class DiffBasisResolver {\n constructor(private readonly forkPoint: ForkPoint) {}\n\n /**\n * Resolve the basis. The command shape is the whole point:\n *\n * clean tree → `git diff <base> <headSha>` (commit-to-commit; reproducible later, unlike 'HEAD')\n * dirty tree → `git diff <base>` (NO head — this is what includes the working tree)\n *\n * Omitting the head on a dirty tree is not a shortcut, it is the only form that shows uncommitted work,\n * and supplying `HEAD` there is precisely the bug this class exists to make unrepresentable.\n */\n resolve(repoRoot: string): DiffBasis {\n const base = this.forkPoint.resolveForkPoint(repoRoot);\n const headSha = this.gitOut(repoRoot, ['rev-parse', 'HEAD']);\n // Point C. Deliberately does NOT fetch — this must work offline, like resolveForkPoint. So it is\n // main as this clone last saw it, which is the honest answer to \"has main moved under me?\" that a\n // local command can give. '' when origin/main is unknown (no remote yet).\n const mainHead = this.gitOut(repoRoot, ['rev-parse', 'origin/main']);\n const dirtyFiles = this.dirtyPaths(repoRoot);\n const dirty = dirtyFiles.length > 0;\n if (base === '') return new DiffBasis('', headSha, dirty, dirtyFiles, '', '', mainHead);\n const whole = dirty ? `git diff ${base}` : `git diff ${base} ${headSha}`;\n return new DiffBasis(base, headSha, dirty, dirtyFiles, whole, `${whole} -- <file>`, mainHead);\n }\n\n /**\n * Every path that makes the tree dirty: tracked modifications/staged changes PLUS untracked files.\n * Untracked must be included — `git status --porcelain` reports them as `??`, and they are exactly the\n * files a brand-new migration or terraform rule arrives as, which is when a checklist most wants to fire.\n */\n private dirtyPaths(repoRoot: string): string[] {\n const out = this.gitOut(repoRoot, ['status', '--porcelain', '--untracked-files=all']);\n if (out === '') return [];\n const paths: string[] = [];\n for (const line of out.split('\\n')) {\n // Porcelain v1: 2 status chars, a space, then the path. A rename reads `R old -> new`; the new\n // name is the one that exists on disk, so it is the one a reviewer can open.\n const raw = line.slice(3).trim();\n if (raw === '') continue;\n const arrow = raw.indexOf(' -> ');\n paths.push(arrow === -1 ? raw : raw.slice(arrow + 4));\n }\n return paths;\n }\n\n private gitOut(repoRoot: string, args: string[]): string {\n const result = spawnSync('git', args, { cwd: repoRoot, encoding: 'utf8' });\n return result.status === 0 ? (result.stdout ?? '').trim() : '';\n }\n}\n"]}
|
|
1
|
+
{"version":3,"file":"diff-basis.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/pr-gate/src/scripts/workflow/diff-basis.ts"],"names":[],"mappings":"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","sourcesContent":["import { spawnSync } from 'child_process';\nimport { injectable, bindingScopeValues } from 'inversify';\nimport { ForkPoint } from './git-findForkPoint';\nimport { GitStatusEntry, GitStatusParser } from './git-status';\n\n/**\n * The ONE resolved answer to \"what exactly is this branch's diff, and what command reproduces it?\"\n *\n * It exists because the two halves of the review flow used to disagree. `ChecklistScanner` computes the\n * changed-FILE set by calling `getChangedFiles` with NO head — base→WORKING TREE, unioning in untracked\n * files — while `ChecklistInstructionsService` printed `git diff <base> HEAD -- <file>` to every\n * reviewer. On a dirty tree those are different ranges, and the printed command returns NOTHING: a reviewer\n * is handed a file list and a command that shows it nothing. That is not hypothetical — a reviewer subagent\n * on monorepo-nx2 ran the printed command, got empty output, and had to guess its way to `git diff HEAD`.\n *\n * So the command is DERIVED from the same basis the file set is, and no caller writes one by hand.\n *\n * Data-only (per CLAUDE.md).\n */\nexport class DiffBasis {\n // The 3-point fork point of main. '' when neither origin/main nor main resolves.\n // Alias of {@link hashForkPoint} — see the hash-point trio below.\n base: string;\n // The REAL sha, never the literal string 'HEAD'. A recorded 'HEAD' is worthless the moment a commit\n // lands on top of it, and the stage receipt has to compare shas to know the tree moved under a review.\n // Alias of {@link hashFeatureHead}.\n headSha: string;\n /**\n * The SAME three hash points the 3-point merge records, under the SAME names\n * (`merge-info/<branch>/updatemain-hashes.json` → hashForkPoint / hashFeatureHead / hashMainHead).\n *\n * Two reasons this matters. First, vocabulary: the merge half of the system said \"hashForkPoint\"\n * while the review half said \"base\" for the identical sha, so nothing could be grepped across both.\n * Second, and the real gap: the review side recorded only TWO of the three. Without `hashMainHead`\n * nothing downstream can answer \"did main move while this branch was under review?\" — which is\n * exactly what you want to know when a review looks stale or a merge is about to surprise you.\n *\n * `hashMainHead` is '' when origin/main cannot be resolved offline; it is a REPORT, never a gate.\n */\n hashForkPoint: string;\n hashFeatureHead: string;\n hashMainHead: string;\n // True when the diff includes staged/unstaged/untracked work, i.e. the range is base→working tree.\n dirty: boolean;\n // Exactly WHICH paths make it dirty, so a message can name them instead of asserting dirtiness.\n dirtyFiles: string[];\n // The command that reproduces the WHOLE diff. Correct for both clean and dirty; see DiffBasisResolver.\n diffCommand: string;\n // The same command with a `-- <file>` tail — what every reviewer instruction prints.\n fileDiffCommand: string;\n\n // eslint-disable-next-line @typescript-eslint/max-params\n constructor(base = '', headSha = '', dirty = false, dirtyFiles: string[] = [], diffCommand = '', fileDiffCommand = '', hashMainHead = '') {\n this.base = base;\n this.headSha = headSha;\n this.dirty = dirty;\n this.dirtyFiles = dirtyFiles;\n this.diffCommand = diffCommand;\n this.fileDiffCommand = fileDiffCommand;\n // The trio is DERIVED from base/headSha rather than passed separately, so the two names for one\n // sha cannot drift apart into disagreeing values.\n this.hashForkPoint = base;\n this.hashFeatureHead = headSha;\n this.hashMainHead = hashMainHead;\n }\n\n // True when there is no usable base — callers must SAY so rather than print a command that cannot work.\n get unresolved(): boolean {\n return this.base.trim() === '';\n }\n}\n\n/**\n * Resolves {@link DiffBasis} for a repo. Injects {@link ForkPoint} so the base is the SAME fork-point\n * computation the checklist matching uses — never `DiffScope.resolveBase`, which overlays NX_BASE/NX_HEAD\n * from the environment and would make review coverage depend on an env var.\n *\n * `@injectable(bindingScopeValues.Singleton)` so it is injected by type and drawn in the DI design.\n */\n@injectable(bindingScopeValues.Singleton)\nexport class DiffBasisResolver {\n constructor(\n private readonly forkPoint: ForkPoint,\n private readonly statusParser: GitStatusParser,\n ) {}\n\n /**\n * Resolve the basis. The command shape is the whole point:\n *\n * clean tree → `git diff <base> <headSha>` (commit-to-commit; reproducible later, unlike 'HEAD')\n * dirty tree → `git diff <base>` (NO head — this is what includes the working tree)\n *\n * Omitting the head on a dirty tree is not a shortcut, it is the only form that shows uncommitted work,\n * and supplying `HEAD` there is precisely the bug this class exists to make unrepresentable.\n */\n resolve(repoRoot: string): DiffBasis {\n const base = this.forkPoint.resolveForkPoint(repoRoot);\n const headSha = this.gitOut(repoRoot, ['rev-parse', 'HEAD']);\n // Point C. Deliberately does NOT fetch — this must work offline, like resolveForkPoint. So it is\n // main as this clone last saw it, which is the honest answer to \"has main moved under me?\" that a\n // local command can give. '' when origin/main is unknown (no remote yet).\n const mainHead = this.gitOut(repoRoot, ['rev-parse', 'origin/main']);\n const dirtyFiles = this.dirtyPaths(repoRoot);\n const dirty = dirtyFiles.length > 0;\n if (base === '') return new DiffBasis('', headSha, dirty, dirtyFiles, '', '', mainHead);\n const whole = dirty ? `git diff ${base}` : `git diff ${base} ${headSha}`;\n return new DiffBasis(base, headSha, dirty, dirtyFiles, whole, `${whole} -- <file>`, mainHead);\n }\n\n /**\n * Every path that makes the tree dirty: tracked modifications/staged changes PLUS untracked files.\n * Untracked must be included — `git status --porcelain` reports them as `??`, and they are exactly the\n * files a brand-new migration or terraform rule arrives as, which is when a checklist most wants to fire.\n *\n * Parsed by {@link GitStatusParser} off the RAW (untrimmed) output. This used to hand-roll\n * `line.slice(3).trim()` over `gitOut(...)`, which trims — and on a porcelain line the leading space\n * IS the index column, so trimming shifted the FIRST line left by one and `slice(3)` then cut a\n * character off the front of its path. It also left git's quoting on, so `\"a b.txt\"` was reported\n * with the quotes still attached and matched no checklist glob.\n */\n private dirtyPaths(repoRoot: string): string[] {\n const out = this.rawGitOut(repoRoot, ['status', '--porcelain', '--untracked-files=all']);\n return this.statusParser.parse(out).map((entry: GitStatusEntry): string => entry.path);\n }\n\n private gitOut(repoRoot: string, args: string[]): string {\n return this.rawGitOut(repoRoot, args).trim();\n }\n\n // Untrimmed — mandatory for `git status --porcelain`, where leading whitespace carries meaning.\n private rawGitOut(repoRoot: string, args: string[]): string {\n const result = spawnSync('git', args, { cwd: repoRoot, encoding: 'utf8' });\n return result.status === 0 ? (result.stdout ?? '') : '';\n }\n}\n"]}
|
|
@@ -1,19 +1,29 @@
|
|
|
1
1
|
import { RepoRootFinder } from '@webpieces/rules-config';
|
|
2
|
+
import { GitStatusEntry, GitStatusParser } from './git-status';
|
|
2
3
|
/** Shared git precondition checks + push logic for the PR/merge workflow. */
|
|
3
4
|
export declare class GitExec {
|
|
4
5
|
private readonly repoRootFinder;
|
|
5
|
-
|
|
6
|
-
|
|
6
|
+
private readonly statusParser;
|
|
7
|
+
constructor(repoRootFinder: RepoRootFinder, statusParser: GitStatusParser);
|
|
7
8
|
/**
|
|
8
|
-
* Raw `git status --porcelain` with ONLY the trailing newline(s) stripped
|
|
9
|
+
* Raw `git status --porcelain` with ONLY the trailing newline(s) stripped — never `.trim()`.
|
|
9
10
|
*
|
|
10
|
-
*
|
|
11
|
-
* and column 2 the worktree state, so an unstaged
|
|
12
|
-
* leading space and the line then reads
|
|
13
|
-
*
|
|
14
|
-
*
|
|
11
|
+
* There used to be a trimmed sibling here (`uncommittedFiles`) and it was a data-destroying trap:
|
|
12
|
+
* porcelain column 1 is the index state and column 2 the worktree state, so an unstaged
|
|
13
|
+
* modification is " M path"; `trim()` eats that leading space and the line then reads "M path",
|
|
14
|
+
* i.e. STAGED — the exact inverse of the truth. It is deleted. This method is for EMPTINESS
|
|
15
|
+
* ("is the tree dirty at all?") and for PRINTING; anything that reasons about the two columns
|
|
16
|
+
* calls {@link statusEntries} and reads booleans instead of re-parsing a string.
|
|
17
|
+
*
|
|
18
|
+
* Tracked changes (staged + unstaged) AND untracked files, EXCLUDING gitignored paths — so
|
|
19
|
+
* `.webpieces/` tooling artifacts never count. Empty string = fully committed.
|
|
15
20
|
*/
|
|
16
21
|
porcelainStatus(cwd: string): string;
|
|
22
|
+
/**
|
|
23
|
+
* The same status, PARSED: two status columns, an unquoted path, and explicit
|
|
24
|
+
* `isStaged()`/`isUnstaged()`/`isUntracked()` answers. Use this for every semantic question.
|
|
25
|
+
*/
|
|
26
|
+
statusEntries(cwd: string): GitStatusEntry[];
|
|
17
27
|
untrackedFiles(cwd: string): string;
|
|
18
28
|
/**
|
|
19
29
|
* Precondition for every PR/merge entry point: the working tree must be fully committed. The
|
|
@@ -5,6 +5,7 @@ const tslib_1 = require("tslib");
|
|
|
5
5
|
const child_process_1 = require("child_process");
|
|
6
6
|
const rules_config_1 = require("@webpieces/rules-config");
|
|
7
7
|
const inversify_1 = require("inversify");
|
|
8
|
+
const git_status_1 = require("./git-status");
|
|
8
9
|
const SEP = '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n';
|
|
9
10
|
// The AI-facing doc the failure messages point at (generated under `.webpieces/instruct-ai`, at the
|
|
10
11
|
// REPO ROOT — resolved absolute so the AI opens it regardless of the cwd it reads the message from).
|
|
@@ -12,26 +13,34 @@ const GIT_WORKFLOW_DOC_NAME = 'webpieces.git-workflow.md';
|
|
|
12
13
|
/** Shared git precondition checks + push logic for the PR/merge workflow. */
|
|
13
14
|
let GitExec = class GitExec {
|
|
14
15
|
repoRootFinder;
|
|
15
|
-
|
|
16
|
+
statusParser;
|
|
17
|
+
constructor(repoRootFinder, statusParser) {
|
|
16
18
|
this.repoRootFinder = repoRootFinder;
|
|
17
|
-
|
|
18
|
-
// Tracked changes (staged + unstaged) AND untracked files, EXCLUDING gitignored paths — so
|
|
19
|
-
// `.webpieces/` tooling artifacts never count. Empty string = fully committed.
|
|
20
|
-
uncommittedFiles(cwd) {
|
|
21
|
-
return this.gitQuery(['status', '--porcelain'], cwd, 'Failed to run `git status --porcelain` to check the working tree.');
|
|
19
|
+
this.statusParser = statusParser;
|
|
22
20
|
}
|
|
23
21
|
/**
|
|
24
|
-
* Raw `git status --porcelain` with ONLY the trailing newline(s) stripped
|
|
22
|
+
* Raw `git status --porcelain` with ONLY the trailing newline(s) stripped — never `.trim()`.
|
|
23
|
+
*
|
|
24
|
+
* There used to be a trimmed sibling here (`uncommittedFiles`) and it was a data-destroying trap:
|
|
25
|
+
* porcelain column 1 is the index state and column 2 the worktree state, so an unstaged
|
|
26
|
+
* modification is " M path"; `trim()` eats that leading space and the line then reads "M path",
|
|
27
|
+
* i.e. STAGED — the exact inverse of the truth. It is deleted. This method is for EMPTINESS
|
|
28
|
+
* ("is the tree dirty at all?") and for PRINTING; anything that reasons about the two columns
|
|
29
|
+
* calls {@link statusEntries} and reads booleans instead of re-parsing a string.
|
|
25
30
|
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* leading space and the line then reads as "M " (staged, clean worktree), i.e. the exact opposite.
|
|
29
|
-
* Anything that parses the two status columns (BuildArtifactGate) must use THIS method; the trimmed
|
|
30
|
-
* one is only safe for "is it empty?" and for printing.
|
|
31
|
+
* Tracked changes (staged + unstaged) AND untracked files, EXCLUDING gitignored paths — so
|
|
32
|
+
* `.webpieces/` tooling artifacts never count. Empty string = fully committed.
|
|
31
33
|
*/
|
|
32
34
|
porcelainStatus(cwd) {
|
|
33
35
|
return this.rawGitQuery(['status', '--porcelain'], cwd, 'Failed to run `git status --porcelain` to check the working tree.').replace(/\n+$/, '');
|
|
34
36
|
}
|
|
37
|
+
/**
|
|
38
|
+
* The same status, PARSED: two status columns, an unquoted path, and explicit
|
|
39
|
+
* `isStaged()`/`isUnstaged()`/`isUntracked()` answers. Use this for every semantic question.
|
|
40
|
+
*/
|
|
41
|
+
statusEntries(cwd) {
|
|
42
|
+
return this.statusParser.parse(this.porcelainStatus(cwd));
|
|
43
|
+
}
|
|
35
44
|
// Untracked files only (respects .gitignore). Empty string = none.
|
|
36
45
|
untrackedFiles(cwd) {
|
|
37
46
|
return this.gitQuery(['ls-files', '--others', '--exclude-standard'], cwd, 'Failed to list untracked files (git ls-files --others).');
|
|
@@ -42,7 +51,7 @@ let GitExec = class GitExec {
|
|
|
42
51
|
* with instructions if anything is uncommitted (tracked or untracked, excluding gitignored).
|
|
43
52
|
*/
|
|
44
53
|
assertCleanTree(cwd) {
|
|
45
|
-
const dirty = this.
|
|
54
|
+
const dirty = this.porcelainStatus(cwd);
|
|
46
55
|
if (dirty === '')
|
|
47
56
|
return;
|
|
48
57
|
throw new rules_config_1.CliExitError(1, '\n' + SEP +
|
|
@@ -114,6 +123,7 @@ let GitExec = class GitExec {
|
|
|
114
123
|
exports.GitExec = GitExec;
|
|
115
124
|
exports.GitExec = GitExec = tslib_1.__decorate([
|
|
116
125
|
(0, inversify_1.injectable)(inversify_1.bindingScopeValues.Singleton),
|
|
117
|
-
tslib_1.__metadata("design:paramtypes", [rules_config_1.RepoRootFinder
|
|
126
|
+
tslib_1.__metadata("design:paramtypes", [rules_config_1.RepoRootFinder,
|
|
127
|
+
git_status_1.GitStatusParser])
|
|
118
128
|
], GitExec);
|
|
119
129
|
//# sourceMappingURL=git-exec.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"git-exec.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/pr-gate/src/scripts/workflow/git-exec.ts"],"names":[],"mappings":";;;;AAAA,iDAA0C;AAC1C,0DAAuE;AACvE,yCAA2D;
|
|
1
|
+
{"version":3,"file":"git-exec.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/pr-gate/src/scripts/workflow/git-exec.ts"],"names":[],"mappings":"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","sourcesContent":["import { spawnSync } from 'child_process';\nimport { CliExitError, RepoRootFinder } from '@webpieces/rules-config';\nimport { injectable, bindingScopeValues } from 'inversify';\nimport { GitStatusEntry, GitStatusParser } from './git-status';\n\nconst SEP = '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\\n';\n// The AI-facing doc the failure messages point at (generated under `.webpieces/instruct-ai`, at the\n// REPO ROOT — resolved absolute so the AI opens it regardless of the cwd it reads the message from).\nconst GIT_WORKFLOW_DOC_NAME = 'webpieces.git-workflow.md';\n\n/** Shared git precondition checks + push logic for the PR/merge workflow. */\n@injectable(bindingScopeValues.Singleton)\nexport class GitExec {\n constructor(\n private readonly repoRootFinder: RepoRootFinder,\n private readonly statusParser: GitStatusParser,\n ) {}\n\n /**\n * Raw `git status --porcelain` with ONLY the trailing newline(s) stripped — never `.trim()`.\n *\n * There used to be a trimmed sibling here (`uncommittedFiles`) and it was a data-destroying trap:\n * porcelain column 1 is the index state and column 2 the worktree state, so an unstaged\n * modification is \" M path\"; `trim()` eats that leading space and the line then reads \"M path\",\n * i.e. STAGED — the exact inverse of the truth. It is deleted. This method is for EMPTINESS\n * (\"is the tree dirty at all?\") and for PRINTING; anything that reasons about the two columns\n * calls {@link statusEntries} and reads booleans instead of re-parsing a string.\n *\n * Tracked changes (staged + unstaged) AND untracked files, EXCLUDING gitignored paths — so\n * `.webpieces/` tooling artifacts never count. Empty string = fully committed.\n */\n porcelainStatus(cwd: string): string {\n return this.rawGitQuery(['status', '--porcelain'], cwd,\n 'Failed to run `git status --porcelain` to check the working tree.').replace(/\\n+$/, '');\n }\n\n /**\n * The same status, PARSED: two status columns, an unquoted path, and explicit\n * `isStaged()`/`isUnstaged()`/`isUntracked()` answers. Use this for every semantic question.\n */\n statusEntries(cwd: string): GitStatusEntry[] {\n return this.statusParser.parse(this.porcelainStatus(cwd));\n }\n\n // Untracked files only (respects .gitignore). Empty string = none.\n untrackedFiles(cwd: string): string {\n return this.gitQuery(['ls-files', '--others', '--exclude-standard'], cwd, 'Failed to list untracked files (git ls-files --others).');\n }\n\n /**\n * Precondition for every PR/merge entry point: the working tree must be fully committed. The\n * webpieces tooling deliberately does NOT `git add`/`commit` the developer's work for them. Aborts\n * with instructions if anything is uncommitted (tracked or untracked, excluding gitignored).\n */\n assertCleanTree(cwd: string): void {\n const dirty = this.porcelainStatus(cwd);\n if (dirty === '') return;\n throw new CliExitError(1,\n '\\n' + SEP +\n '❌ ERROR: You have uncommitted or untracked changes\\n' +\n SEP + '\\n' +\n 'The webpieces PR tooling will NOT commit your work for you. Commit your\\n' +\n 'changes, and either commit or delete any untracked files, then re-run.\\n\\n' +\n 'Working tree (git status --porcelain):\\n' +\n dirty + '\\n\\n' +\n `See ${this.repoRootFinder.docPathFrom(cwd, GIT_WORKFLOW_DOC_NAME)} for the full merge + PR process.\\n` +\n SEP,\n );\n }\n\n /**\n * Narrower guard for the merge-resolve commit point, where tracked resolutions are legitimately in\n * the tree but untracked files must NOT be swept into the squash commit. Lists any untracked files\n * and aborts so the AI commits or deletes them explicitly.\n */\n assertNoUntracked(cwd: string): void {\n const untracked = this.untrackedFiles(cwd);\n if (untracked === '') return;\n throw new CliExitError(1,\n '\\n' + SEP +\n '❌ ERROR: Untracked files present during merge finalize\\n' +\n SEP + '\\n' +\n 'The tooling will not sweep untracked files into the squash commit. Commit\\n' +\n 'or delete these, then re-run:\\n\\n' +\n untracked + '\\n\\n' +\n `See ${this.repoRootFinder.docPathFrom(cwd, GIT_WORKFLOW_DOC_NAME)} for the full merge + PR process.\\n` +\n SEP,\n );\n }\n\n /**\n * Run a git command that is expected to succeed; abort the process with a clear message if it\n * fails. Used for fetch/pull/checkout/commit where silently continuing on failure would operate on\n * stale or wrong state.\n */\n runGitChecked(args: string[], errMsg: string): void {\n const result = spawnSync('git', args, { stdio: 'inherit' });\n if (result.status !== 0) {\n throw new CliExitError(1, `❌ ${errMsg} (git ${args.join(' ')} exited ${String(result.status)})`);\n }\n }\n\n /**\n * Push HEAD to origin/<currentBranch>. Uses --force-with-lease for an existing remote branch (the\n * 3-point squash rewrites history) and -u for a brand-new branch.\n */\n ensurePushed(currentBranch: string): void {\n const remoteExists = spawnSync('git', ['ls-remote', '--exit-code', '--heads', 'origin', currentBranch]).status === 0;\n if (remoteExists) {\n this.runGitChecked(['push', '--force-with-lease', 'origin', `HEAD:${currentBranch}`], 'Failed to push branch');\n } else {\n this.runGitChecked(['push', '-u', 'origin', `HEAD:${currentBranch}`], 'Failed to push new branch');\n }\n }\n\n // Run a read-only git query from the repo root; abort if git itself errors. Kept `cwd`-explicit\n // because `git ls-files --others` is scoped to the cwd subtree.\n private gitQuery(args: string[], cwd: string, failMsg: string): string {\n return this.rawGitQuery(args, cwd, failMsg).trim();\n }\n\n // The untrimmed sibling — see porcelainStatus for why trimming is not always safe.\n private rawGitQuery(args: string[], cwd: string, failMsg: string): string {\n const out = spawnSync('git', args, { encoding: 'utf8', cwd });\n if (out.status !== 0) {\n throw new CliExitError(1, `❌ ${failMsg}`);\n }\n return out.stdout;\n }\n}\n"]}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One entry of `git status --porcelain` (v1), already split into its two status columns and its
|
|
3
|
+
* (unquoted) path, with the staged/unstaged question answered as BOOLEANS.
|
|
4
|
+
*
|
|
5
|
+
* The booleans are the whole point. Porcelain is `XY <path>` where X is the INDEX state and Y the
|
|
6
|
+
* WORKING TREE state, so an unstaged modification is `" M path"` and a staged one is `"M path"` —
|
|
7
|
+
* they differ ONLY in which column the space lands in. Every caller that handed the raw line around
|
|
8
|
+
* had to re-derive that, and the one that ran `.trim()` first got the exact INVERSE of the truth,
|
|
9
|
+
* because trimming deletes the index column and shifts the worktree column into its place.
|
|
10
|
+
*
|
|
11
|
+
* A comment saying "do not trim" cannot stop that; a type that never exposes the columns as a
|
|
12
|
+
* positional string can. Data-only (per CLAUDE.md).
|
|
13
|
+
*/
|
|
14
|
+
export declare class GitStatusEntry {
|
|
15
|
+
/** Porcelain column 1 — the INDEX (staged) state. `' '` = nothing staged, `'?'` = untracked. */
|
|
16
|
+
indexStatus: string;
|
|
17
|
+
/** Porcelain column 2 — the WORKING TREE (unstaged) state. `' '` = worktree matches the index. */
|
|
18
|
+
worktreeStatus: string;
|
|
19
|
+
/** The path on disk, already unquoted/unescaped. For a rename this is the NEW name. */
|
|
20
|
+
path: string;
|
|
21
|
+
/** The OLD name of a rename/copy, `''` when the entry is not one. */
|
|
22
|
+
renamedFrom: string;
|
|
23
|
+
constructor(indexStatus: string, worktreeStatus: string, entryPath: string, renamedFrom?: string);
|
|
24
|
+
/** `?? path` — the file exists on disk and nobody has ever committed or added it. */
|
|
25
|
+
isUntracked(): boolean;
|
|
26
|
+
/** `!! path` — matched by .gitignore. Only ever present with `--ignored`. */
|
|
27
|
+
isIgnored(): boolean;
|
|
28
|
+
/** Something is in the INDEX for this path, i.e. `git commit` would record it. */
|
|
29
|
+
isStaged(): boolean;
|
|
30
|
+
/** The working tree differs from the index — a `git commit` right now would NOT capture it. */
|
|
31
|
+
isUnstaged(): boolean;
|
|
32
|
+
/** True when this path is fully captured by a commit made right now (committed OR staged). */
|
|
33
|
+
isCommittedOrStaged(): boolean;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Parses `git status --porcelain` (v1) text into {@link GitStatusEntry} objects.
|
|
37
|
+
*
|
|
38
|
+
* MUST be fed UNTRIMMED text — see {@link GitStatusEntry} for why trimming inverts staged-ness. It
|
|
39
|
+
* handles the two things hand-rolled `line.slice(3)` parsers historically got wrong: git QUOTES any
|
|
40
|
+
* path containing a space, a quote, a backslash or a non-ASCII byte (`" M \"a b.txt\""`, with
|
|
41
|
+
* non-ASCII as octal escapes), and a rename arrives as `R old -> new` where only the NEW name is
|
|
42
|
+
* the file on disk.
|
|
43
|
+
*/
|
|
44
|
+
export declare class GitStatusParser {
|
|
45
|
+
/** Split porcelain text into entries, skipping blank lines. */
|
|
46
|
+
parse(porcelain: string): GitStatusEntry[];
|
|
47
|
+
/** One line → an entry, or null when the line is blank/too short to be an entry. */
|
|
48
|
+
parseLine(line: string): GitStatusEntry | null;
|
|
49
|
+
private isRenameOrCopy;
|
|
50
|
+
private readPath;
|
|
51
|
+
private readQuoted;
|
|
52
|
+
private decodeEscape;
|
|
53
|
+
private pushUtf8;
|
|
54
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.GitStatusParser = exports.GitStatusEntry = void 0;
|
|
4
|
+
const tslib_1 = require("tslib");
|
|
5
|
+
const inversify_1 = require("inversify");
|
|
6
|
+
// git's C-style quoting for the escapes it does NOT emit as octal. `\"` and `\\` are the two that
|
|
7
|
+
// actually show up in real paths; the control-character ones are here for completeness.
|
|
8
|
+
const SIMPLE_ESCAPES = new Map([
|
|
9
|
+
['"', 0x22], ['\\', 0x5c], ['a', 0x07], ['b', 0x08], ['t', 0x09],
|
|
10
|
+
['n', 0x0a], ['v', 0x0b], ['f', 0x0c], ['r', 0x0d],
|
|
11
|
+
]);
|
|
12
|
+
const RENAME_ARROW = ' -> ';
|
|
13
|
+
/**
|
|
14
|
+
* One entry of `git status --porcelain` (v1), already split into its two status columns and its
|
|
15
|
+
* (unquoted) path, with the staged/unstaged question answered as BOOLEANS.
|
|
16
|
+
*
|
|
17
|
+
* The booleans are the whole point. Porcelain is `XY <path>` where X is the INDEX state and Y the
|
|
18
|
+
* WORKING TREE state, so an unstaged modification is `" M path"` and a staged one is `"M path"` —
|
|
19
|
+
* they differ ONLY in which column the space lands in. Every caller that handed the raw line around
|
|
20
|
+
* had to re-derive that, and the one that ran `.trim()` first got the exact INVERSE of the truth,
|
|
21
|
+
* because trimming deletes the index column and shifts the worktree column into its place.
|
|
22
|
+
*
|
|
23
|
+
* A comment saying "do not trim" cannot stop that; a type that never exposes the columns as a
|
|
24
|
+
* positional string can. Data-only (per CLAUDE.md).
|
|
25
|
+
*/
|
|
26
|
+
class GitStatusEntry {
|
|
27
|
+
/** Porcelain column 1 — the INDEX (staged) state. `' '` = nothing staged, `'?'` = untracked. */
|
|
28
|
+
indexStatus;
|
|
29
|
+
/** Porcelain column 2 — the WORKING TREE (unstaged) state. `' '` = worktree matches the index. */
|
|
30
|
+
worktreeStatus;
|
|
31
|
+
/** The path on disk, already unquoted/unescaped. For a rename this is the NEW name. */
|
|
32
|
+
path;
|
|
33
|
+
/** The OLD name of a rename/copy, `''` when the entry is not one. */
|
|
34
|
+
renamedFrom;
|
|
35
|
+
// eslint-disable-next-line @typescript-eslint/max-params
|
|
36
|
+
constructor(indexStatus, worktreeStatus, entryPath, renamedFrom = '') {
|
|
37
|
+
this.indexStatus = indexStatus;
|
|
38
|
+
this.worktreeStatus = worktreeStatus;
|
|
39
|
+
this.path = entryPath;
|
|
40
|
+
this.renamedFrom = renamedFrom;
|
|
41
|
+
}
|
|
42
|
+
/** `?? path` — the file exists on disk and nobody has ever committed or added it. */
|
|
43
|
+
isUntracked() {
|
|
44
|
+
return this.indexStatus === '?';
|
|
45
|
+
}
|
|
46
|
+
/** `!! path` — matched by .gitignore. Only ever present with `--ignored`. */
|
|
47
|
+
isIgnored() {
|
|
48
|
+
return this.indexStatus === '!';
|
|
49
|
+
}
|
|
50
|
+
/** Something is in the INDEX for this path, i.e. `git commit` would record it. */
|
|
51
|
+
isStaged() {
|
|
52
|
+
return !this.isUntracked() && !this.isIgnored() && this.indexStatus !== ' ';
|
|
53
|
+
}
|
|
54
|
+
/** The working tree differs from the index — a `git commit` right now would NOT capture it. */
|
|
55
|
+
isUnstaged() {
|
|
56
|
+
if (this.isIgnored())
|
|
57
|
+
return false;
|
|
58
|
+
return this.isUntracked() || this.worktreeStatus !== ' ';
|
|
59
|
+
}
|
|
60
|
+
/** True when this path is fully captured by a commit made right now (committed OR staged). */
|
|
61
|
+
isCommittedOrStaged() {
|
|
62
|
+
return !this.isUnstaged();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
exports.GitStatusEntry = GitStatusEntry;
|
|
66
|
+
/** Where a path token ended, so the caller can look for the rename arrow after it. */
|
|
67
|
+
class ParsedPath {
|
|
68
|
+
value;
|
|
69
|
+
end;
|
|
70
|
+
constructor(value, end) {
|
|
71
|
+
this.value = value;
|
|
72
|
+
this.end = end;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Parses `git status --porcelain` (v1) text into {@link GitStatusEntry} objects.
|
|
77
|
+
*
|
|
78
|
+
* MUST be fed UNTRIMMED text — see {@link GitStatusEntry} for why trimming inverts staged-ness. It
|
|
79
|
+
* handles the two things hand-rolled `line.slice(3)` parsers historically got wrong: git QUOTES any
|
|
80
|
+
* path containing a space, a quote, a backslash or a non-ASCII byte (`" M \"a b.txt\""`, with
|
|
81
|
+
* non-ASCII as octal escapes), and a rename arrives as `R old -> new` where only the NEW name is
|
|
82
|
+
* the file on disk.
|
|
83
|
+
*/
|
|
84
|
+
let GitStatusParser = class GitStatusParser {
|
|
85
|
+
/** Split porcelain text into entries, skipping blank lines. */
|
|
86
|
+
parse(porcelain) {
|
|
87
|
+
const entries = [];
|
|
88
|
+
for (const line of porcelain.split('\n')) {
|
|
89
|
+
const entry = this.parseLine(line.replace(/\r$/, ''));
|
|
90
|
+
if (entry !== null)
|
|
91
|
+
entries.push(entry);
|
|
92
|
+
}
|
|
93
|
+
return entries;
|
|
94
|
+
}
|
|
95
|
+
/** One line → an entry, or null when the line is blank/too short to be an entry. */
|
|
96
|
+
parseLine(line) {
|
|
97
|
+
// `XY p` is the shortest legal entry: 2 status chars, a space, at least one path char.
|
|
98
|
+
if (line.length < 4)
|
|
99
|
+
return null;
|
|
100
|
+
const indexStatus = line.charAt(0);
|
|
101
|
+
const worktreeStatus = line.charAt(1);
|
|
102
|
+
// ONLY an R/C entry carries the `old -> new` pair. Checking the status first means a plain path
|
|
103
|
+
// that happens to contain the literal ` -> ` is never mis-split into a rename.
|
|
104
|
+
const renamed = this.isRenameOrCopy(indexStatus) || this.isRenameOrCopy(worktreeStatus);
|
|
105
|
+
const first = this.readPath(line, 3, renamed);
|
|
106
|
+
if (renamed && line.slice(first.end, first.end + RENAME_ARROW.length) === RENAME_ARROW) {
|
|
107
|
+
const second = this.readPath(line, first.end + RENAME_ARROW.length, false);
|
|
108
|
+
if (second.value === '')
|
|
109
|
+
return null;
|
|
110
|
+
return new GitStatusEntry(indexStatus, worktreeStatus, second.value, first.value);
|
|
111
|
+
}
|
|
112
|
+
if (first.value === '')
|
|
113
|
+
return null;
|
|
114
|
+
return new GitStatusEntry(indexStatus, worktreeStatus, first.value);
|
|
115
|
+
}
|
|
116
|
+
isRenameOrCopy(status) {
|
|
117
|
+
return status === 'R' || status === 'C';
|
|
118
|
+
}
|
|
119
|
+
// Read one path token: either a C-quoted string, or (for a rename source) everything up to the
|
|
120
|
+
// arrow, or the rest of the line.
|
|
121
|
+
readPath(line, start, stopAtArrow) {
|
|
122
|
+
if (line.charAt(start) === '"')
|
|
123
|
+
return this.readQuoted(line, start);
|
|
124
|
+
const arrow = stopAtArrow ? line.indexOf(RENAME_ARROW, start) : -1;
|
|
125
|
+
const end = arrow === -1 ? line.length : arrow;
|
|
126
|
+
return new ParsedPath(line.slice(start, end), end);
|
|
127
|
+
}
|
|
128
|
+
// Decode git's C-style quoting back to the real path. Octal escapes are BYTES, so they are
|
|
129
|
+
// collected as bytes and decoded as UTF-8 at the end — decoding each one alone would mangle any
|
|
130
|
+
// multi-byte character (é arrives as \303\251, two separate escapes).
|
|
131
|
+
readQuoted(line, start) {
|
|
132
|
+
const bytes = [];
|
|
133
|
+
let i = start + 1;
|
|
134
|
+
while (i < line.length && line.charAt(i) !== '"') {
|
|
135
|
+
if (line.charAt(i) === '\\') {
|
|
136
|
+
i = this.decodeEscape(line, i + 1, bytes);
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
this.pushUtf8(line.charAt(i), bytes);
|
|
140
|
+
i += 1;
|
|
141
|
+
}
|
|
142
|
+
return new ParsedPath(Buffer.from(Uint8Array.from(bytes)).toString('utf8'), i + 1);
|
|
143
|
+
}
|
|
144
|
+
// Append the escape that starts at `i` (the char AFTER the backslash); return the next index.
|
|
145
|
+
decodeEscape(line, i, bytes) {
|
|
146
|
+
const escaped = line.charAt(i);
|
|
147
|
+
const simple = SIMPLE_ESCAPES.get(escaped);
|
|
148
|
+
if (simple !== undefined) {
|
|
149
|
+
bytes.push(simple);
|
|
150
|
+
return i + 1;
|
|
151
|
+
}
|
|
152
|
+
const octal = line.slice(i, i + 3);
|
|
153
|
+
if (/^[0-7]{3}$/.test(octal)) {
|
|
154
|
+
bytes.push(parseInt(octal, 8));
|
|
155
|
+
return i + 3;
|
|
156
|
+
}
|
|
157
|
+
// Unknown escape — git would not emit one, so keep the character verbatim rather than lose it.
|
|
158
|
+
this.pushUtf8(escaped, bytes);
|
|
159
|
+
return i + 1;
|
|
160
|
+
}
|
|
161
|
+
pushUtf8(char, bytes) {
|
|
162
|
+
for (const byte of Buffer.from(char, 'utf8'))
|
|
163
|
+
bytes.push(byte);
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
exports.GitStatusParser = GitStatusParser;
|
|
167
|
+
exports.GitStatusParser = GitStatusParser = tslib_1.__decorate([
|
|
168
|
+
(0, inversify_1.injectable)(inversify_1.bindingScopeValues.Singleton)
|
|
169
|
+
], GitStatusParser);
|
|
170
|
+
//# sourceMappingURL=git-status.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"git-status.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/pr-gate/src/scripts/workflow/git-status.ts"],"names":[],"mappings":"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","sourcesContent":["import { injectable, bindingScopeValues } from 'inversify';\n\n// git's C-style quoting for the escapes it does NOT emit as octal. `\\\"` and `\\\\` are the two that\n// actually show up in real paths; the control-character ones are here for completeness.\nconst SIMPLE_ESCAPES = new Map<string, number>([\n ['\"', 0x22], ['\\\\', 0x5c], ['a', 0x07], ['b', 0x08], ['t', 0x09],\n ['n', 0x0a], ['v', 0x0b], ['f', 0x0c], ['r', 0x0d],\n]);\n\nconst RENAME_ARROW = ' -> ';\n\n/**\n * One entry of `git status --porcelain` (v1), already split into its two status columns and its\n * (unquoted) path, with the staged/unstaged question answered as BOOLEANS.\n *\n * The booleans are the whole point. Porcelain is `XY <path>` where X is the INDEX state and Y the\n * WORKING TREE state, so an unstaged modification is `\" M path\"` and a staged one is `\"M path\"` —\n * they differ ONLY in which column the space lands in. Every caller that handed the raw line around\n * had to re-derive that, and the one that ran `.trim()` first got the exact INVERSE of the truth,\n * because trimming deletes the index column and shifts the worktree column into its place.\n *\n * A comment saying \"do not trim\" cannot stop that; a type that never exposes the columns as a\n * positional string can. Data-only (per CLAUDE.md).\n */\nexport class GitStatusEntry {\n /** Porcelain column 1 — the INDEX (staged) state. `' '` = nothing staged, `'?'` = untracked. */\n indexStatus: string;\n /** Porcelain column 2 — the WORKING TREE (unstaged) state. `' '` = worktree matches the index. */\n worktreeStatus: string;\n /** The path on disk, already unquoted/unescaped. For a rename this is the NEW name. */\n path: string;\n /** The OLD name of a rename/copy, `''` when the entry is not one. */\n renamedFrom: string;\n\n // eslint-disable-next-line @typescript-eslint/max-params\n constructor(indexStatus: string, worktreeStatus: string, entryPath: string, renamedFrom = '') {\n this.indexStatus = indexStatus;\n this.worktreeStatus = worktreeStatus;\n this.path = entryPath;\n this.renamedFrom = renamedFrom;\n }\n\n /** `?? path` — the file exists on disk and nobody has ever committed or added it. */\n isUntracked(): boolean {\n return this.indexStatus === '?';\n }\n\n /** `!! path` — matched by .gitignore. Only ever present with `--ignored`. */\n isIgnored(): boolean {\n return this.indexStatus === '!';\n }\n\n /** Something is in the INDEX for this path, i.e. `git commit` would record it. */\n isStaged(): boolean {\n return !this.isUntracked() && !this.isIgnored() && this.indexStatus !== ' ';\n }\n\n /** The working tree differs from the index — a `git commit` right now would NOT capture it. */\n isUnstaged(): boolean {\n if (this.isIgnored()) return false;\n return this.isUntracked() || this.worktreeStatus !== ' ';\n }\n\n /** True when this path is fully captured by a commit made right now (committed OR staged). */\n isCommittedOrStaged(): boolean {\n return !this.isUnstaged();\n }\n}\n\n/** Where a path token ended, so the caller can look for the rename arrow after it. */\nclass ParsedPath {\n value: string;\n end: number;\n\n constructor(value: string, end: number) {\n this.value = value;\n this.end = end;\n }\n}\n\n/**\n * Parses `git status --porcelain` (v1) text into {@link GitStatusEntry} objects.\n *\n * MUST be fed UNTRIMMED text — see {@link GitStatusEntry} for why trimming inverts staged-ness. It\n * handles the two things hand-rolled `line.slice(3)` parsers historically got wrong: git QUOTES any\n * path containing a space, a quote, a backslash or a non-ASCII byte (`\" M \\\"a b.txt\\\"\"`, with\n * non-ASCII as octal escapes), and a rename arrives as `R old -> new` where only the NEW name is\n * the file on disk.\n */\n@injectable(bindingScopeValues.Singleton)\nexport class GitStatusParser {\n /** Split porcelain text into entries, skipping blank lines. */\n parse(porcelain: string): GitStatusEntry[] {\n const entries: GitStatusEntry[] = [];\n for (const line of porcelain.split('\\n')) {\n const entry = this.parseLine(line.replace(/\\r$/, ''));\n if (entry !== null) entries.push(entry);\n }\n return entries;\n }\n\n /** One line → an entry, or null when the line is blank/too short to be an entry. */\n parseLine(line: string): GitStatusEntry | null {\n // `XY p` is the shortest legal entry: 2 status chars, a space, at least one path char.\n if (line.length < 4) return null;\n const indexStatus = line.charAt(0);\n const worktreeStatus = line.charAt(1);\n // ONLY an R/C entry carries the `old -> new` pair. Checking the status first means a plain path\n // that happens to contain the literal ` -> ` is never mis-split into a rename.\n const renamed = this.isRenameOrCopy(indexStatus) || this.isRenameOrCopy(worktreeStatus);\n const first = this.readPath(line, 3, renamed);\n if (renamed && line.slice(first.end, first.end + RENAME_ARROW.length) === RENAME_ARROW) {\n const second = this.readPath(line, first.end + RENAME_ARROW.length, false);\n if (second.value === '') return null;\n return new GitStatusEntry(indexStatus, worktreeStatus, second.value, first.value);\n }\n if (first.value === '') return null;\n return new GitStatusEntry(indexStatus, worktreeStatus, first.value);\n }\n\n private isRenameOrCopy(status: string): boolean {\n return status === 'R' || status === 'C';\n }\n\n // Read one path token: either a C-quoted string, or (for a rename source) everything up to the\n // arrow, or the rest of the line.\n private readPath(line: string, start: number, stopAtArrow: boolean): ParsedPath {\n if (line.charAt(start) === '\"') return this.readQuoted(line, start);\n const arrow = stopAtArrow ? line.indexOf(RENAME_ARROW, start) : -1;\n const end = arrow === -1 ? line.length : arrow;\n return new ParsedPath(line.slice(start, end), end);\n }\n\n // Decode git's C-style quoting back to the real path. Octal escapes are BYTES, so they are\n // collected as bytes and decoded as UTF-8 at the end — decoding each one alone would mangle any\n // multi-byte character (é arrives as \\303\\251, two separate escapes).\n private readQuoted(line: string, start: number): ParsedPath {\n const bytes: number[] = [];\n let i = start + 1;\n while (i < line.length && line.charAt(i) !== '\"') {\n if (line.charAt(i) === '\\\\') {\n i = this.decodeEscape(line, i + 1, bytes);\n continue;\n }\n this.pushUtf8(line.charAt(i), bytes);\n i += 1;\n }\n return new ParsedPath(Buffer.from(Uint8Array.from(bytes)).toString('utf8'), i + 1);\n }\n\n // Append the escape that starts at `i` (the char AFTER the backslash); return the next index.\n private decodeEscape(line: string, i: number, bytes: number[]): number {\n const escaped = line.charAt(i);\n const simple = SIMPLE_ESCAPES.get(escaped);\n if (simple !== undefined) {\n bytes.push(simple);\n return i + 1;\n }\n const octal = line.slice(i, i + 3);\n if (/^[0-7]{3}$/.test(octal)) {\n bytes.push(parseInt(octal, 8));\n return i + 3;\n }\n // Unknown escape — git would not emit one, so keep the character verbatim rather than lose it.\n this.pushUtf8(escaped, bytes);\n return i + 1;\n }\n\n private pushUtf8(char: string, bytes: number[]): void {\n for (const byte of Buffer.from(char, 'utf8')) bytes.push(byte);\n }\n}\n"]}
|
|
@@ -94,6 +94,11 @@ C-A.diff # what main changed (C−A)
|
|
|
94
94
|
\`updatemain-hashes.json\` holds A/B/C commit hashes. To see why main changed:
|
|
95
95
|
\`git log <A>..<C> --oneline\`.
|
|
96
96
|
|
|
97
|
+
Why A/B/C are trustworthy here — and why you must never \`git merge\`/\`git rebase\` main in yourself —
|
|
98
|
+
is the fork-point invariant: see \`webpieces.git-workflow.md\` → "THE FORK POINT INVARIANT". Read it
|
|
99
|
+
once if \`B-A.diff\` or \`C-A.diff\` ever shows you changes you do not recognize; that is the symptom
|
|
100
|
+
of a polluted fork point, not of a bad diff.
|
|
101
|
+
|
|
97
102
|
## STEP 2 — Resolve each conflicted file
|
|
98
103
|
|
|
99
104
|
For each file: read the working-tree file (the markers) and its \`B-A.diff\` / \`C-A.diff\` (intent),
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"merge-start.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/pr-gate/src/scripts/workflow/merge-start.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 WEBPIECES_TMP_DIR, MERGE_EXPLANATION_FILE, CliExitError,\n MutationVerb, BranchMutationEvent, logBranchMutation, SyncFlowGuidance,\n} from '@webpieces/rules-config';\nimport { injectable, bindingScopeValues } from 'inversify';\nimport { GatherInfo } from '../git-gatherInfo';\nimport { BranchNaming } from './branch-naming';\nimport { GitExec } from './git-exec';\nimport { MergeState, MergeMarker } from './merge-state';\n\nconst SEP = '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\\n';\n\ninterface HashPoints {\n hashForkPoint: string;\n hashFeatureHead: string;\n hashMainHead: string;\n}\n\n// The four branch names merge-END needs to finalize a merge (swap squash→feature, push, clean up).\nexport class MergeContext {\n currentBranch: string;\n squashBranch: string;\n backupBranch: string;\n prNumber: string;\n\n constructor(currentBranch: string, squashBranch: string, backupBranch: string, prNumber: string) {\n this.currentBranch = currentBranch;\n this.squashBranch = squashBranch;\n this.backupBranch = backupBranch;\n this.prNumber = prNumber;\n }\n}\n\n// Outcome of merge-start: 'clean' carries the context for merge-END to finalize; 'conflict' means the\n// marker + context files were written and the caller should hand back to the AI (exit 2).\nexport class MergeStartResult {\n status: 'clean' | 'conflict';\n context: MergeContext | null;\n runDir: string; // this sync's numbered `merge-<n>/` dir — passed to merge-END so it reads THIS marker\n\n constructor(status: 'clean' | 'conflict', context: MergeContext | null, runDir: string) {\n this.status = status;\n this.context = context;\n this.runDir = runDir;\n }\n}\n\n// The one number `n` for a sync and the things it names: the pre-merge backup branch, its paired\n// conflict-context run dir, and (via `n`) the `preMerge<n>.hash` record of the tip it snapshotted.\nclass SyncSlot {\n backupBranch: string;\n runDir: string;\n n: number;\n\n constructor(backupBranch: string, runDir: string, n: number) {\n this.backupBranch = backupBranch;\n this.runDir = runDir;\n this.n = n;\n }\n}\n\n// Single source of truth for the merge process (written at conflict time, parameterized with the live\n// MERGE_DIR + conflicted-file list so it can never drift from the actual layout).\nconst MERGE_PROCESS_TEMPLATE = `# AI-Assisted Squash-Merge Conflict Resolution (generated)\n\nThis file was generated by \\`pnpm {{START_COMMAND}}\\` when the 3-point squash-merge hit conflicts.\nIt is the single source of truth for the merge process — follow it exactly.\n\nYou are on branch \\`{{SQUASH_BRANCH}}\\` with conflict markers in the working tree.\n\\`MERGE_DIR = {{MERGE_DIR}}\\`\n\n## How the gate works\n\n- Resolve every conflicted file in the working tree.\n- Run **\\`pnpm {{FINISH_COMMAND}}\\`** — the validation + finish gate. It scans for leftover conflict\n markers, checks each conflicted file has a written merge explanation, validates, and commits the\n merge. It is the paired half of \\`{{START_COMMAND}}\\` — never finish with the other flow's command\n (\\`wp-start-update\\` pairs with \\`wp-finish-update\\`; \\`wp-start-upsert-pr\\` pairs with\n \\`wp-finish-upsert-pr\\`, which additionally runs the \\`nx affected\\` build, renders the dashboard,\n and creates/updates the PR).\n- **DO \\`git add\\` each file you resolve.** Staging your resolutions is your job, not the gate's, and\n \\`git add\\` is **not** blocked by any guard. The gate stages with \\`git add -u\\`, which only re-stages\n already-tracked paths — leave a resolution unstaged and the gate stops with \"Git still reports\n unmerged files\" and sends you back to \\`git add\\` it.\n- **Do NOT run \\`git commit\\` / \\`git push\\` / \\`gh pr create|edit|merge\\` yourself** (nor \\`git merge\\` /\n \\`git rebase\\`). The \\`merge-in-progress-guard\\` hook blocks exactly those until the gate validates.\n The gate does the commit. See \\`webpieces.git-workflow.md\\` → \"Outcome B — CONFLICT merge\" for the\n full who-does-what; this file does not restate it.\n\n## STEP 1 — Load the merge context\n\nPer conflicted file, \\`MERGE_DIR/updatemain-<safe_path>/\\` holds (\\`<safe_path>\\` = path with \\`/\\`→\\`__\\`):\n\n\\`\\`\\`\nA-forkpoint.txt # file at fork point (base)\nB-feature.txt # file on your feature branch\nC-main.txt # file on main\nB-A.diff # what your feature changed (B−A)\nC-A.diff # what main changed (C−A)\n\\`\\`\\`\n\n\\`updatemain-hashes.json\\` holds A/B/C commit hashes. To see why main changed:\n\\`git log <A>..<C> --oneline\\`.\n\n## STEP 2 — Resolve each conflicted file\n\nFor each file: read the working-tree file (the markers) and its \\`B-A.diff\\` / \\`C-A.diff\\` (intent),\nthen Edit to the resolved version, removing ALL conflict markers.\n\nStrategies: goals align & non-overlapping → merge both · one side removes what the other modifies\n→ prefer the removal · same lines, simple (imports/format) → merge both · same lines, complex or\nconflicting goals → ask the user · feature re-implements what main already squashed → prefer\nmain's, then re-apply only the genuinely new feature work.\n\n**Then write a merge explanation** for each conflicted file — NOT a comment in the source (that\nbreaks for JSON and deleted files). Write it next to that file's diffs, at:\n\n\\`\\`\\`\nMERGE_DIR/updatemain-<safe_path>/{{EXPLANATION_FILE}}\n\\`\\`\\`\n\n(\\`<safe_path>\\` = the conflict file path with \\`/\\` → \\`__\\`, the same dir that holds its\n\\`A-forkpoint.txt\\` / \\`B-A.diff\\` / \\`C-A.diff\\`.) In it, explain in a few sentences how you resolved\nthis file: which side you took where, what you combined from B-A.diff vs C-A.diff, and why. The\ngate fails if any conflicted file's explanation is missing or empty. Do not paste A/B/C context\nblocks into the source code.\n\n## STEP 3 — Run the gate (validates the merge AND finalizes it)\n\n\\`\\`\\`\npnpm {{FINISH_COMMAND}}\n\\`\\`\\`\n\n- Leftover conflict markers → fix those files and re-run.\n- Missing merge explanation → write it (see STEP 2) and re-run.\n- Build failure → fix the TypeScript/lint errors and re-run (the gate re-stages for you).\n- Missing review.json (PR flow only) → write it in the printed format (your PR review), then re-run.\n- On success it commits and finalizes the merge (in the PR flow it also renders the dashboard and\n creates/updates the PR).\n\n## Conflicted files\n\n{{FILE_LIST}}\n\n## If you need to bail out\n\nA numbered backup branch was created (e.g. \\`<feature>PreMerge1\\`). To abandon:\n\n\\`\\`\\`\ngit merge --abort 2>/dev/null; git checkout <feature> ; git branch -D {{SQUASH_BRANCH}}\n\\`\\`\\`\n\nThen delete \\`{{MERGE_DIR}}/\\` for a clean slate.\n`;\n\n// merge-START: the first half of the 3-point squash-merge lifecycle. Brings origin/main into a fresh\n// `<branch>Squash`, and on conflict writes the 3-point context + unvalidated marker + process doc,\n// then hands control back to the AI. On a clean merge it commits the squash and returns the branch\n// context so the caller (RunUpdate) can run merge-END to finalize. NEVER finalizes or posts a PR.\n@injectable(bindingScopeValues.Singleton)\nexport class MergeStart {\n constructor(\n private readonly gatherInfo: GatherInfo,\n private readonly branchNaming: BranchNaming,\n private readonly gitExec: GitExec,\n private readonly mergeState: MergeState,\n ) {}\n\n async mergeStart(repoRoot: string, verb: MutationVerb, home: string, finishCommand: string): Promise<MergeStartResult> {\n const currentBranch = execSync('git branch --show-current', { encoding: 'utf8' }).trim();\n if (currentBranch.endsWith('Squash')) {\n throw new CliExitError(1, `❌ On a leftover ${currentBranch} branch with no merge marker. Clean up: git branch -D ${currentBranch}`);\n }\n\n // One number `n` for this sync drives BOTH the backup branch and its `merge-<n>/` context dir.\n const slot = this.chooseSyncSlot(home, currentBranch);\n const backupBranch = slot.backupBranch;\n const mergeDir = slot.runDir;\n\n process.stdout.write('\\n' + SEP + '🔄 Squash-Merge Update from Main\\n' + SEP + '\\n');\n const info = await this.gatherInfo.gatherInfo();\n const hashes = info.hashes;\n if (info.alreadyUpToDate) {\n process.stdout.write('ℹ️ Branch already even with main; nothing to merge, continuing to push/build.\\n');\n }\n\n const prNumber = this.detectPr(this.branchNaming.baseBranchName(currentBranch));\n process.stdout.write(prNumber ? `Existing PR #${prNumber} will be updated.\\n` : 'No existing PR (one can be created later).\\n');\n\n this.createBackup(currentBranch, backupBranch);\n // Record THIS sync's pre-merge tip as `staged/<feature>/preMerge<n>.hash` — every intermediate\n // state, not just the last one. It is what lets the `<feature>PreMerge<n>` snapshot BRANCH be\n // disposable (branches count toward the branch cap; a written-down hash does not), and it is the\n // ref the archive tag is cut from when the PR lands.\n this.mergeState.writePreMergeHash(home, slot.n, this.fullSha(currentBranch));\n const backupEvent = new BranchMutationEvent(verb, 'BACKUP');\n backupEvent.fromBranch = currentBranch;\n backupEvent.toBranch = backupBranch;\n logBranchMutation(repoRoot, backupEvent);\n\n const squashBranch = `${currentBranch}Squash`;\n if (spawnSync('git', ['show-ref', '--verify', '--quiet', `refs/heads/${squashBranch}`]).status === 0) {\n throw new CliExitError(1, `❌ Stale ${squashBranch} from a previous run. Delete it: git branch -D ${squashBranch}`);\n }\n\n // Branch the squash off origin/main directly — worktree-native. origin/main was already fetched\n // in gatherInfo(), and A/B/C are computed purely from origin/main, so the merge base is identical\n // to the old checkout-main + pull path.\n const originMainSha = this.shortSha('origin/main');\n this.gitExec.runGitChecked(['checkout', '-b', squashBranch, 'origin/main'], 'Failed to create squash branch off origin/main');\n const baseEvent = new BranchMutationEvent(verb, 'PULL');\n baseEvent.newMain = originMainSha;\n logBranchMutation(repoRoot, baseEvent);\n\n process.stdout.write('\\n' + SEP + `🔀 Squash merging ${currentBranch}\\n` + SEP + '\\n');\n const merge = spawnSync('git', ['merge', '--squash', currentBranch], { stdio: 'inherit' });\n if (merge.status !== 0) {\n this.handleConflictsHandback(repoRoot, mergeDir, currentBranch, squashBranch, backupBranch, prNumber, hashes, finishCommand);\n this.logConflict(repoRoot, verb, mergeDir);\n return new MergeStartResult('conflict', null, mergeDir);\n }\n logBranchMutation(repoRoot, new BranchMutationEvent(verb, 'SQUASH'));\n\n const nothingStaged = spawnSync('git', ['diff-index', '--quiet', '--cached', 'HEAD', '--']).status === 0;\n if (nothingStaged) {\n process.stdout.write('ℹ️ Already up-to-date with main (nothing to merge).\\n');\n } else {\n // Internal, transient subject for the single squash commit on the feature branch. It NO LONGER\n // reaches main's history: finish-upsert-pr squash-merges the PR with an explicit\n // `gh pr merge --subject <PR title> --body-file <commit summary>`, so main carries the PR title.\n this.gitExec.runGitChecked(['commit', '-m', `Squash merge of ${currentBranch}`], 'Failed to commit squash merge');\n // Clean merge ⇒ hashes only, no conflicts.md. Its ABSENCE is what marks this merge clean.\n this.mergeState.recordCleanMerge(mergeDir, hashes.hashForkPoint, hashes.hashFeatureHead, hashes.hashMainHead);\n }\n return new MergeStartResult('clean', new MergeContext(currentBranch, squashBranch, backupBranch, prNumber), mergeDir);\n }\n\n // Detect the PR by its STABLE feature branch (a leftover `…wpN` still resolves to the one name the\n // PR lives on).\n private detectPr(baseBranch: string): string {\n const result = spawnSync(\n 'gh', ['pr', 'list', '--head', baseBranch, '--json', 'number', '--jq', '.[0].number'],\n { encoding: 'utf8' },\n );\n return result.status === 0 ? (result.stdout ?? '').trim() : '';\n }\n\n // Pick this sync's slot number MONOTONICALLY from the durable audit dirs, then step past any\n // existing `<branch>PreMerge<n>` branch so the same `n` is free for the paired backup branch.\n private chooseSyncSlot(home: string, currentBranch: string): SyncSlot {\n const branchExists = (name: string): boolean =>\n spawnSync('git', ['show-ref', '--verify', '--quiet', `refs/heads/${name}`]).status === 0;\n let n = this.mergeState.nextMergeSlotNumber(home);\n while (branchExists(this.branchNaming.preMergeBackupName(currentBranch, n))) n += 1;\n return new SyncSlot(this.branchNaming.preMergeBackupName(currentBranch, n), this.mergeState.mergeRunDirFor(home, n), n);\n }\n\n // Snapshot the pre-merge state onto the caller-chosen `backupBranch`, never overwriting.\n private createBackup(currentBranch: string, backupBranch: string): void {\n process.stdout.write('\\n' + SEP + '💾 Creating Pre-Merge Backup\\n' + SEP + '\\n');\n this.gitExec.runGitChecked(['checkout', '-b', backupBranch], 'Failed to create backup branch');\n this.gitExec.runGitChecked(['checkout', currentBranch], 'Failed to return to feature branch');\n process.stdout.write(`✅ Backup created: ${backupBranch}\\n\\n`);\n }\n\n private saveConflictContext(\n conflictedFiles: string[], mergeDir: string, forkPoint: string, featureHead: string, mainHead: string,\n ): void {\n for (const file of conflictedFiles) {\n const fileDir = this.mergeState.perFileContextDir(mergeDir, file);\n fs.mkdirSync(fileDir, { recursive: true });\n\n const fork = spawnSync('git', ['show', `${forkPoint}:${file}`], { encoding: 'utf8' });\n fs.writeFileSync(path.join(fileDir, 'A-forkpoint.txt'), fork.status === 0 ? (fork.stdout ?? '') : '(file did not exist)\\n');\n const feature = spawnSync('git', ['show', `${featureHead}:${file}`], { encoding: 'utf8' });\n fs.writeFileSync(path.join(fileDir, 'B-feature.txt'), feature.status === 0 ? (feature.stdout ?? '') : '(file did not exist)\\n');\n const main = spawnSync('git', ['show', `${mainHead}:${file}`], { encoding: 'utf8' });\n fs.writeFileSync(path.join(fileDir, 'C-main.txt'), main.status === 0 ? (main.stdout ?? '') : '(file did not exist)\\n');\n\n const ba = spawnSync('git', ['diff', forkPoint, featureHead, '--', file], { encoding: 'utf8' });\n fs.writeFileSync(path.join(fileDir, 'B-A.diff'), ba.stdout ?? '');\n const ca = spawnSync('git', ['diff', forkPoint, mainHead, '--', file], { encoding: 'utf8' });\n fs.writeFileSync(path.join(fileDir, 'C-A.diff'), ca.stdout ?? '');\n }\n }\n\n private mergeProcessDoc(mergeDir: string, squashBranch: string, conflictedFiles: string[], finishCommand: string): string {\n const fileList = conflictedFiles.map((f: string): string => `- \\`${f}\\``).join('\\n');\n return MERGE_PROCESS_TEMPLATE\n .replace(/\\{\\{SQUASH_BRANCH\\}\\}/g, squashBranch)\n .replace(/\\{\\{MERGE_DIR\\}\\}/g, mergeDir)\n .replace(/\\{\\{EXPLANATION_FILE\\}\\}/g, MERGE_EXPLANATION_FILE)\n .replace(/\\{\\{FINISH_COMMAND\\}\\}/g, finishCommand)\n .replace(/\\{\\{START_COMMAND\\}\\}/g, new SyncFlowGuidance().pairedStart(finishCommand))\n .replace(/\\{\\{FILE_LIST\\}\\}/g, fileList);\n }\n\n // Returns the absolute path of the written doc.\n private writeMergeProcessDoc(repoRoot: string, mergeDir: string, squashBranch: string, conflictedFiles: string[], finishCommand: string): string {\n const docDir = path.join(repoRoot, WEBPIECES_TMP_DIR, 'instruct-ai');\n fs.mkdirSync(docDir, { recursive: true });\n const docPath = path.join(docDir, 'webpieces.mergeprocess.md');\n fs.writeFileSync(docPath, this.mergeProcessDoc(mergeDir, squashBranch, conflictedFiles, finishCommand));\n return docPath;\n }\n\n // The AI-facing \"what just happened / what to do next\" recap on the conflict path.\n private printConflictHandback(\n docPath: string, mergeDir: string, squashBranch: string, conflictedFiles: string[], finishCommand: string,\n ): void {\n process.stdout.write('\\n' + SEP + `⚠️ Conflicts in ${conflictedFiles.length} file(s) — handing control back to you\\n` + SEP + '\\n');\n process.stdout.write('Here is exactly what I did and what you need to do:\\n\\n');\n process.stdout.write('What I did:\\n');\n process.stdout.write(' 1. snapshotted your pre-merge state to a PreMerge branch\\n');\n process.stdout.write(' 2. pulled origin/main and squash-merged your work onto it\\n');\n process.stdout.write(` 3. hit conflicts — you are now on the transient branch ${squashBranch}\\n\\n`);\n process.stdout.write('What you need to do:\\n');\n process.stdout.write(` 1. read the merge process doc: ${docPath}\\n`);\n process.stdout.write(` 2. resolve each conflicted file below (its 3-point A/B/C context + diffs are in\\n`);\n process.stdout.write(` ${mergeDir}/updatemain-<file>/), \\`git add\\` it, and write that file's merge-explanation.md\\n`);\n process.stdout.write(` 3. run pnpm ${finishCommand} — it validates, commits, and finalizes (do NOT git commit/push yourself)\\n\\n`);\n process.stdout.write('Conflicted files:\\n');\n for (const file of conflictedFiles) process.stdout.write(` - ${file}\\n`);\n process.stdout.write('\\n' + SEP);\n }\n\n // Write the conflict context files + the unvalidated marker + the process doc. Does NOT exit.\n private handleConflictsHandback(\n repoRoot: string, mergeDir: string, currentBranch: string, squashBranch: string,\n backupBranch: string, prNumber: string, hashes: HashPoints, finishCommand: string,\n ): void {\n const raw = execSync('git diff --name-only --diff-filter=U', { encoding: 'utf8' }).trim();\n const conflictedFiles = raw.split('\\n').filter((f: string): boolean => f.trim() !== '');\n fs.mkdirSync(mergeDir, { recursive: true });\n // `conflicts.md` replaces the old `updatemain-conflicted-files.txt` (which nothing read) AND is\n // the 3-point signal itself: this file exists in a run dir if and only if that merge conflicted,\n // which is what lets the index classify each merge without a per-branch directory-name scheme.\n this.mergeState.writeConflicts(mergeDir, conflictedFiles);\n fs.writeFileSync(path.join(mergeDir, 'updatemain-hashes.json'), JSON.stringify(hashes, null, 2) + '\\n');\n this.saveConflictContext(conflictedFiles, mergeDir, hashes.hashForkPoint, hashes.hashFeatureHead, hashes.hashMainHead);\n\n const marker = new MergeMarker(\n currentBranch, squashBranch, backupBranch, prNumber, conflictedFiles,\n hashes.hashForkPoint, hashes.hashFeatureHead, hashes.hashMainHead, false,\n );\n this.mergeState.writeMergeMarker(mergeDir, marker);\n const docPath = this.writeMergeProcessDoc(repoRoot, mergeDir, squashBranch, conflictedFiles, finishCommand);\n this.printConflictHandback(docPath, mergeDir, squashBranch, conflictedFiles, finishCommand);\n }\n\n // Short sha of a ref (best-effort — '' if it can't resolve).\n private shortSha(ref: string): string {\n const result = spawnSync('git', ['rev-parse', '--short', ref], { encoding: 'utf8' });\n return result.status === 0 ? (result.stdout ?? '').trim() : '';\n }\n\n // FULL sha of a ref (best-effort — '' if it can't resolve). Recorded for the pre-merge tips because\n // those hashes are meant to be used later to restore state, and an abbreviated sha can go ambiguous\n // as the repo grows.\n private fullSha(ref: string): string {\n const result = spawnSync('git', ['rev-parse', ref], { encoding: 'utf8' });\n return result.status === 0 ? (result.stdout ?? '').trim() : '';\n }\n\n // Log the CONFLICT phase with the conflicted-file list + artifact paths a resolver needs.\n private logConflict(repoRoot: string, verb: MutationVerb, mergeDir: string): void {\n const raw = spawnSync('git', ['diff', '--name-only', '--diff-filter=U'], { cwd: repoRoot, encoding: 'utf8' });\n const files = (raw.status === 0 ? (raw.stdout ?? '') : '').split('\\n').map((f: string): string => f.trim()).filter((f: string): boolean => f !== '');\n const event = new BranchMutationEvent(verb, 'CONFLICT');\n event.conflict = true;\n event.conflictFiles = files;\n event.artifacts = [\n path.join(mergeDir, 'updatemain-<file>'),\n path.join(repoRoot, WEBPIECES_TMP_DIR, 'instruct-ai', 'webpieces.mergeprocess.md'),\n ];\n logBranchMutation(repoRoot, event);\n }\n}\n"]}
|
|
1
|
+
{"version":3,"file":"merge-start.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/pr-gate/src/scripts/workflow/merge-start.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 WEBPIECES_TMP_DIR, MERGE_EXPLANATION_FILE, CliExitError,\n MutationVerb, BranchMutationEvent, logBranchMutation, SyncFlowGuidance,\n} from '@webpieces/rules-config';\nimport { injectable, bindingScopeValues } from 'inversify';\nimport { GatherInfo } from '../git-gatherInfo';\nimport { BranchNaming } from './branch-naming';\nimport { GitExec } from './git-exec';\nimport { MergeState, MergeMarker } from './merge-state';\n\nconst SEP = '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\\n';\n\ninterface HashPoints {\n hashForkPoint: string;\n hashFeatureHead: string;\n hashMainHead: string;\n}\n\n// The four branch names merge-END needs to finalize a merge (swap squash→feature, push, clean up).\nexport class MergeContext {\n currentBranch: string;\n squashBranch: string;\n backupBranch: string;\n prNumber: string;\n\n constructor(currentBranch: string, squashBranch: string, backupBranch: string, prNumber: string) {\n this.currentBranch = currentBranch;\n this.squashBranch = squashBranch;\n this.backupBranch = backupBranch;\n this.prNumber = prNumber;\n }\n}\n\n// Outcome of merge-start: 'clean' carries the context for merge-END to finalize; 'conflict' means the\n// marker + context files were written and the caller should hand back to the AI (exit 2).\nexport class MergeStartResult {\n status: 'clean' | 'conflict';\n context: MergeContext | null;\n runDir: string; // this sync's numbered `merge-<n>/` dir — passed to merge-END so it reads THIS marker\n\n constructor(status: 'clean' | 'conflict', context: MergeContext | null, runDir: string) {\n this.status = status;\n this.context = context;\n this.runDir = runDir;\n }\n}\n\n// The one number `n` for a sync and the things it names: the pre-merge backup branch, its paired\n// conflict-context run dir, and (via `n`) the `preMerge<n>.hash` record of the tip it snapshotted.\nclass SyncSlot {\n backupBranch: string;\n runDir: string;\n n: number;\n\n constructor(backupBranch: string, runDir: string, n: number) {\n this.backupBranch = backupBranch;\n this.runDir = runDir;\n this.n = n;\n }\n}\n\n// Single source of truth for the merge process (written at conflict time, parameterized with the live\n// MERGE_DIR + conflicted-file list so it can never drift from the actual layout).\nconst MERGE_PROCESS_TEMPLATE = `# AI-Assisted Squash-Merge Conflict Resolution (generated)\n\nThis file was generated by \\`pnpm {{START_COMMAND}}\\` when the 3-point squash-merge hit conflicts.\nIt is the single source of truth for the merge process — follow it exactly.\n\nYou are on branch \\`{{SQUASH_BRANCH}}\\` with conflict markers in the working tree.\n\\`MERGE_DIR = {{MERGE_DIR}}\\`\n\n## How the gate works\n\n- Resolve every conflicted file in the working tree.\n- Run **\\`pnpm {{FINISH_COMMAND}}\\`** — the validation + finish gate. It scans for leftover conflict\n markers, checks each conflicted file has a written merge explanation, validates, and commits the\n merge. It is the paired half of \\`{{START_COMMAND}}\\` — never finish with the other flow's command\n (\\`wp-start-update\\` pairs with \\`wp-finish-update\\`; \\`wp-start-upsert-pr\\` pairs with\n \\`wp-finish-upsert-pr\\`, which additionally runs the \\`nx affected\\` build, renders the dashboard,\n and creates/updates the PR).\n- **DO \\`git add\\` each file you resolve.** Staging your resolutions is your job, not the gate's, and\n \\`git add\\` is **not** blocked by any guard. The gate stages with \\`git add -u\\`, which only re-stages\n already-tracked paths — leave a resolution unstaged and the gate stops with \"Git still reports\n unmerged files\" and sends you back to \\`git add\\` it.\n- **Do NOT run \\`git commit\\` / \\`git push\\` / \\`gh pr create|edit|merge\\` yourself** (nor \\`git merge\\` /\n \\`git rebase\\`). The \\`merge-in-progress-guard\\` hook blocks exactly those until the gate validates.\n The gate does the commit. See \\`webpieces.git-workflow.md\\` → \"Outcome B — CONFLICT merge\" for the\n full who-does-what; this file does not restate it.\n\n## STEP 1 — Load the merge context\n\nPer conflicted file, \\`MERGE_DIR/updatemain-<safe_path>/\\` holds (\\`<safe_path>\\` = path with \\`/\\`→\\`__\\`):\n\n\\`\\`\\`\nA-forkpoint.txt # file at fork point (base)\nB-feature.txt # file on your feature branch\nC-main.txt # file on main\nB-A.diff # what your feature changed (B−A)\nC-A.diff # what main changed (C−A)\n\\`\\`\\`\n\n\\`updatemain-hashes.json\\` holds A/B/C commit hashes. To see why main changed:\n\\`git log <A>..<C> --oneline\\`.\n\nWhy A/B/C are trustworthy here — and why you must never \\`git merge\\`/\\`git rebase\\` main in yourself —\nis the fork-point invariant: see \\`webpieces.git-workflow.md\\` → \"THE FORK POINT INVARIANT\". Read it\nonce if \\`B-A.diff\\` or \\`C-A.diff\\` ever shows you changes you do not recognize; that is the symptom\nof a polluted fork point, not of a bad diff.\n\n## STEP 2 — Resolve each conflicted file\n\nFor each file: read the working-tree file (the markers) and its \\`B-A.diff\\` / \\`C-A.diff\\` (intent),\nthen Edit to the resolved version, removing ALL conflict markers.\n\nStrategies: goals align & non-overlapping → merge both · one side removes what the other modifies\n→ prefer the removal · same lines, simple (imports/format) → merge both · same lines, complex or\nconflicting goals → ask the user · feature re-implements what main already squashed → prefer\nmain's, then re-apply only the genuinely new feature work.\n\n**Then write a merge explanation** for each conflicted file — NOT a comment in the source (that\nbreaks for JSON and deleted files). Write it next to that file's diffs, at:\n\n\\`\\`\\`\nMERGE_DIR/updatemain-<safe_path>/{{EXPLANATION_FILE}}\n\\`\\`\\`\n\n(\\`<safe_path>\\` = the conflict file path with \\`/\\` → \\`__\\`, the same dir that holds its\n\\`A-forkpoint.txt\\` / \\`B-A.diff\\` / \\`C-A.diff\\`.) In it, explain in a few sentences how you resolved\nthis file: which side you took where, what you combined from B-A.diff vs C-A.diff, and why. The\ngate fails if any conflicted file's explanation is missing or empty. Do not paste A/B/C context\nblocks into the source code.\n\n## STEP 3 — Run the gate (validates the merge AND finalizes it)\n\n\\`\\`\\`\npnpm {{FINISH_COMMAND}}\n\\`\\`\\`\n\n- Leftover conflict markers → fix those files and re-run.\n- Missing merge explanation → write it (see STEP 2) and re-run.\n- Build failure → fix the TypeScript/lint errors and re-run (the gate re-stages for you).\n- Missing review.json (PR flow only) → write it in the printed format (your PR review), then re-run.\n- On success it commits and finalizes the merge (in the PR flow it also renders the dashboard and\n creates/updates the PR).\n\n## Conflicted files\n\n{{FILE_LIST}}\n\n## If you need to bail out\n\nA numbered backup branch was created (e.g. \\`<feature>PreMerge1\\`). To abandon:\n\n\\`\\`\\`\ngit merge --abort 2>/dev/null; git checkout <feature> ; git branch -D {{SQUASH_BRANCH}}\n\\`\\`\\`\n\nThen delete \\`{{MERGE_DIR}}/\\` for a clean slate.\n`;\n\n// merge-START: the first half of the 3-point squash-merge lifecycle. Brings origin/main into a fresh\n// `<branch>Squash`, and on conflict writes the 3-point context + unvalidated marker + process doc,\n// then hands control back to the AI. On a clean merge it commits the squash and returns the branch\n// context so the caller (RunUpdate) can run merge-END to finalize. NEVER finalizes or posts a PR.\n@injectable(bindingScopeValues.Singleton)\nexport class MergeStart {\n constructor(\n private readonly gatherInfo: GatherInfo,\n private readonly branchNaming: BranchNaming,\n private readonly gitExec: GitExec,\n private readonly mergeState: MergeState,\n ) {}\n\n async mergeStart(repoRoot: string, verb: MutationVerb, home: string, finishCommand: string): Promise<MergeStartResult> {\n const currentBranch = execSync('git branch --show-current', { encoding: 'utf8' }).trim();\n if (currentBranch.endsWith('Squash')) {\n throw new CliExitError(1, `❌ On a leftover ${currentBranch} branch with no merge marker. Clean up: git branch -D ${currentBranch}`);\n }\n\n // One number `n` for this sync drives BOTH the backup branch and its `merge-<n>/` context dir.\n const slot = this.chooseSyncSlot(home, currentBranch);\n const backupBranch = slot.backupBranch;\n const mergeDir = slot.runDir;\n\n process.stdout.write('\\n' + SEP + '🔄 Squash-Merge Update from Main\\n' + SEP + '\\n');\n const info = await this.gatherInfo.gatherInfo();\n const hashes = info.hashes;\n if (info.alreadyUpToDate) {\n process.stdout.write('ℹ️ Branch already even with main; nothing to merge, continuing to push/build.\\n');\n }\n\n const prNumber = this.detectPr(this.branchNaming.baseBranchName(currentBranch));\n process.stdout.write(prNumber ? `Existing PR #${prNumber} will be updated.\\n` : 'No existing PR (one can be created later).\\n');\n\n this.createBackup(currentBranch, backupBranch);\n // Record THIS sync's pre-merge tip as `staged/<feature>/preMerge<n>.hash` — every intermediate\n // state, not just the last one. It is what lets the `<feature>PreMerge<n>` snapshot BRANCH be\n // disposable (branches count toward the branch cap; a written-down hash does not), and it is the\n // ref the archive tag is cut from when the PR lands.\n this.mergeState.writePreMergeHash(home, slot.n, this.fullSha(currentBranch));\n const backupEvent = new BranchMutationEvent(verb, 'BACKUP');\n backupEvent.fromBranch = currentBranch;\n backupEvent.toBranch = backupBranch;\n logBranchMutation(repoRoot, backupEvent);\n\n const squashBranch = `${currentBranch}Squash`;\n if (spawnSync('git', ['show-ref', '--verify', '--quiet', `refs/heads/${squashBranch}`]).status === 0) {\n throw new CliExitError(1, `❌ Stale ${squashBranch} from a previous run. Delete it: git branch -D ${squashBranch}`);\n }\n\n // Branch the squash off origin/main directly — worktree-native. origin/main was already fetched\n // in gatherInfo(), and A/B/C are computed purely from origin/main, so the merge base is identical\n // to the old checkout-main + pull path.\n const originMainSha = this.shortSha('origin/main');\n this.gitExec.runGitChecked(['checkout', '-b', squashBranch, 'origin/main'], 'Failed to create squash branch off origin/main');\n const baseEvent = new BranchMutationEvent(verb, 'PULL');\n baseEvent.newMain = originMainSha;\n logBranchMutation(repoRoot, baseEvent);\n\n process.stdout.write('\\n' + SEP + `🔀 Squash merging ${currentBranch}\\n` + SEP + '\\n');\n const merge = spawnSync('git', ['merge', '--squash', currentBranch], { stdio: 'inherit' });\n if (merge.status !== 0) {\n this.handleConflictsHandback(repoRoot, mergeDir, currentBranch, squashBranch, backupBranch, prNumber, hashes, finishCommand);\n this.logConflict(repoRoot, verb, mergeDir);\n return new MergeStartResult('conflict', null, mergeDir);\n }\n logBranchMutation(repoRoot, new BranchMutationEvent(verb, 'SQUASH'));\n\n const nothingStaged = spawnSync('git', ['diff-index', '--quiet', '--cached', 'HEAD', '--']).status === 0;\n if (nothingStaged) {\n process.stdout.write('ℹ️ Already up-to-date with main (nothing to merge).\\n');\n } else {\n // Internal, transient subject for the single squash commit on the feature branch. It NO LONGER\n // reaches main's history: finish-upsert-pr squash-merges the PR with an explicit\n // `gh pr merge --subject <PR title> --body-file <commit summary>`, so main carries the PR title.\n this.gitExec.runGitChecked(['commit', '-m', `Squash merge of ${currentBranch}`], 'Failed to commit squash merge');\n // Clean merge ⇒ hashes only, no conflicts.md. Its ABSENCE is what marks this merge clean.\n this.mergeState.recordCleanMerge(mergeDir, hashes.hashForkPoint, hashes.hashFeatureHead, hashes.hashMainHead);\n }\n return new MergeStartResult('clean', new MergeContext(currentBranch, squashBranch, backupBranch, prNumber), mergeDir);\n }\n\n // Detect the PR by its STABLE feature branch (a leftover `…wpN` still resolves to the one name the\n // PR lives on).\n private detectPr(baseBranch: string): string {\n const result = spawnSync(\n 'gh', ['pr', 'list', '--head', baseBranch, '--json', 'number', '--jq', '.[0].number'],\n { encoding: 'utf8' },\n );\n return result.status === 0 ? (result.stdout ?? '').trim() : '';\n }\n\n // Pick this sync's slot number MONOTONICALLY from the durable audit dirs, then step past any\n // existing `<branch>PreMerge<n>` branch so the same `n` is free for the paired backup branch.\n private chooseSyncSlot(home: string, currentBranch: string): SyncSlot {\n const branchExists = (name: string): boolean =>\n spawnSync('git', ['show-ref', '--verify', '--quiet', `refs/heads/${name}`]).status === 0;\n let n = this.mergeState.nextMergeSlotNumber(home);\n while (branchExists(this.branchNaming.preMergeBackupName(currentBranch, n))) n += 1;\n return new SyncSlot(this.branchNaming.preMergeBackupName(currentBranch, n), this.mergeState.mergeRunDirFor(home, n), n);\n }\n\n // Snapshot the pre-merge state onto the caller-chosen `backupBranch`, never overwriting.\n private createBackup(currentBranch: string, backupBranch: string): void {\n process.stdout.write('\\n' + SEP + '💾 Creating Pre-Merge Backup\\n' + SEP + '\\n');\n this.gitExec.runGitChecked(['checkout', '-b', backupBranch], 'Failed to create backup branch');\n this.gitExec.runGitChecked(['checkout', currentBranch], 'Failed to return to feature branch');\n process.stdout.write(`✅ Backup created: ${backupBranch}\\n\\n`);\n }\n\n private saveConflictContext(\n conflictedFiles: string[], mergeDir: string, forkPoint: string, featureHead: string, mainHead: string,\n ): void {\n for (const file of conflictedFiles) {\n const fileDir = this.mergeState.perFileContextDir(mergeDir, file);\n fs.mkdirSync(fileDir, { recursive: true });\n\n const fork = spawnSync('git', ['show', `${forkPoint}:${file}`], { encoding: 'utf8' });\n fs.writeFileSync(path.join(fileDir, 'A-forkpoint.txt'), fork.status === 0 ? (fork.stdout ?? '') : '(file did not exist)\\n');\n const feature = spawnSync('git', ['show', `${featureHead}:${file}`], { encoding: 'utf8' });\n fs.writeFileSync(path.join(fileDir, 'B-feature.txt'), feature.status === 0 ? (feature.stdout ?? '') : '(file did not exist)\\n');\n const main = spawnSync('git', ['show', `${mainHead}:${file}`], { encoding: 'utf8' });\n fs.writeFileSync(path.join(fileDir, 'C-main.txt'), main.status === 0 ? (main.stdout ?? '') : '(file did not exist)\\n');\n\n const ba = spawnSync('git', ['diff', forkPoint, featureHead, '--', file], { encoding: 'utf8' });\n fs.writeFileSync(path.join(fileDir, 'B-A.diff'), ba.stdout ?? '');\n const ca = spawnSync('git', ['diff', forkPoint, mainHead, '--', file], { encoding: 'utf8' });\n fs.writeFileSync(path.join(fileDir, 'C-A.diff'), ca.stdout ?? '');\n }\n }\n\n private mergeProcessDoc(mergeDir: string, squashBranch: string, conflictedFiles: string[], finishCommand: string): string {\n const fileList = conflictedFiles.map((f: string): string => `- \\`${f}\\``).join('\\n');\n return MERGE_PROCESS_TEMPLATE\n .replace(/\\{\\{SQUASH_BRANCH\\}\\}/g, squashBranch)\n .replace(/\\{\\{MERGE_DIR\\}\\}/g, mergeDir)\n .replace(/\\{\\{EXPLANATION_FILE\\}\\}/g, MERGE_EXPLANATION_FILE)\n .replace(/\\{\\{FINISH_COMMAND\\}\\}/g, finishCommand)\n .replace(/\\{\\{START_COMMAND\\}\\}/g, new SyncFlowGuidance().pairedStart(finishCommand))\n .replace(/\\{\\{FILE_LIST\\}\\}/g, fileList);\n }\n\n // Returns the absolute path of the written doc.\n private writeMergeProcessDoc(repoRoot: string, mergeDir: string, squashBranch: string, conflictedFiles: string[], finishCommand: string): string {\n const docDir = path.join(repoRoot, WEBPIECES_TMP_DIR, 'instruct-ai');\n fs.mkdirSync(docDir, { recursive: true });\n const docPath = path.join(docDir, 'webpieces.mergeprocess.md');\n fs.writeFileSync(docPath, this.mergeProcessDoc(mergeDir, squashBranch, conflictedFiles, finishCommand));\n return docPath;\n }\n\n // The AI-facing \"what just happened / what to do next\" recap on the conflict path.\n private printConflictHandback(\n docPath: string, mergeDir: string, squashBranch: string, conflictedFiles: string[], finishCommand: string,\n ): void {\n process.stdout.write('\\n' + SEP + `⚠️ Conflicts in ${conflictedFiles.length} file(s) — handing control back to you\\n` + SEP + '\\n');\n process.stdout.write('Here is exactly what I did and what you need to do:\\n\\n');\n process.stdout.write('What I did:\\n');\n process.stdout.write(' 1. snapshotted your pre-merge state to a PreMerge branch\\n');\n process.stdout.write(' 2. pulled origin/main and squash-merged your work onto it\\n');\n process.stdout.write(` 3. hit conflicts — you are now on the transient branch ${squashBranch}\\n\\n`);\n process.stdout.write('What you need to do:\\n');\n process.stdout.write(` 1. read the merge process doc: ${docPath}\\n`);\n process.stdout.write(` 2. resolve each conflicted file below (its 3-point A/B/C context + diffs are in\\n`);\n process.stdout.write(` ${mergeDir}/updatemain-<file>/), \\`git add\\` it, and write that file's merge-explanation.md\\n`);\n process.stdout.write(` 3. run pnpm ${finishCommand} — it validates, commits, and finalizes (do NOT git commit/push yourself)\\n\\n`);\n process.stdout.write('Conflicted files:\\n');\n for (const file of conflictedFiles) process.stdout.write(` - ${file}\\n`);\n process.stdout.write('\\n' + SEP);\n }\n\n // Write the conflict context files + the unvalidated marker + the process doc. Does NOT exit.\n private handleConflictsHandback(\n repoRoot: string, mergeDir: string, currentBranch: string, squashBranch: string,\n backupBranch: string, prNumber: string, hashes: HashPoints, finishCommand: string,\n ): void {\n const raw = execSync('git diff --name-only --diff-filter=U', { encoding: 'utf8' }).trim();\n const conflictedFiles = raw.split('\\n').filter((f: string): boolean => f.trim() !== '');\n fs.mkdirSync(mergeDir, { recursive: true });\n // `conflicts.md` replaces the old `updatemain-conflicted-files.txt` (which nothing read) AND is\n // the 3-point signal itself: this file exists in a run dir if and only if that merge conflicted,\n // which is what lets the index classify each merge without a per-branch directory-name scheme.\n this.mergeState.writeConflicts(mergeDir, conflictedFiles);\n fs.writeFileSync(path.join(mergeDir, 'updatemain-hashes.json'), JSON.stringify(hashes, null, 2) + '\\n');\n this.saveConflictContext(conflictedFiles, mergeDir, hashes.hashForkPoint, hashes.hashFeatureHead, hashes.hashMainHead);\n\n const marker = new MergeMarker(\n currentBranch, squashBranch, backupBranch, prNumber, conflictedFiles,\n hashes.hashForkPoint, hashes.hashFeatureHead, hashes.hashMainHead, false,\n );\n this.mergeState.writeMergeMarker(mergeDir, marker);\n const docPath = this.writeMergeProcessDoc(repoRoot, mergeDir, squashBranch, conflictedFiles, finishCommand);\n this.printConflictHandback(docPath, mergeDir, squashBranch, conflictedFiles, finishCommand);\n }\n\n // Short sha of a ref (best-effort — '' if it can't resolve).\n private shortSha(ref: string): string {\n const result = spawnSync('git', ['rev-parse', '--short', ref], { encoding: 'utf8' });\n return result.status === 0 ? (result.stdout ?? '').trim() : '';\n }\n\n // FULL sha of a ref (best-effort — '' if it can't resolve). Recorded for the pre-merge tips because\n // those hashes are meant to be used later to restore state, and an abbreviated sha can go ambiguous\n // as the repo grows.\n private fullSha(ref: string): string {\n const result = spawnSync('git', ['rev-parse', ref], { encoding: 'utf8' });\n return result.status === 0 ? (result.stdout ?? '').trim() : '';\n }\n\n // Log the CONFLICT phase with the conflicted-file list + artifact paths a resolver needs.\n private logConflict(repoRoot: string, verb: MutationVerb, mergeDir: string): void {\n const raw = spawnSync('git', ['diff', '--name-only', '--diff-filter=U'], { cwd: repoRoot, encoding: 'utf8' });\n const files = (raw.status === 0 ? (raw.stdout ?? '') : '').split('\\n').map((f: string): string => f.trim()).filter((f: string): boolean => f !== '');\n const event = new BranchMutationEvent(verb, 'CONFLICT');\n event.conflict = true;\n event.conflictFiles = files;\n event.artifacts = [\n path.join(mergeDir, 'updatemain-<file>'),\n path.join(repoRoot, WEBPIECES_TMP_DIR, 'instruct-ai', 'webpieces.mergeprocess.md'),\n ];\n logBranchMutation(repoRoot, event);\n }\n}\n"]}
|