@webpieces/pr-gate 0.4.677 → 0.4.678
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/scripts/commands/checkout-clean-main-command.d.ts +7 -6
- package/src/scripts/commands/checkout-clean-main-command.js +15 -21
- package/src/scripts/commands/checkout-clean-main-command.js.map +1 -1
- package/src/scripts/commands/working-tree-gate.d.ts +48 -0
- package/src/scripts/commands/working-tree-gate.js +95 -0
- package/src/scripts/commands/working-tree-gate.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.678",
|
|
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",
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"directory": "packages/tooling/pr-gate"
|
|
16
16
|
},
|
|
17
17
|
"dependencies": {
|
|
18
|
-
"@webpieces/rules-config": "0.4.
|
|
18
|
+
"@webpieces/rules-config": "0.4.678",
|
|
19
19
|
"@inversifyjs/binding-decorators": "1.1.5",
|
|
20
20
|
"inversify": "7.10.4",
|
|
21
21
|
"reflect-metadata": "0.2.2"
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { OrphanDirSweeper, RepoRootFinder } from '@webpieces/rules-config';
|
|
2
2
|
import { CleanupCommand } from './cleanup-command';
|
|
3
|
+
import { WorkingTreeGate } from './working-tree-gate';
|
|
3
4
|
/**
|
|
4
5
|
* `wp-checkout-clean-main` — go to main, get main, and take out the trash. ONE command, because the three
|
|
5
6
|
* are one intention and splitting them is what let the third never happen.
|
|
@@ -44,7 +45,8 @@ export declare class CheckoutCleanMainCommand {
|
|
|
44
45
|
private readonly repoRootFinder;
|
|
45
46
|
private readonly cleanupCommand;
|
|
46
47
|
private readonly orphanDirSweeper;
|
|
47
|
-
|
|
48
|
+
private readonly workingTreeGate;
|
|
49
|
+
constructor(repoRootFinder: RepoRootFinder, cleanupCommand: CleanupCommand, orphanDirSweeper: OrphanDirSweeper, workingTreeGate: WorkingTreeGate);
|
|
48
50
|
run(): Promise<void>;
|
|
49
51
|
/**
|
|
50
52
|
* A linked worktree has no main to check out — `git checkout main` FATALS there with "main is already
|
|
@@ -55,10 +57,11 @@ export declare class CheckoutCleanMainCommand {
|
|
|
55
57
|
private sweepOnly;
|
|
56
58
|
private isLinkedWorktree;
|
|
57
59
|
/**
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
+
* Say which untracked files were let through, AFTER the move — so nobody has to wonder whether the
|
|
61
|
+
* files still sitting in their tree came along for the ride. Printed rather than refused on: see
|
|
62
|
+
* `WorkingTreeGate` for why an untracked file is not the hazard a tracked one is.
|
|
60
63
|
*/
|
|
61
|
-
private
|
|
64
|
+
private reportUntracked;
|
|
62
65
|
/**
|
|
63
66
|
* Checkout main and fast-forward it. `--ff-only` rather than a `reset --hard`, deliberately: a
|
|
64
67
|
* developer or an agent that accidentally committed to local main would have that work silently
|
|
@@ -71,8 +74,6 @@ export declare class CheckoutCleanMainCommand {
|
|
|
71
74
|
* permitted to turn somebody's completed sync into a failed command.
|
|
72
75
|
*/
|
|
73
76
|
private sweep;
|
|
74
|
-
/** Captured git output, or null when git could not be run or exited non-zero. */
|
|
75
|
-
private git;
|
|
76
77
|
/** git with its output going straight to the terminal — for the commands whose progress is the point. */
|
|
77
78
|
private run_;
|
|
78
79
|
}
|
|
@@ -6,6 +6,7 @@ const child_process_1 = require("child_process");
|
|
|
6
6
|
const inversify_1 = require("inversify");
|
|
7
7
|
const rules_config_1 = require("@webpieces/rules-config");
|
|
8
8
|
const cleanup_command_1 = require("./cleanup-command");
|
|
9
|
+
const working_tree_gate_1 = require("./working-tree-gate");
|
|
9
10
|
const SEP = '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n';
|
|
10
11
|
/**
|
|
11
12
|
* `wp-checkout-clean-main` — go to main, get main, and take out the trash. ONE command, because the three
|
|
@@ -51,10 +52,12 @@ let CheckoutCleanMainCommand = class CheckoutCleanMainCommand {
|
|
|
51
52
|
repoRootFinder;
|
|
52
53
|
cleanupCommand;
|
|
53
54
|
orphanDirSweeper;
|
|
54
|
-
|
|
55
|
+
workingTreeGate;
|
|
56
|
+
constructor(repoRootFinder, cleanupCommand, orphanDirSweeper, workingTreeGate) {
|
|
55
57
|
this.repoRootFinder = repoRootFinder;
|
|
56
58
|
this.cleanupCommand = cleanupCommand;
|
|
57
59
|
this.orphanDirSweeper = orphanDirSweeper;
|
|
60
|
+
this.workingTreeGate = workingTreeGate;
|
|
58
61
|
}
|
|
59
62
|
async run() {
|
|
60
63
|
const repoRoot = this.repoRootFinder.resolveRepoRoot(process.cwd());
|
|
@@ -63,8 +66,9 @@ let CheckoutCleanMainCommand = class CheckoutCleanMainCommand {
|
|
|
63
66
|
await this.sweepOnly(repoRoot);
|
|
64
67
|
return;
|
|
65
68
|
}
|
|
66
|
-
this.
|
|
69
|
+
const untracked = this.workingTreeGate.assertNoTrackedChanges(repoRoot);
|
|
67
70
|
this.goToMain(repoRoot);
|
|
71
|
+
this.reportUntracked(untracked);
|
|
68
72
|
await this.cleanupCommand.run();
|
|
69
73
|
this.sweep(repoRoot);
|
|
70
74
|
}
|
|
@@ -88,17 +92,15 @@ let CheckoutCleanMainCommand = class CheckoutCleanMainCommand {
|
|
|
88
92
|
return dirs !== null && dirs.isLinkedWorktree;
|
|
89
93
|
}
|
|
90
94
|
/**
|
|
91
|
-
*
|
|
92
|
-
*
|
|
95
|
+
* Say which untracked files were let through, AFTER the move — so nobody has to wonder whether the
|
|
96
|
+
* files still sitting in their tree came along for the ride. Printed rather than refused on: see
|
|
97
|
+
* `WorkingTreeGate` for why an untracked file is not the hazard a tracked one is.
|
|
93
98
|
*/
|
|
94
|
-
|
|
95
|
-
const
|
|
96
|
-
if (
|
|
99
|
+
reportUntracked(untracked) {
|
|
100
|
+
const rendered = untracked.render();
|
|
101
|
+
if (rendered === '')
|
|
97
102
|
return;
|
|
98
|
-
|
|
99
|
-
+ 'Going to main would carry them along. Commit them on this branch, or stash them, then\n'
|
|
100
|
-
+ 're-run. The webpieces tooling never commits your work for you.\n\n'
|
|
101
|
-
+ `${status}`);
|
|
103
|
+
process.stdout.write(`\n${rendered}`);
|
|
102
104
|
}
|
|
103
105
|
/**
|
|
104
106
|
* Checkout main and fast-forward it. `--ff-only` rather than a `reset --hard`, deliberately: a
|
|
@@ -130,15 +132,6 @@ let CheckoutCleanMainCommand = class CheckoutCleanMainCommand {
|
|
|
130
132
|
return;
|
|
131
133
|
process.stdout.write(`\n${rendered}`);
|
|
132
134
|
}
|
|
133
|
-
/** Captured git output, or null when git could not be run or exited non-zero. */
|
|
134
|
-
git(repoRoot, args) {
|
|
135
|
-
const result = (0, child_process_1.spawnSync)('git', ['-C', repoRoot, ...args], {
|
|
136
|
-
encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
|
|
137
|
-
});
|
|
138
|
-
if (result.error !== undefined || result.status !== 0)
|
|
139
|
-
return null;
|
|
140
|
-
return result.stdout;
|
|
141
|
-
}
|
|
142
135
|
/** git with its output going straight to the terminal — for the commands whose progress is the point. */
|
|
143
136
|
run_(repoRoot, args) {
|
|
144
137
|
const result = (0, child_process_1.spawnSync)('git', ['-C', repoRoot, ...args], { encoding: 'utf8', stdio: 'inherit' });
|
|
@@ -150,6 +143,7 @@ exports.CheckoutCleanMainCommand = CheckoutCleanMainCommand = tslib_1.__decorate
|
|
|
150
143
|
(0, inversify_1.injectable)(inversify_1.bindingScopeValues.Singleton),
|
|
151
144
|
tslib_1.__metadata("design:paramtypes", [rules_config_1.RepoRootFinder,
|
|
152
145
|
cleanup_command_1.CleanupCommand,
|
|
153
|
-
rules_config_1.OrphanDirSweeper
|
|
146
|
+
rules_config_1.OrphanDirSweeper,
|
|
147
|
+
working_tree_gate_1.WorkingTreeGate])
|
|
154
148
|
], CheckoutCleanMainCommand);
|
|
155
149
|
//# sourceMappingURL=checkout-clean-main-command.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"checkout-clean-main-command.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/pr-gate/src/scripts/commands/checkout-clean-main-command.ts"],"names":[],"mappings":"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","sourcesContent":["import { spawnSync } from 'child_process';\nimport { injectable, bindingScopeValues } from 'inversify';\nimport {\n CliExitError,\n OrphanDirSweeper,\n RepoRootFinder,\n dotWebpieces,\n} from '@webpieces/rules-config';\n\nimport { CleanupCommand } from './cleanup-command';\n\nconst SEP = '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\\n';\n\n/**\n * `wp-checkout-clean-main` — go to main, get main, and take out the trash. ONE command, because the three\n * are one intention and splitting them is what let the third never happen.\n *\n * ─── ITS RELATIONSHIP TO `git checkout main && git pull origin main` (READ BEFORE \"DELETING\" THAT) ────\n * That pair was already the enforced spelling: bare `git checkout main` is blocked precisely so the pull\n * rides along. This command is the same pairing with cleanup and the sweep welded on, and the WORKFLOW\n * messages now prescribe this instead — two spellings where one sweeps and one does not is shim shape\n * #1, an agent types whichever is accepted and the corpses accumulate forever.\n *\n * The raw pair is NOT deleted, and the reason is a constraint that outranks the shim rule: it is a\n * TERMINAL ENTRY ON THE L0 ALLOWLIST (`CHECKOUT_MAIN_PULL_CMD` in ai-hook-rules\n * `src/bin/l0-allowlist.ts`, consumed as an L0 cure in `src/core/l0-matrix.ts`) — one of\n * the few commands still permitted while an L0 block denies every other tool call. In exactly that\n * state, `node_modules` is the thing that is untrustworthy, so a `pnpm wp-*` bin is the one kind of cure\n * that cannot be relied on to run. The same reasoning covers the L0 shim's own fix option in\n * `templates/ai-hook.sh` (rendered from `src/bin/shim-drift-fix.ts`).\n *\n * So the end state is TWO LAYERS, not a deletion, and it is now in place:\n * • L0 recovery (l0-allowlist, shim-drift-fix, l0-matrix) → keeps the raw pair. It is not a shim\n * there; it is the only thing that works when the package manager's output is what is in doubt.\n * • the WORKFLOW layer (stale-main-bash-guard's preferred cure, merged-branch-message/-bash-guard,\n * TreeRecovery's cleanupSteps and updateMainSteps, the L2 rows/doc, CLAUDE.md's \"Finishing a\n * Feature\") → prescribes `pnpm wp-checkout-clean-main`, and no longer prints the pair.\n *\n * The pair remains ALLOWED as a Bash command in both layers — this changed what the guards TEACH, not\n * what they permit. Blocking it would have deleted the L0 escape along with the shim.\n *\n * ─── WHY GOING TO MAIN IS THE RIGHT MOMENT TO SWEEP ───────────────────────────────────────────────────\n * The corpses appear when git moves the working tree onto a base where the tracked files under some\n * directory are gone. Landing a PR and returning to main is when every developer does that, on every\n * clone, roughly once per merged PR — so a sweep bolted here converges the whole team's machines with no\n * new trigger, no git hook to distribute, and no background daemon. It is also the quietest moment in the\n * day to move directories: nothing is building, no dev server is watching files.\n *\n * ─── WHY THE ORDER IS FIXED AND NOT PARALLEL ──────────────────────────────────────────────────────────\n * Cleanup runs BEFORE the sweep and not beside it. `wp-cleanup` removes dead worktrees, which changes\n * what is on disk, and a scan racing that would be reading a tree mid-demolition. The scan is one\n * ignore-walk and costs about a second; there is no wall-clock worth buying with that race.\n */\n@injectable(bindingScopeValues.Singleton)\nexport class CheckoutCleanMainCommand {\n constructor(\n private readonly repoRootFinder: RepoRootFinder,\n private readonly cleanupCommand: CleanupCommand,\n private readonly orphanDirSweeper: OrphanDirSweeper,\n ) {}\n\n async run(): Promise<void> {\n const repoRoot = this.repoRootFinder.resolveRepoRoot(process.cwd());\n process.stdout.write(`${SEP}🧹 Checkout main, pull, clean\\n${SEP}\\n`);\n if (this.isLinkedWorktree(repoRoot)) {\n await this.sweepOnly(repoRoot);\n return;\n }\n this.assertTreeIsClean(repoRoot);\n this.goToMain(repoRoot);\n await this.cleanupCommand.run();\n this.sweep(repoRoot);\n }\n\n /**\n * A linked worktree has no main to check out — `git checkout main` FATALS there with \"main is already\n * checked out at <primary clone>\", which is a footgun CLAUDE.md currently has to warn about in prose.\n * So the command does the half that IS meaningful here (worktrees accumulate corpses too, if a\n * long-lived one keeps merging main in) and says plainly where the other half lives.\n */\n private async sweepOnly(repoRoot: string): Promise<void> {\n process.stdout.write(\n 'This is a linked WORKTREE. main is checked out in the primary clone, so there is nothing\\n'\n + 'here to check out or pull — sweeping this tree only.\\n\\n'\n + 'Most worktrees never need this: `wp-cleanup` removes the whole directory once its branch\\n'\n + 'lands, which takes any orphan directories inside it along. Run this command again from the\\n'\n + 'primary clone to go to main there.\\n\\n');\n this.sweep(repoRoot);\n return Promise.resolve();\n }\n\n private isLinkedWorktree(repoRoot: string): boolean {\n const dirs = dotWebpieces.gitDirs(repoRoot);\n return dirs !== null && dirs.isLinkedWorktree;\n }\n\n /**\n * Refuse on a dirty tree instead of carrying the changes onto main. `git checkout` would happily\n * bring uncommitted work along, and the one place nobody wants to discover their work is on main.\n */\n private assertTreeIsClean(repoRoot: string): void {\n const status = this.git(repoRoot, ['status', '--porcelain']);\n if (status === null || status.trim() === '') return;\n throw new CliExitError(1,\n `${SEP}❌ Uncommitted or untracked changes\\n${SEP}\\n`\n + 'Going to main would carry them along. Commit them on this branch, or stash them, then\\n'\n + 're-run. The webpieces tooling never commits your work for you.\\n\\n'\n + `${status}`);\n }\n\n /**\n * Checkout main and fast-forward it. `--ff-only` rather than a `reset --hard`, deliberately: a\n * developer or an agent that accidentally committed to local main would have that work silently\n * destroyed by a reset, and the whole point of this command is that it is safe to run without\n * thinking. A refusal to fast-forward is a real condition a human should see and decide about.\n */\n private goToMain(repoRoot: string): void {\n this.run_(repoRoot, ['fetch', 'origin', 'main']);\n if (this.run_(repoRoot, ['checkout', 'main']) !== 0) {\n throw new CliExitError(1, `${SEP}❌ Could not check out main\\n${SEP}\\n`\n + 'git refused the checkout — its message is above.\\n');\n }\n if (this.run_(repoRoot, ['pull', '--ff-only', 'origin', 'main']) !== 0) {\n throw new CliExitError(1, `${SEP}❌ Local main could not fast-forward\\n${SEP}\\n`\n + 'Local main has commits that origin/main does not, so it is not a clean copy of the\\n'\n + 'remote. Nothing was reset — those commits may be the only copy. Inspect them with\\n'\n + '`git log origin/main..main` and decide what they are before going further.\\n');\n }\n }\n\n /**\n * Sweep, then print. Never throws: the checkout and pull above already succeeded, and a tidier is not\n * permitted to turn somebody's completed sync into a failed command.\n */\n private sweep(repoRoot: string): void {\n const report = this.orphanDirSweeper.sweep(repoRoot, new Date());\n const rendered = report.render();\n if (rendered === '') return;\n process.stdout.write(`\\n${rendered}`);\n }\n\n /** Captured git output, or null when git could not be run or exited non-zero. */\n private git(repoRoot: string, args: string[]): string | null {\n const result = spawnSync('git', ['-C', repoRoot, ...args], {\n encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],\n });\n if (result.error !== undefined || result.status !== 0) return null;\n return result.stdout;\n }\n\n /** git with its output going straight to the terminal — for the commands whose progress is the point. */\n private run_(repoRoot: string, args: string[]): number {\n const result = spawnSync('git', ['-C', repoRoot, ...args], { encoding: 'utf8', stdio: 'inherit' });\n return result.status === null ? 1 : result.status;\n }\n}\n"]}
|
|
1
|
+
{"version":3,"file":"checkout-clean-main-command.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/pr-gate/src/scripts/commands/checkout-clean-main-command.ts"],"names":[],"mappings":";;;;AAAA,iDAA0C;AAC1C,yCAA2D;AAC3D,0DAKiC;AAEjC,uDAAmD;AACnD,2DAAsE;AAEtE,MAAM,GAAG,GAAG,0DAA0D,CAAC;AAEvE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuCG;AAEI,IAAM,wBAAwB,GAA9B,MAAM,wBAAwB;IAEZ;IACA;IACA;IACA;IAJrB,YACqB,cAA8B,EAC9B,cAA8B,EAC9B,gBAAkC,EAClC,eAAgC;QAHhC,mBAAc,GAAd,cAAc,CAAgB;QAC9B,mBAAc,GAAd,cAAc,CAAgB;QAC9B,qBAAgB,GAAhB,gBAAgB,CAAkB;QAClC,oBAAe,GAAf,eAAe,CAAiB;IAClD,CAAC;IAEJ,KAAK,CAAC,GAAG;QACL,MAAM,QAAQ,GAAG,IAAI,CAAC,cAAc,CAAC,eAAe,CAAC,OAAO,CAAC,GAAG,EAAE,CAAC,CAAC;QACpE,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,GAAG,GAAG,kCAAkC,GAAG,IAAI,CAAC,CAAC;QACtE,IAAI,IAAI,CAAC,gBAAgB,CAAC,QAAQ,CAAC,EAAE,CAAC;YAClC,MAAM,IAAI,CAAC,SAAS,CAAC,QAAQ,CAAC,CAAC;YAC/B,OAAO;QACX,CAAC;QACD,MAAM,SAAS,GAAG,IAAI,CAAC,eAAe,CAAC,sBAAsB,CAAC,QAAQ,CAAC,CAAC;QACxE,IAAI,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC;QACxB,IAAI,CAAC,eAAe,CAAC,SAAS,CAAC,CAAC;QAChC,MAAM,IAAI,CAAC,cAAc,CAAC,GAAG,EAAE,CAAC;QAChC,IAAI,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAC;IACzB,CAAC;IAED;;;;;OAKG;IACK,KAAK,CAAC,SAAS,CAAC,QAAgB;QACpC,OAAO,CAAC,MAAM,CAAC,KAAK,CAChB,4FAA4F;cAC1F,0DAA0D;cAC1D,4FAA4F;cAC5F,8FAA8F;cAC9F,wCAAwC,CAAC,CAAC;QAChD,IAAI,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAC;QACrB,OAAO,OAAO,CAAC,OAAO,EAAE,CAAC;IAC7B,CAAC;IAEO,gBAAgB,CAAC,QAAgB;QACrC,MAAM,IAAI,GAAG,2BAAY,CAAC,OAAO,CAAC,QAAQ,CAAC,CAAC;QAC5C,OAAO,IAAI,KAAK,IAAI,IAAI,IAAI,CAAC,gBAAgB,CAAC;IAClD,CAAC;IAED;;;;OAIG;IACK,eAAe,CAAC,SAAyB;QAC7C,MAAM,QAAQ,GAAG,SAAS,CAAC,MAAM,EAAE,CAAC;QACpC,IAAI,QAAQ,KAAK,EAAE;YAAE,OAAO;QAC5B,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,KAAK,QAAQ,EAAE,CAAC,CAAC;IAC1C,CAAC;IAED;;;;;OAKG;IACK,QAAQ,CAAC,QAAgB;QAC7B,IAAI,CAAC,IAAI,CAAC,QAAQ,EAAE,CAAC,OAAO,EAAE,QAAQ,EAAE,MAAM,CAAC,CAAC,CAAC;QACjD,IAAI,IAAI,CAAC,IAAI,CAAC,QAAQ,EAAE,CAAC,UAAU,EAAE,MAAM,CAAC,CAAC,KAAK,CAAC,EAAE,CAAC;YAClD,MAAM,IAAI,2BAAY,CAAC,CAAC,EAAE,GAAG,GAAG,+BAA+B,GAAG,IAAI;kBAChE,oDAAoD,CAAC,CAAC;QAChE,CAAC;QACD,IAAI,IAAI,CAAC,IAAI,CAAC,QAAQ,EAAE,CAAC,MAAM,EAAE,WAAW,EAAE,QAAQ,EAAE,MAAM,CAAC,CAAC,KAAK,CAAC,EAAE,CAAC;YACrE,MAAM,IAAI,2BAAY,CAAC,CAAC,EAAE,GAAG,GAAG,wCAAwC,GAAG,IAAI;kBACzE,sFAAsF;kBACtF,qFAAqF;kBACrF,8EAA8E,CAAC,CAAC;QAC1F,CAAC;IACL,CAAC;IAED;;;OAGG;IACK,KAAK,CAAC,QAAgB;QAC1B,MAAM,MAAM,GAAG,IAAI,CAAC,gBAAgB,CAAC,KAAK,CAAC,QAAQ,EAAE,IAAI,IAAI,EAAE,CAAC,CAAC;QACjE,MAAM,QAAQ,GAAG,MAAM,CAAC,MAAM,EAAE,CAAC;QACjC,IAAI,QAAQ,KAAK,EAAE;YAAE,OAAO;QAC5B,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,KAAK,QAAQ,EAAE,CAAC,CAAC;IAC1C,CAAC;IAED,yGAAyG;IACjG,IAAI,CAAC,QAAgB,EAAE,IAAc;QACzC,MAAM,MAAM,GAAG,IAAA,yBAAS,EAAC,KAAK,EAAE,CAAC,IAAI,EAAE,QAAQ,EAAE,GAAG,IAAI,CAAC,EAAE,EAAE,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,SAAS,EAAE,CAAC,CAAC;QACnG,OAAO,MAAM,CAAC,MAAM,KAAK,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,MAAM,CAAC;IACtD,CAAC;CACJ,CAAA;AA3FY,4DAAwB;mCAAxB,wBAAwB;IADpC,IAAA,sBAAU,EAAC,8BAAkB,CAAC,SAAS,CAAC;6CAGA,6BAAc;QACd,gCAAc;QACZ,+BAAgB;QACjB,mCAAe;GAL5C,wBAAwB,CA2FpC","sourcesContent":["import { spawnSync } from 'child_process';\nimport { injectable, bindingScopeValues } from 'inversify';\nimport {\n CliExitError,\n OrphanDirSweeper,\n RepoRootFinder,\n dotWebpieces,\n} from '@webpieces/rules-config';\n\nimport { CleanupCommand } from './cleanup-command';\nimport { WorkingTreeGate, UntrackedFiles } from './working-tree-gate';\n\nconst SEP = '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\\n';\n\n/**\n * `wp-checkout-clean-main` — go to main, get main, and take out the trash. ONE command, because the three\n * are one intention and splitting them is what let the third never happen.\n *\n * ─── ITS RELATIONSHIP TO `git checkout main && git pull origin main` (READ BEFORE \"DELETING\" THAT) ────\n * That pair was already the enforced spelling: bare `git checkout main` is blocked precisely so the pull\n * rides along. This command is the same pairing with cleanup and the sweep welded on, and the WORKFLOW\n * messages now prescribe this instead — two spellings where one sweeps and one does not is shim shape\n * #1, an agent types whichever is accepted and the corpses accumulate forever.\n *\n * The raw pair is NOT deleted, and the reason is a constraint that outranks the shim rule: it is a\n * TERMINAL ENTRY ON THE L0 ALLOWLIST (`CHECKOUT_MAIN_PULL_CMD` in ai-hook-rules\n * `src/bin/l0-allowlist.ts`, consumed as an L0 cure in `src/core/l0-matrix.ts`) — one of\n * the few commands still permitted while an L0 block denies every other tool call. In exactly that\n * state, `node_modules` is the thing that is untrustworthy, so a `pnpm wp-*` bin is the one kind of cure\n * that cannot be relied on to run. The same reasoning covers the L0 shim's own fix option in\n * `templates/ai-hook.sh` (rendered from `src/bin/shim-drift-fix.ts`).\n *\n * So the end state is TWO LAYERS, not a deletion, and it is now in place:\n * • L0 recovery (l0-allowlist, shim-drift-fix, l0-matrix) → keeps the raw pair. It is not a shim\n * there; it is the only thing that works when the package manager's output is what is in doubt.\n * • the WORKFLOW layer (stale-main-bash-guard's preferred cure, merged-branch-message/-bash-guard,\n * TreeRecovery's cleanupSteps and updateMainSteps, the L2 rows/doc, CLAUDE.md's \"Finishing a\n * Feature\") → prescribes `pnpm wp-checkout-clean-main`, and no longer prints the pair.\n *\n * The pair remains ALLOWED as a Bash command in both layers — this changed what the guards TEACH, not\n * what they permit. Blocking it would have deleted the L0 escape along with the shim.\n *\n * ─── WHY GOING TO MAIN IS THE RIGHT MOMENT TO SWEEP ───────────────────────────────────────────────────\n * The corpses appear when git moves the working tree onto a base where the tracked files under some\n * directory are gone. Landing a PR and returning to main is when every developer does that, on every\n * clone, roughly once per merged PR — so a sweep bolted here converges the whole team's machines with no\n * new trigger, no git hook to distribute, and no background daemon. It is also the quietest moment in the\n * day to move directories: nothing is building, no dev server is watching files.\n *\n * ─── WHY THE ORDER IS FIXED AND NOT PARALLEL ──────────────────────────────────────────────────────────\n * Cleanup runs BEFORE the sweep and not beside it. `wp-cleanup` removes dead worktrees, which changes\n * what is on disk, and a scan racing that would be reading a tree mid-demolition. The scan is one\n * ignore-walk and costs about a second; there is no wall-clock worth buying with that race.\n */\n@injectable(bindingScopeValues.Singleton)\nexport class CheckoutCleanMainCommand {\n constructor(\n private readonly repoRootFinder: RepoRootFinder,\n private readonly cleanupCommand: CleanupCommand,\n private readonly orphanDirSweeper: OrphanDirSweeper,\n private readonly workingTreeGate: WorkingTreeGate,\n ) {}\n\n async run(): Promise<void> {\n const repoRoot = this.repoRootFinder.resolveRepoRoot(process.cwd());\n process.stdout.write(`${SEP}🧹 Checkout main, pull, clean\\n${SEP}\\n`);\n if (this.isLinkedWorktree(repoRoot)) {\n await this.sweepOnly(repoRoot);\n return;\n }\n const untracked = this.workingTreeGate.assertNoTrackedChanges(repoRoot);\n this.goToMain(repoRoot);\n this.reportUntracked(untracked);\n await this.cleanupCommand.run();\n this.sweep(repoRoot);\n }\n\n /**\n * A linked worktree has no main to check out — `git checkout main` FATALS there with \"main is already\n * checked out at <primary clone>\", which is a footgun CLAUDE.md currently has to warn about in prose.\n * So the command does the half that IS meaningful here (worktrees accumulate corpses too, if a\n * long-lived one keeps merging main in) and says plainly where the other half lives.\n */\n private async sweepOnly(repoRoot: string): Promise<void> {\n process.stdout.write(\n 'This is a linked WORKTREE. main is checked out in the primary clone, so there is nothing\\n'\n + 'here to check out or pull — sweeping this tree only.\\n\\n'\n + 'Most worktrees never need this: `wp-cleanup` removes the whole directory once its branch\\n'\n + 'lands, which takes any orphan directories inside it along. Run this command again from the\\n'\n + 'primary clone to go to main there.\\n\\n');\n this.sweep(repoRoot);\n return Promise.resolve();\n }\n\n private isLinkedWorktree(repoRoot: string): boolean {\n const dirs = dotWebpieces.gitDirs(repoRoot);\n return dirs !== null && dirs.isLinkedWorktree;\n }\n\n /**\n * Say which untracked files were let through, AFTER the move — so nobody has to wonder whether the\n * files still sitting in their tree came along for the ride. Printed rather than refused on: see\n * `WorkingTreeGate` for why an untracked file is not the hazard a tracked one is.\n */\n private reportUntracked(untracked: UntrackedFiles): void {\n const rendered = untracked.render();\n if (rendered === '') return;\n process.stdout.write(`\\n${rendered}`);\n }\n\n /**\n * Checkout main and fast-forward it. `--ff-only` rather than a `reset --hard`, deliberately: a\n * developer or an agent that accidentally committed to local main would have that work silently\n * destroyed by a reset, and the whole point of this command is that it is safe to run without\n * thinking. A refusal to fast-forward is a real condition a human should see and decide about.\n */\n private goToMain(repoRoot: string): void {\n this.run_(repoRoot, ['fetch', 'origin', 'main']);\n if (this.run_(repoRoot, ['checkout', 'main']) !== 0) {\n throw new CliExitError(1, `${SEP}❌ Could not check out main\\n${SEP}\\n`\n + 'git refused the checkout — its message is above.\\n');\n }\n if (this.run_(repoRoot, ['pull', '--ff-only', 'origin', 'main']) !== 0) {\n throw new CliExitError(1, `${SEP}❌ Local main could not fast-forward\\n${SEP}\\n`\n + 'Local main has commits that origin/main does not, so it is not a clean copy of the\\n'\n + 'remote. Nothing was reset — those commits may be the only copy. Inspect them with\\n'\n + '`git log origin/main..main` and decide what they are before going further.\\n');\n }\n }\n\n /**\n * Sweep, then print. Never throws: the checkout and pull above already succeeded, and a tidier is not\n * permitted to turn somebody's completed sync into a failed command.\n */\n private sweep(repoRoot: string): void {\n const report = this.orphanDirSweeper.sweep(repoRoot, new Date());\n const rendered = report.render();\n if (rendered === '') return;\n process.stdout.write(`\\n${rendered}`);\n }\n\n /** git with its output going straight to the terminal — for the commands whose progress is the point. */\n private run_(repoRoot: string, args: string[]): number {\n const result = spawnSync('git', ['-C', repoRoot, ...args], { encoding: 'utf8', stdio: 'inherit' });\n return result.status === null ? 1 : result.status;\n }\n}\n"]}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The untracked files a branch switch is about to leave exactly where they are — carried out of the gate
|
|
3
|
+
* so the caller can SAY so, rather than surprise anyone with files that appear to have followed them.
|
|
4
|
+
*/
|
|
5
|
+
export declare class UntrackedFiles {
|
|
6
|
+
readonly paths: readonly string[];
|
|
7
|
+
constructor(paths: readonly string[]);
|
|
8
|
+
isEmpty(): boolean;
|
|
9
|
+
/** The human-facing block, or '' when there is nothing to say. */
|
|
10
|
+
render(): string;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* The clean-tree gate for `wp-checkout-clean-main`: refuse when TRACKED work would ride onto main, and
|
|
14
|
+
* only then.
|
|
15
|
+
*
|
|
16
|
+
* ─── WHY UNTRACKED FILES ARE NOT A REASON TO REFUSE ───────────────────────────────────────────────────
|
|
17
|
+
* The hazard the gate exists for is real but narrow: `git checkout` carries uncommitted changes to
|
|
18
|
+
* TRACKED files across the switch, and main is the one place nobody wants to discover their work. An
|
|
19
|
+
* untracked file has none of that. It is not on any branch, the switch does not move it, and no branch
|
|
20
|
+
* can end up holding it — so refusing on one blocks a routine, safe operation.
|
|
21
|
+
*
|
|
22
|
+
* Worse, it was a refusal the user could not clear without deleting their file. The message prescribed
|
|
23
|
+
* "commit them on this branch, or stash them", and a plain `git stash` does NOT take untracked files
|
|
24
|
+
* (that needs `-u`) — so a user who did exactly what the error said got the identical refusal back. That
|
|
25
|
+
* is the shape this repo treats as a defect in its own right: a cure that does not resolve the state it
|
|
26
|
+
* is prescribed for. Reported from the field, where a generated `design.html` sitting untracked made the
|
|
27
|
+
* command permanently unusable in that clone.
|
|
28
|
+
*
|
|
29
|
+
* Now the refusal only ever names tracked changes, so `git stash` — the cure it prints — always resolves
|
|
30
|
+
* it, by construction.
|
|
31
|
+
*
|
|
32
|
+
* ─── WHY `--untracked-files=no` AND `ls-files -z` RATHER THAN PARSING `??` ─────────────────────────────
|
|
33
|
+
* The decision half asks git the tracked-only question directly, so no line of porcelain output is ever
|
|
34
|
+
* parsed to make it. The reporting half asks `ls-files --others --exclude-standard -z`, which emits bare
|
|
35
|
+
* NUL-separated paths — no `?? ` prefix to strip and, crucially, no quoting: porcelain v1 wraps paths
|
|
36
|
+
* containing special characters in quotes with C-style escapes, so a hand-parser would hand back a
|
|
37
|
+
* mangled name for exactly the paths a human most needs to read correctly.
|
|
38
|
+
*/
|
|
39
|
+
export declare class WorkingTreeGate {
|
|
40
|
+
/**
|
|
41
|
+
* Throw when tracked files are modified or staged; otherwise hand back the untracked files that were
|
|
42
|
+
* deliberately allowed through, for the caller to mention.
|
|
43
|
+
*/
|
|
44
|
+
assertNoTrackedChanges(repoRoot: string): UntrackedFiles;
|
|
45
|
+
private untrackedFiles;
|
|
46
|
+
/** Captured git output, or null when git could not be run or exited non-zero. */
|
|
47
|
+
private git;
|
|
48
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.WorkingTreeGate = exports.UntrackedFiles = void 0;
|
|
4
|
+
const tslib_1 = require("tslib");
|
|
5
|
+
const child_process_1 = require("child_process");
|
|
6
|
+
const inversify_1 = require("inversify");
|
|
7
|
+
const rules_config_1 = require("@webpieces/rules-config");
|
|
8
|
+
const SEP = '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n';
|
|
9
|
+
/**
|
|
10
|
+
* The untracked files a branch switch is about to leave exactly where they are — carried out of the gate
|
|
11
|
+
* so the caller can SAY so, rather than surprise anyone with files that appear to have followed them.
|
|
12
|
+
*/
|
|
13
|
+
class UntrackedFiles {
|
|
14
|
+
paths;
|
|
15
|
+
constructor(paths) {
|
|
16
|
+
this.paths = paths;
|
|
17
|
+
}
|
|
18
|
+
isEmpty() {
|
|
19
|
+
return this.paths.length === 0;
|
|
20
|
+
}
|
|
21
|
+
/** The human-facing block, or '' when there is nothing to say. */
|
|
22
|
+
render() {
|
|
23
|
+
if (this.isEmpty())
|
|
24
|
+
return '';
|
|
25
|
+
const list = this.paths.map((path) => ` ${path}\n`).join('');
|
|
26
|
+
return 'These untracked files are not on any branch, so the checkout leaves them exactly where\n'
|
|
27
|
+
+ 'they are — nothing about them changed:\n'
|
|
28
|
+
+ list;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
exports.UntrackedFiles = UntrackedFiles;
|
|
32
|
+
/**
|
|
33
|
+
* The clean-tree gate for `wp-checkout-clean-main`: refuse when TRACKED work would ride onto main, and
|
|
34
|
+
* only then.
|
|
35
|
+
*
|
|
36
|
+
* ─── WHY UNTRACKED FILES ARE NOT A REASON TO REFUSE ───────────────────────────────────────────────────
|
|
37
|
+
* The hazard the gate exists for is real but narrow: `git checkout` carries uncommitted changes to
|
|
38
|
+
* TRACKED files across the switch, and main is the one place nobody wants to discover their work. An
|
|
39
|
+
* untracked file has none of that. It is not on any branch, the switch does not move it, and no branch
|
|
40
|
+
* can end up holding it — so refusing on one blocks a routine, safe operation.
|
|
41
|
+
*
|
|
42
|
+
* Worse, it was a refusal the user could not clear without deleting their file. The message prescribed
|
|
43
|
+
* "commit them on this branch, or stash them", and a plain `git stash` does NOT take untracked files
|
|
44
|
+
* (that needs `-u`) — so a user who did exactly what the error said got the identical refusal back. That
|
|
45
|
+
* is the shape this repo treats as a defect in its own right: a cure that does not resolve the state it
|
|
46
|
+
* is prescribed for. Reported from the field, where a generated `design.html` sitting untracked made the
|
|
47
|
+
* command permanently unusable in that clone.
|
|
48
|
+
*
|
|
49
|
+
* Now the refusal only ever names tracked changes, so `git stash` — the cure it prints — always resolves
|
|
50
|
+
* it, by construction.
|
|
51
|
+
*
|
|
52
|
+
* ─── WHY `--untracked-files=no` AND `ls-files -z` RATHER THAN PARSING `??` ─────────────────────────────
|
|
53
|
+
* The decision half asks git the tracked-only question directly, so no line of porcelain output is ever
|
|
54
|
+
* parsed to make it. The reporting half asks `ls-files --others --exclude-standard -z`, which emits bare
|
|
55
|
+
* NUL-separated paths — no `?? ` prefix to strip and, crucially, no quoting: porcelain v1 wraps paths
|
|
56
|
+
* containing special characters in quotes with C-style escapes, so a hand-parser would hand back a
|
|
57
|
+
* mangled name for exactly the paths a human most needs to read correctly.
|
|
58
|
+
*/
|
|
59
|
+
let WorkingTreeGate = class WorkingTreeGate {
|
|
60
|
+
/**
|
|
61
|
+
* Throw when tracked files are modified or staged; otherwise hand back the untracked files that were
|
|
62
|
+
* deliberately allowed through, for the caller to mention.
|
|
63
|
+
*/
|
|
64
|
+
assertNoTrackedChanges(repoRoot) {
|
|
65
|
+
const tracked = this.git(repoRoot, ['status', '--porcelain', '--untracked-files=no']);
|
|
66
|
+
if (tracked !== null && tracked.trim() !== '') {
|
|
67
|
+
throw new rules_config_1.CliExitError(1, `${SEP}❌ Uncommitted changes to tracked files\n${SEP}\n`
|
|
68
|
+
+ 'Going to main would carry them along. Commit them on this branch, or `git stash` them\n'
|
|
69
|
+
+ '(everything listed below is tracked, which is exactly what a plain stash takes), then\n'
|
|
70
|
+
+ 're-run. The webpieces tooling never commits your work for you.\n\n'
|
|
71
|
+
+ `${tracked}`);
|
|
72
|
+
}
|
|
73
|
+
return this.untrackedFiles(repoRoot);
|
|
74
|
+
}
|
|
75
|
+
untrackedFiles(repoRoot) {
|
|
76
|
+
const output = this.git(repoRoot, ['ls-files', '--others', '--exclude-standard', '-z']);
|
|
77
|
+
if (output === null)
|
|
78
|
+
return new UntrackedFiles([]);
|
|
79
|
+
return new UntrackedFiles(output.split('\0').filter((path) => path !== ''));
|
|
80
|
+
}
|
|
81
|
+
/** Captured git output, or null when git could not be run or exited non-zero. */
|
|
82
|
+
git(repoRoot, args) {
|
|
83
|
+
const result = (0, child_process_1.spawnSync)('git', ['-C', repoRoot, ...args], {
|
|
84
|
+
encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
|
|
85
|
+
});
|
|
86
|
+
if (result.error !== undefined || result.status !== 0)
|
|
87
|
+
return null;
|
|
88
|
+
return result.stdout;
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
exports.WorkingTreeGate = WorkingTreeGate;
|
|
92
|
+
exports.WorkingTreeGate = WorkingTreeGate = tslib_1.__decorate([
|
|
93
|
+
(0, inversify_1.injectable)(inversify_1.bindingScopeValues.Singleton)
|
|
94
|
+
], WorkingTreeGate);
|
|
95
|
+
//# sourceMappingURL=working-tree-gate.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"working-tree-gate.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/pr-gate/src/scripts/commands/working-tree-gate.ts"],"names":[],"mappings":";;;;AAAA,iDAA0C;AAC1C,yCAA2D;AAC3D,0DAAuD;AAEvD,MAAM,GAAG,GAAG,0DAA0D,CAAC;AAEvE;;;GAGG;AACH,MAAa,cAAc;IACF;IAArB,YAAqB,KAAwB;QAAxB,UAAK,GAAL,KAAK,CAAmB;IAAG,CAAC;IAEjD,OAAO;QACH,OAAO,IAAI,CAAC,KAAK,CAAC,MAAM,KAAK,CAAC,CAAC;IACnC,CAAC;IAED,kEAAkE;IAClE,MAAM;QACF,IAAI,IAAI,CAAC,OAAO,EAAE;YAAE,OAAO,EAAE,CAAC;QAC9B,MAAM,IAAI,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,IAAY,EAAU,EAAE,CAAC,KAAK,IAAI,IAAI,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;QAC9E,OAAO,0FAA0F;cAC3F,0CAA0C;cAC1C,IAAI,CAAC;IACf,CAAC;CACJ;AAfD,wCAeC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAEI,IAAM,eAAe,GAArB,MAAM,eAAe;IACxB;;;OAGG;IACH,sBAAsB,CAAC,QAAgB;QACnC,MAAM,OAAO,GAAG,IAAI,CAAC,GAAG,CAAC,QAAQ,EAAE,CAAC,QAAQ,EAAE,aAAa,EAAE,sBAAsB,CAAC,CAAC,CAAC;QACtF,IAAI,OAAO,KAAK,IAAI,IAAI,OAAO,CAAC,IAAI,EAAE,KAAK,EAAE,EAAE,CAAC;YAC5C,MAAM,IAAI,2BAAY,CAAC,CAAC,EACpB,GAAG,GAAG,2CAA2C,GAAG,IAAI;kBACtD,yFAAyF;kBACzF,yFAAyF;kBACzF,oEAAoE;kBACpE,GAAG,OAAO,EAAE,CAAC,CAAC;QACxB,CAAC;QACD,OAAO,IAAI,CAAC,cAAc,CAAC,QAAQ,CAAC,CAAC;IACzC,CAAC;IAEO,cAAc,CAAC,QAAgB;QACnC,MAAM,MAAM,GAAG,IAAI,CAAC,GAAG,CAAC,QAAQ,EAAE,CAAC,UAAU,EAAE,UAAU,EAAE,oBAAoB,EAAE,IAAI,CAAC,CAAC,CAAC;QACxF,IAAI,MAAM,KAAK,IAAI;YAAE,OAAO,IAAI,cAAc,CAAC,EAAE,CAAC,CAAC;QACnD,OAAO,IAAI,cAAc,CAAC,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,MAAM,CAAC,CAAC,IAAY,EAAW,EAAE,CAAC,IAAI,KAAK,EAAE,CAAC,CAAC,CAAC;IACjG,CAAC;IAED,iFAAiF;IACzE,GAAG,CAAC,QAAgB,EAAE,IAAc;QACxC,MAAM,MAAM,GAAG,IAAA,yBAAS,EAAC,KAAK,EAAE,CAAC,IAAI,EAAE,QAAQ,EAAE,GAAG,IAAI,CAAC,EAAE;YACvD,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,QAAQ,CAAC;SACxD,CAAC,CAAC;QACH,IAAI,MAAM,CAAC,KAAK,KAAK,SAAS,IAAI,MAAM,CAAC,MAAM,KAAK,CAAC;YAAE,OAAO,IAAI,CAAC;QACnE,OAAO,MAAM,CAAC,MAAM,CAAC;IACzB,CAAC;CACJ,CAAA;AAhCY,0CAAe;0BAAf,eAAe;IAD3B,IAAA,sBAAU,EAAC,8BAAkB,CAAC,SAAS,CAAC;GAC5B,eAAe,CAgC3B","sourcesContent":["import { spawnSync } from 'child_process';\nimport { injectable, bindingScopeValues } from 'inversify';\nimport { CliExitError } from '@webpieces/rules-config';\n\nconst SEP = '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\\n';\n\n/**\n * The untracked files a branch switch is about to leave exactly where they are — carried out of the gate\n * so the caller can SAY so, rather than surprise anyone with files that appear to have followed them.\n */\nexport class UntrackedFiles {\n constructor(readonly paths: readonly string[]) {}\n\n isEmpty(): boolean {\n return this.paths.length === 0;\n }\n\n /** The human-facing block, or '' when there is nothing to say. */\n render(): string {\n if (this.isEmpty()) return '';\n const list = this.paths.map((path: string): string => ` ${path}\\n`).join('');\n return 'These untracked files are not on any branch, so the checkout leaves them exactly where\\n'\n + 'they are — nothing about them changed:\\n'\n + list;\n }\n}\n\n/**\n * The clean-tree gate for `wp-checkout-clean-main`: refuse when TRACKED work would ride onto main, and\n * only then.\n *\n * ─── WHY UNTRACKED FILES ARE NOT A REASON TO REFUSE ───────────────────────────────────────────────────\n * The hazard the gate exists for is real but narrow: `git checkout` carries uncommitted changes to\n * TRACKED files across the switch, and main is the one place nobody wants to discover their work. An\n * untracked file has none of that. It is not on any branch, the switch does not move it, and no branch\n * can end up holding it — so refusing on one blocks a routine, safe operation.\n *\n * Worse, it was a refusal the user could not clear without deleting their file. The message prescribed\n * \"commit them on this branch, or stash them\", and a plain `git stash` does NOT take untracked files\n * (that needs `-u`) — so a user who did exactly what the error said got the identical refusal back. That\n * is the shape this repo treats as a defect in its own right: a cure that does not resolve the state it\n * is prescribed for. Reported from the field, where a generated `design.html` sitting untracked made the\n * command permanently unusable in that clone.\n *\n * Now the refusal only ever names tracked changes, so `git stash` — the cure it prints — always resolves\n * it, by construction.\n *\n * ─── WHY `--untracked-files=no` AND `ls-files -z` RATHER THAN PARSING `??` ─────────────────────────────\n * The decision half asks git the tracked-only question directly, so no line of porcelain output is ever\n * parsed to make it. The reporting half asks `ls-files --others --exclude-standard -z`, which emits bare\n * NUL-separated paths — no `?? ` prefix to strip and, crucially, no quoting: porcelain v1 wraps paths\n * containing special characters in quotes with C-style escapes, so a hand-parser would hand back a\n * mangled name for exactly the paths a human most needs to read correctly.\n */\n@injectable(bindingScopeValues.Singleton)\nexport class WorkingTreeGate {\n /**\n * Throw when tracked files are modified or staged; otherwise hand back the untracked files that were\n * deliberately allowed through, for the caller to mention.\n */\n assertNoTrackedChanges(repoRoot: string): UntrackedFiles {\n const tracked = this.git(repoRoot, ['status', '--porcelain', '--untracked-files=no']);\n if (tracked !== null && tracked.trim() !== '') {\n throw new CliExitError(1,\n `${SEP}❌ Uncommitted changes to tracked files\\n${SEP}\\n`\n + 'Going to main would carry them along. Commit them on this branch, or `git stash` them\\n'\n + '(everything listed below is tracked, which is exactly what a plain stash takes), then\\n'\n + 're-run. The webpieces tooling never commits your work for you.\\n\\n'\n + `${tracked}`);\n }\n return this.untrackedFiles(repoRoot);\n }\n\n private untrackedFiles(repoRoot: string): UntrackedFiles {\n const output = this.git(repoRoot, ['ls-files', '--others', '--exclude-standard', '-z']);\n if (output === null) return new UntrackedFiles([]);\n return new UntrackedFiles(output.split('\\0').filter((path: string): boolean => path !== ''));\n }\n\n /** Captured git output, or null when git could not be run or exited non-zero. */\n private git(repoRoot: string, args: string[]): string | null {\n const result = spawnSync('git', ['-C', repoRoot, ...args], {\n encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],\n });\n if (result.error !== undefined || result.status !== 0) return null;\n return result.stdout;\n }\n}\n"]}
|