@webpieces/pr-gate 0.4.516 → 0.4.517
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webpieces/pr-gate",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.517",
|
|
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.517",
|
|
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":";;;;AAAA,0DAAuE;AACvE,yCAA2D;AAC3D,qDAAiD;AACjD,+EAA8F;AAC9F,yCAAqC;AAGrC,MAAM,GAAG,GAAG,0DAA0D,CAAC;AAEvE;;;;GAIG;AACH,MAAa,oBAAoB;IAC7B,qGAAqG;IACrG,cAAc,CAAmB;IACjC,iGAAiG;IACjG,cAAc,CAAmB;IAEjC,YAAY,cAAgC,EAAE,cAAgC;QAC1E,IAAI,CAAC,cAAc,GAAG,cAAc,CAAC;QACrC,IAAI,CAAC,cAAc,GAAG,cAAc,CAAC;IACzC,CAAC;IAED,OAAO;QACH,OAAO,IAAI,CAAC,cAAc,CAAC,MAAM,KAAK,CAAC,IAAI,IAAI,CAAC,cAAc,CAAC,MAAM,KAAK,CAAC,CAAC;IAChF,CAAC;CACJ;AAdD,oDAcC;AAED;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAEI,IAAM,iBAAiB,GAAvB,MAAM,iBAAiB;IAEL;IACA;IACA;IAHrB,YACqB,OAAgB,EAChB,QAAmC,EACnC,aAA4B;QAF5B,YAAO,GAAP,OAAO,CAAS;QAChB,aAAQ,GAAR,QAAQ,CAA2B;QACnC,kBAAa,GAAb,aAAa,CAAe;IAC9C,CAAC;IAEJ;;;OAGG;IACH,iCAAiC,CAAC,QAAgB;QAC9C,MAAM,SAAS,GAAG,IAAI,CAAC,QAAQ,CAAC,OAAO,CAAC,QAAQ,CAAC,CAAC;QAClD,gGAAgG;QAChG,4EAA4E;QAC5E,MAAM,OAAO,GAAG,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,OAAO,CAAC,aAAa,CAAC,QAAQ,CAAC,EAAE,SAAS,CAAC,CAAC;QAC/E,IAAI,OAAO,CAAC,OAAO,EAAE,EAAE,CAAC;YACpB,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,uEAAuE,CAAC,CAAC;YAC9F,OAAO;QACX,CAAC;QACD,MAAM,IAAI,2BAAY,CAAC,CAAC,EAAE,IAAI,CAAC,MAAM,CAAC,QAAQ,EAAE,OAAO,EAAE,SAAS,CAAC,CAAC,CAAC;IACzE,CAAC;IAED;;;;;;;OAOG;IACH,QAAQ,CAAC,OAAkC,EAAE,SAA6B;QACtE,MAAM,KAAK,GAAqB,EAAE,CAAC;QACnC,MAAM,KAAK,GAAqB,EAAE,CAAC;QACnC,KAAK,MAAM,KAAK,IAAI,OAAO,EAAE,CAAC;YAC1B,IAAI,KAAK,CAAC,mBAAmB,EAAE;gBAAE,SAAS;YAC1C,IAAI,IAAA,6BAAc,EAAC,KAAK,CAAC,IAAI,EAAE,SAAS,CAAC,KAAK,CAAC;gBAAE,KAAK,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;;gBAC9D,KAAK,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;QAC3B,CAAC;QACD,OAAO,IAAI,oBAAoB,CAAC,KAAK,EAAE,KAAK,CAAC,CAAC;IAClD,CAAC;IAED,gFAAgF;IACxE,MAAM,CAAC,QAAgB,EAAE,OAA6B,EAAE,SAA6B;QACzF,MAAM,YAAY,GAAG,IAAI,CAAC,aAAa,CAAC,mBAAmB,CAAC,QAAQ,CAAC,CAAC;QACtE,MAAM,KAAK,GAAa,CAAC,IAAI,GAAG,GAAG,GAAG,4DAA4D,GAAG,GAAG,CAAC,CAAC;QAC1G,IAAI,OAAO,CAAC,cAAc,CAAC,MAAM,GAAG,CAAC;YAAE,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,WAAW,CAAC,OAAO,EAAE,YAAY,CAAC,CAAC,CAAC;QAC3F,IAAI,OAAO,CAAC,cAAc,CAAC,MAAM,GAAG,CAAC;YAAE,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,WAAW,CAAC,OAAO,CAAC,CAAC,CAAC;QAC7E,KAAK,CAAC,IAAI,CACN,gGAAgG;YAChG,kGAAkG;YAClG,wFAAwF;YACxF,oCAAoC,SAAS,CAAC,MAAM,IAAI,GAAG,GAAG,CAAC,CAAC;QACpE,OAAO,KAAK,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;IAC1B,CAAC;IAEO,WAAW,CAAC,OAA6B,EAAE,YAAoB;QACnE,OAAO,uBAAuB,YAAY,sDAAsD;YAC5F,8FAA8F;YAC9F,IAAI,CAAC,OAAO,CAAC,OAAO,CAAC,cAAc,CAAC;YACpC,2EAA2E;YAC3E,OAAO,YAAY,IAAI;YACvB,6DAA6D,CAAC;IACtE,CAAC;IAEO,WAAW,CAAC,OAA6B;QAC7C,OAAO,8FAA8F;YACjG,kGAAkG;YAClG,4BAA4B;YAC5B,qEAAqE;YACrE,IAAI,CAAC,OAAO,CAAC,OAAO,CAAC,cAAc,CAAC,CAAC;IAC7C,CAAC;IAEO,OAAO,CAAC,OAAkC;QAC9C,OAAO,OAAO;aACT,GAAG,CAAC,CAAC,CAAiB,EAAU,EAAE,CAAC,MAAM,CAAC,CAAC,WAAW,GAAG,CAAC,CAAC,cAAc,KAAK,CAAC,CAAC,IAAI,GAAG,CAAC,CAAC,WAAW,EAAE,CAAC,CAAC,CAAC,4BAA4B,CAAC,CAAC,CAAC,EAAE,IAAI,CAAC;aAC/I,IAAI,CAAC,EAAE,CAAC,CAAC;IAClB,CAAC;CACJ,CAAA;AA9EY,8CAAiB;4BAAjB,iBAAiB;IAD7B,IAAA,sBAAU,EAAC,8BAAkB,CAAC,SAAS,CAAC;6CAGP,kBAAO;QACN,uDAAyB;QACpB,8BAAa;GAJxC,iBAAiB,CA8E7B","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"]}
|