@webpieces/pr-gate 0.4.679 → 0.4.680
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 +10 -8
- package/src/scripts/commands/checkout-clean-main-command.js +23 -27
- package/src/scripts/commands/checkout-clean-main-command.js.map +1 -1
- package/src/scripts/commands/main-checkout.d.ts +75 -0
- package/src/scripts/commands/main-checkout.js +198 -0
- package/src/scripts/commands/main-checkout.js.map +1 -0
- package/src/scripts/commands/working-tree-gate.d.ts +6 -0
- package/src/scripts/commands/working-tree-gate.js +6 -0
- package/src/scripts/commands/working-tree-gate.js.map +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webpieces/pr-gate",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.680",
|
|
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.680",
|
|
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 { MainCheckout } from './main-checkout';
|
|
3
4
|
import { WorkingTreeGate } from './working-tree-gate';
|
|
4
5
|
/**
|
|
5
6
|
* `wp-checkout-clean-main` — go to main, get main, and take out the trash. ONE command, because the three
|
|
@@ -46,7 +47,8 @@ export declare class CheckoutCleanMainCommand {
|
|
|
46
47
|
private readonly cleanupCommand;
|
|
47
48
|
private readonly orphanDirSweeper;
|
|
48
49
|
private readonly workingTreeGate;
|
|
49
|
-
|
|
50
|
+
private readonly mainCheckout;
|
|
51
|
+
constructor(repoRootFinder: RepoRootFinder, cleanupCommand: CleanupCommand, orphanDirSweeper: OrphanDirSweeper, workingTreeGate: WorkingTreeGate, mainCheckout: MainCheckout);
|
|
50
52
|
run(): Promise<void>;
|
|
51
53
|
/**
|
|
52
54
|
* A linked worktree has no main to check out — `git checkout main` FATALS there with "main is already
|
|
@@ -60,20 +62,20 @@ export declare class CheckoutCleanMainCommand {
|
|
|
60
62
|
* Say which untracked files were let through, AFTER the move — so nobody has to wonder whether the
|
|
61
63
|
* files still sitting in their tree came along for the ride. Printed rather than refused on: see
|
|
62
64
|
* `WorkingTreeGate` for why an untracked file is not the hazard a tracked one is.
|
|
65
|
+
*
|
|
66
|
+
* Silent when the checkout had to stash: those files are NOT sitting in the tree any more, and
|
|
67
|
+
* "nothing about them changed" would be a lie. `reportStashed` speaks for that case instead.
|
|
63
68
|
*/
|
|
64
69
|
private reportUntracked;
|
|
65
70
|
/**
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
* thinking. A refusal to fast-forward is a real condition a human should see and decide about.
|
|
71
|
+
* The stash banner goes LAST, on purpose. It is the one thing in this command's output a reader
|
|
72
|
+
* must not miss — their files left the working tree — and the cleanup and sweep that follow the
|
|
73
|
+
* checkout print enough to scroll it away if it were emitted where it happened.
|
|
70
74
|
*/
|
|
71
|
-
private
|
|
75
|
+
private reportStashed;
|
|
72
76
|
/**
|
|
73
77
|
* Sweep, then print. Never throws: the checkout and pull above already succeeded, and a tidier is not
|
|
74
78
|
* permitted to turn somebody's completed sync into a failed command.
|
|
75
79
|
*/
|
|
76
80
|
private sweep;
|
|
77
|
-
/** git with its output going straight to the terminal — for the commands whose progress is the point. */
|
|
78
|
-
private run_;
|
|
79
81
|
}
|
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.CheckoutCleanMainCommand = void 0;
|
|
4
4
|
const tslib_1 = require("tslib");
|
|
5
|
-
const child_process_1 = require("child_process");
|
|
6
5
|
const inversify_1 = require("inversify");
|
|
7
6
|
const rules_config_1 = require("@webpieces/rules-config");
|
|
8
7
|
const cleanup_command_1 = require("./cleanup-command");
|
|
8
|
+
const main_checkout_1 = require("./main-checkout");
|
|
9
9
|
const working_tree_gate_1 = require("./working-tree-gate");
|
|
10
10
|
const SEP = '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n';
|
|
11
11
|
/**
|
|
@@ -53,11 +53,13 @@ let CheckoutCleanMainCommand = class CheckoutCleanMainCommand {
|
|
|
53
53
|
cleanupCommand;
|
|
54
54
|
orphanDirSweeper;
|
|
55
55
|
workingTreeGate;
|
|
56
|
-
|
|
56
|
+
mainCheckout;
|
|
57
|
+
constructor(repoRootFinder, cleanupCommand, orphanDirSweeper, workingTreeGate, mainCheckout) {
|
|
57
58
|
this.repoRootFinder = repoRootFinder;
|
|
58
59
|
this.cleanupCommand = cleanupCommand;
|
|
59
60
|
this.orphanDirSweeper = orphanDirSweeper;
|
|
60
61
|
this.workingTreeGate = workingTreeGate;
|
|
62
|
+
this.mainCheckout = mainCheckout;
|
|
61
63
|
}
|
|
62
64
|
async run() {
|
|
63
65
|
const repoRoot = this.repoRootFinder.resolveRepoRoot(process.cwd());
|
|
@@ -67,10 +69,11 @@ let CheckoutCleanMainCommand = class CheckoutCleanMainCommand {
|
|
|
67
69
|
return;
|
|
68
70
|
}
|
|
69
71
|
const untracked = this.workingTreeGate.assertNoTrackedChanges(repoRoot);
|
|
70
|
-
this.goToMain(repoRoot);
|
|
71
|
-
this.reportUntracked(untracked);
|
|
72
|
+
const stashed = this.mainCheckout.goToMain(repoRoot, untracked);
|
|
73
|
+
this.reportUntracked(untracked, stashed);
|
|
72
74
|
await this.cleanupCommand.run();
|
|
73
75
|
this.sweep(repoRoot);
|
|
76
|
+
this.reportStashed(stashed);
|
|
74
77
|
}
|
|
75
78
|
/**
|
|
76
79
|
* A linked worktree has no main to check out — `git checkout main` FATALS there with "main is already
|
|
@@ -95,31 +98,28 @@ let CheckoutCleanMainCommand = class CheckoutCleanMainCommand {
|
|
|
95
98
|
* Say which untracked files were let through, AFTER the move — so nobody has to wonder whether the
|
|
96
99
|
* files still sitting in their tree came along for the ride. Printed rather than refused on: see
|
|
97
100
|
* `WorkingTreeGate` for why an untracked file is not the hazard a tracked one is.
|
|
101
|
+
*
|
|
102
|
+
* Silent when the checkout had to stash: those files are NOT sitting in the tree any more, and
|
|
103
|
+
* "nothing about them changed" would be a lie. `reportStashed` speaks for that case instead.
|
|
98
104
|
*/
|
|
99
|
-
reportUntracked(untracked) {
|
|
105
|
+
reportUntracked(untracked, stashed) {
|
|
106
|
+
if (!stashed.isEmpty())
|
|
107
|
+
return;
|
|
100
108
|
const rendered = untracked.render();
|
|
101
109
|
if (rendered === '')
|
|
102
110
|
return;
|
|
103
111
|
process.stdout.write(`\n${rendered}`);
|
|
104
112
|
}
|
|
105
113
|
/**
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
* thinking. A refusal to fast-forward is a real condition a human should see and decide about.
|
|
114
|
+
* The stash banner goes LAST, on purpose. It is the one thing in this command's output a reader
|
|
115
|
+
* must not miss — their files left the working tree — and the cleanup and sweep that follow the
|
|
116
|
+
* checkout print enough to scroll it away if it were emitted where it happened.
|
|
110
117
|
*/
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
if (
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
}
|
|
117
|
-
if (this.run_(repoRoot, ['pull', '--ff-only', 'origin', 'main']) !== 0) {
|
|
118
|
-
throw new rules_config_1.CliExitError(1, `${SEP}❌ Local main could not fast-forward\n${SEP}\n`
|
|
119
|
-
+ 'Local main has commits that origin/main does not, so it is not a clean copy of the\n'
|
|
120
|
-
+ 'remote. Nothing was reset — those commits may be the only copy. Inspect them with\n'
|
|
121
|
-
+ '`git log origin/main..main` and decide what they are before going further.\n');
|
|
122
|
-
}
|
|
118
|
+
reportStashed(stashed) {
|
|
119
|
+
const rendered = stashed.render();
|
|
120
|
+
if (rendered === '')
|
|
121
|
+
return;
|
|
122
|
+
process.stdout.write(`\n${rendered}`);
|
|
123
123
|
}
|
|
124
124
|
/**
|
|
125
125
|
* Sweep, then print. Never throws: the checkout and pull above already succeeded, and a tidier is not
|
|
@@ -132,11 +132,6 @@ let CheckoutCleanMainCommand = class CheckoutCleanMainCommand {
|
|
|
132
132
|
return;
|
|
133
133
|
process.stdout.write(`\n${rendered}`);
|
|
134
134
|
}
|
|
135
|
-
/** git with its output going straight to the terminal — for the commands whose progress is the point. */
|
|
136
|
-
run_(repoRoot, args) {
|
|
137
|
-
const result = (0, child_process_1.spawnSync)('git', ['-C', repoRoot, ...args], { encoding: 'utf8', stdio: 'inherit' });
|
|
138
|
-
return result.status === null ? 1 : result.status;
|
|
139
|
-
}
|
|
140
135
|
};
|
|
141
136
|
exports.CheckoutCleanMainCommand = CheckoutCleanMainCommand;
|
|
142
137
|
exports.CheckoutCleanMainCommand = CheckoutCleanMainCommand = tslib_1.__decorate([
|
|
@@ -144,6 +139,7 @@ exports.CheckoutCleanMainCommand = CheckoutCleanMainCommand = tslib_1.__decorate
|
|
|
144
139
|
tslib_1.__metadata("design:paramtypes", [rules_config_1.RepoRootFinder,
|
|
145
140
|
cleanup_command_1.CleanupCommand,
|
|
146
141
|
rules_config_1.OrphanDirSweeper,
|
|
147
|
-
working_tree_gate_1.WorkingTreeGate
|
|
142
|
+
working_tree_gate_1.WorkingTreeGate,
|
|
143
|
+
main_checkout_1.MainCheckout])
|
|
148
144
|
], CheckoutCleanMainCommand);
|
|
149
145
|
//# 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';\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"]}
|
|
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 { injectable, bindingScopeValues } from 'inversify';\nimport {\n OrphanDirSweeper,\n RepoRootFinder,\n dotWebpieces,\n} from '@webpieces/rules-config';\n\nimport { CleanupCommand } from './cleanup-command';\nimport { MainCheckout, StashedFiles } from './main-checkout';\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 private readonly mainCheckout: MainCheckout,\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 const stashed = this.mainCheckout.goToMain(repoRoot, untracked);\n this.reportUntracked(untracked, stashed);\n await this.cleanupCommand.run();\n this.sweep(repoRoot);\n this.reportStashed(stashed);\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 * Silent when the checkout had to stash: those files are NOT sitting in the tree any more, and\n * \"nothing about them changed\" would be a lie. `reportStashed` speaks for that case instead.\n */\n private reportUntracked(untracked: UntrackedFiles, stashed: StashedFiles): void {\n if (!stashed.isEmpty()) return;\n const rendered = untracked.render();\n if (rendered === '') return;\n process.stdout.write(`\\n${rendered}`);\n }\n\n /**\n * The stash banner goes LAST, on purpose. It is the one thing in this command's output a reader\n * must not miss — their files left the working tree — and the cleanup and sweep that follow the\n * checkout print enough to scroll it away if it were emitted where it happened.\n */\n private reportStashed(stashed: StashedFiles): void {\n const rendered = stashed.render();\n if (rendered === '') return;\n process.stdout.write(`\\n${rendered}`);\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"]}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { UntrackedFiles } from './working-tree-gate';
|
|
2
|
+
/** One git invocation's exit status and the output it produced, for a caller that must decide on it. */
|
|
3
|
+
export declare class GitRun {
|
|
4
|
+
readonly status: number;
|
|
5
|
+
readonly output: string;
|
|
6
|
+
constructor(status: number, output: string);
|
|
7
|
+
succeeded(): boolean;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* The files `git stash -u` took so the checkout could proceed — carried out so the caller can SHOUT
|
|
11
|
+
* about them. Empty is the overwhelmingly common case: nothing collided, nothing was stashed.
|
|
12
|
+
*
|
|
13
|
+
* The message is ALL CAPS and banner-wrapped deliberately. This is the one path in the command where
|
|
14
|
+
* files that were sitting in the working tree are no longer sitting in the working tree, and a reader
|
|
15
|
+
* who skims past that line loses track of their own work. Everything else this command prints is
|
|
16
|
+
* routine; this is not.
|
|
17
|
+
*/
|
|
18
|
+
export declare class StashedFiles {
|
|
19
|
+
readonly paths: readonly string[];
|
|
20
|
+
constructor(paths: readonly string[]);
|
|
21
|
+
isEmpty(): boolean;
|
|
22
|
+
/** The human-facing banner, or '' when nothing was stashed. */
|
|
23
|
+
render(): string;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Get onto a current main, whatever the working tree is holding.
|
|
27
|
+
*
|
|
28
|
+
* ─── WHY A COLLISION STASHES RATHER THAN REFUSES ──────────────────────────────────────────────────
|
|
29
|
+
* `WorkingTreeGate` deliberately lets untracked files through: they are on no branch, so the switch
|
|
30
|
+
* does not move them. That is true about the FILES and not true about the CHECKOUT — git aborts when
|
|
31
|
+
* an untracked file sits at a path the destination branch TRACKS ("The following untracked working
|
|
32
|
+
* tree files would be overwritten by checkout"). Letting untracked files reach the checkout is
|
|
33
|
+
* exactly what makes that abort reachable, and the field case that produced it is the common one: a
|
|
34
|
+
* generated `design.html`, untracked in a working clone because it is regenerated, and committed on
|
|
35
|
+
* main.
|
|
36
|
+
*
|
|
37
|
+
* A refusal there is a DEAD END for the one caller that most needs the command to work — an agent
|
|
38
|
+
* mid-upgrade of webpieces, unable to use its tools until it reaches a clean main, with nobody
|
|
39
|
+
* watching to move the file for it. So the collision is resolved rather than reported: stash with
|
|
40
|
+
* `-u`, retry, and then say so loudly enough that no human loses track of the files. Losing a minute
|
|
41
|
+
* to a `git stash pop` beats a state nothing can get out of.
|
|
42
|
+
*
|
|
43
|
+
* ─── WHY THE CHECKOUT'S OUTPUT IS CAPTURED AND RE-PRINTED ─────────────────────────────────────────
|
|
44
|
+
* The decision above is made on git's own refusal text, which means the checkout cannot run on
|
|
45
|
+
* `stdio: 'inherit'` — inherited output is never seen by this process. It is piped and echoed back
|
|
46
|
+
* instead, so the reader still gets every byte git wrote. `fetch` and `pull` stay inherited: nothing
|
|
47
|
+
* is decided from their text, and they are the two that render live progress, which a pipe flattens.
|
|
48
|
+
*/
|
|
49
|
+
export declare class MainCheckout {
|
|
50
|
+
/**
|
|
51
|
+
* Fetch, check out main, fast-forward it. Returns the files that had to be stashed to get there —
|
|
52
|
+
* empty in the ordinary case.
|
|
53
|
+
*
|
|
54
|
+
* `--ff-only` rather than a `reset --hard`, deliberately: a developer or an agent that
|
|
55
|
+
* accidentally committed to local main would have that work silently destroyed by a reset, and
|
|
56
|
+
* the whole point of this command is that it is safe to run without thinking. A refusal to
|
|
57
|
+
* fast-forward is a real condition a human should see and decide about.
|
|
58
|
+
*/
|
|
59
|
+
goToMain(repoRoot: string, untracked: UntrackedFiles): StashedFiles;
|
|
60
|
+
private checkoutMain;
|
|
61
|
+
/**
|
|
62
|
+
* Only the collision git NAMES, and only when there is something a stash could actually take.
|
|
63
|
+
* Untracked files with any other refusal, or a refusal with an empty untracked list, is a
|
|
64
|
+
* different problem and stashing would be a guess.
|
|
65
|
+
*/
|
|
66
|
+
private isCollision;
|
|
67
|
+
private checkoutFailed;
|
|
68
|
+
/** Appended to a failure that happens AFTER the stash, so the stash is never silently forgotten. */
|
|
69
|
+
private alsoStashed;
|
|
70
|
+
private list;
|
|
71
|
+
/** git piped, then echoed back verbatim — for the one command whose text is decided on. */
|
|
72
|
+
private captureAndEcho;
|
|
73
|
+
/** git with its output going straight to the terminal — for the commands whose progress is the point. */
|
|
74
|
+
private passThrough;
|
|
75
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.MainCheckout = exports.StashedFiles = exports.GitRun = 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
|
+
* git's own words for the one checkout failure this class is allowed to react to. Matched on git's
|
|
11
|
+
* TEXT rather than re-derived from the untracked list on purpose: git's rules here cover
|
|
12
|
+
* directory-vs-file collisions, sparse checkouts and case-insensitive filesystems, and any
|
|
13
|
+
* re-derivation of them drifts from the git actually installed. git's refusal is the authority.
|
|
14
|
+
*/
|
|
15
|
+
const WOULD_BE_OVERWRITTEN = 'untracked working tree files would be overwritten';
|
|
16
|
+
/** One git invocation's exit status and the output it produced, for a caller that must decide on it. */
|
|
17
|
+
class GitRun {
|
|
18
|
+
status;
|
|
19
|
+
output;
|
|
20
|
+
constructor(status, output) {
|
|
21
|
+
this.status = status;
|
|
22
|
+
this.output = output;
|
|
23
|
+
}
|
|
24
|
+
succeeded() {
|
|
25
|
+
return this.status === 0;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
exports.GitRun = GitRun;
|
|
29
|
+
/**
|
|
30
|
+
* The files `git stash -u` took so the checkout could proceed — carried out so the caller can SHOUT
|
|
31
|
+
* about them. Empty is the overwhelmingly common case: nothing collided, nothing was stashed.
|
|
32
|
+
*
|
|
33
|
+
* The message is ALL CAPS and banner-wrapped deliberately. This is the one path in the command where
|
|
34
|
+
* files that were sitting in the working tree are no longer sitting in the working tree, and a reader
|
|
35
|
+
* who skims past that line loses track of their own work. Everything else this command prints is
|
|
36
|
+
* routine; this is not.
|
|
37
|
+
*/
|
|
38
|
+
class StashedFiles {
|
|
39
|
+
paths;
|
|
40
|
+
constructor(paths) {
|
|
41
|
+
this.paths = paths;
|
|
42
|
+
}
|
|
43
|
+
isEmpty() {
|
|
44
|
+
return this.paths.length === 0;
|
|
45
|
+
}
|
|
46
|
+
/** The human-facing banner, or '' when nothing was stashed. */
|
|
47
|
+
render() {
|
|
48
|
+
if (this.isEmpty())
|
|
49
|
+
return '';
|
|
50
|
+
const list = this.paths.map((path) => ` ${path}\n`).join('');
|
|
51
|
+
const quoted = this.paths.map((path) => `'${path}'`).join(' ');
|
|
52
|
+
return `${SEP}⚠️ UNTRACKED FILES WERE STASHED BECAUSE THEY CONFLICTED\n${SEP}\n`
|
|
53
|
+
+ 'GIT REFUSED TO CHECK OUT MAIN: AN UNTRACKED FILE HERE SITS AT A PATH MAIN TRACKS, AND\n'
|
|
54
|
+
+ 'THE CHECKOUT WOULD HAVE OVERWRITTEN IT. `git stash -u` TAKES EVERY UNTRACKED FILE, NOT\n'
|
|
55
|
+
+ 'ONLY THE ONE THAT COLLIDED, SO ALL OF THESE LEFT THE WORKING TREE. THE CHECKOUT WAS THEN\n'
|
|
56
|
+
+ 'RETRIED AND YOU ARE NOW ON A CLEAN MAIN. NOTHING WAS DELETED — YOUR VERSION OF EVERY\n'
|
|
57
|
+
+ 'FILE BELOW IS IN THE STASH:\n\n'
|
|
58
|
+
+ list
|
|
59
|
+
+ '\nGET THEM BACK WITH:\n\n'
|
|
60
|
+
+ ' git stash pop\n\n'
|
|
61
|
+
+ 'EXPECT THAT POP TO REFUSE THE FIRST TIME. IT LANDS ON THE NEW MAIN, WHICH IS EXACTLY\n'
|
|
62
|
+
+ 'WHERE A COMMITTED COPY OF THE COLLIDING PATH NOW SITS — THAT COLLISION IS WHY ANY OF\n'
|
|
63
|
+
+ 'THIS HAPPENED — SO GIT SAYS "already exists, no checkout" AND KEEPS THE STASH. TO TAKE\n'
|
|
64
|
+
+ 'YOUR VERSIONS BACK, DELETE MAIN\'S COPIES AND POP ONTO THE HOLE:\n\n'
|
|
65
|
+
+ ` rm -f ${quoted} && git stash pop\n\n`
|
|
66
|
+
+ 'NOTHING IS DISCARDED WHILE THE POP HAS NOT SUCCEEDED — `git stash list` STILL SHOWS THE\n'
|
|
67
|
+
+ 'ENTRY, AS MANY TRIES AS YOU NEED. IF THE FILE IS GENERATED, REGENERATING IT ON THE NEW\n'
|
|
68
|
+
+ 'MAIN IS USUALLY BETTER THAN RESTORING A COPY BUILT FROM THE OLD ONE.\n\n'
|
|
69
|
+
+ 'WHY STASHED AND NOT REFUSED: REACHING A CLEAN MAIN HAS TO STAY POSSIBLE. A REFUSAL HERE\n'
|
|
70
|
+
+ 'DEADLOCKS ANYONE — HUMAN OR AGENT — WHOSE NEXT STEP IS GATED ON BEING ON MAIN, AND AN\n'
|
|
71
|
+
+ `AGENT MID-UPGRADE CANNOT ASK FOR HELP.\n${SEP}`;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
exports.StashedFiles = StashedFiles;
|
|
75
|
+
/**
|
|
76
|
+
* Get onto a current main, whatever the working tree is holding.
|
|
77
|
+
*
|
|
78
|
+
* ─── WHY A COLLISION STASHES RATHER THAN REFUSES ──────────────────────────────────────────────────
|
|
79
|
+
* `WorkingTreeGate` deliberately lets untracked files through: they are on no branch, so the switch
|
|
80
|
+
* does not move them. That is true about the FILES and not true about the CHECKOUT — git aborts when
|
|
81
|
+
* an untracked file sits at a path the destination branch TRACKS ("The following untracked working
|
|
82
|
+
* tree files would be overwritten by checkout"). Letting untracked files reach the checkout is
|
|
83
|
+
* exactly what makes that abort reachable, and the field case that produced it is the common one: a
|
|
84
|
+
* generated `design.html`, untracked in a working clone because it is regenerated, and committed on
|
|
85
|
+
* main.
|
|
86
|
+
*
|
|
87
|
+
* A refusal there is a DEAD END for the one caller that most needs the command to work — an agent
|
|
88
|
+
* mid-upgrade of webpieces, unable to use its tools until it reaches a clean main, with nobody
|
|
89
|
+
* watching to move the file for it. So the collision is resolved rather than reported: stash with
|
|
90
|
+
* `-u`, retry, and then say so loudly enough that no human loses track of the files. Losing a minute
|
|
91
|
+
* to a `git stash pop` beats a state nothing can get out of.
|
|
92
|
+
*
|
|
93
|
+
* ─── WHY THE CHECKOUT'S OUTPUT IS CAPTURED AND RE-PRINTED ─────────────────────────────────────────
|
|
94
|
+
* The decision above is made on git's own refusal text, which means the checkout cannot run on
|
|
95
|
+
* `stdio: 'inherit'` — inherited output is never seen by this process. It is piped and echoed back
|
|
96
|
+
* instead, so the reader still gets every byte git wrote. `fetch` and `pull` stay inherited: nothing
|
|
97
|
+
* is decided from their text, and they are the two that render live progress, which a pipe flattens.
|
|
98
|
+
*/
|
|
99
|
+
let MainCheckout = class MainCheckout {
|
|
100
|
+
/**
|
|
101
|
+
* Fetch, check out main, fast-forward it. Returns the files that had to be stashed to get there —
|
|
102
|
+
* empty in the ordinary case.
|
|
103
|
+
*
|
|
104
|
+
* `--ff-only` rather than a `reset --hard`, deliberately: a developer or an agent that
|
|
105
|
+
* accidentally committed to local main would have that work silently destroyed by a reset, and
|
|
106
|
+
* the whole point of this command is that it is safe to run without thinking. A refusal to
|
|
107
|
+
* fast-forward is a real condition a human should see and decide about.
|
|
108
|
+
*/
|
|
109
|
+
goToMain(repoRoot, untracked) {
|
|
110
|
+
this.passThrough(repoRoot, ['fetch', 'origin', 'main']);
|
|
111
|
+
const stashed = this.checkoutMain(repoRoot, untracked);
|
|
112
|
+
if (this.passThrough(repoRoot, ['pull', '--ff-only', 'origin', 'main']) !== 0) {
|
|
113
|
+
throw new rules_config_1.CliExitError(1, `${SEP}❌ Local main could not fast-forward\n${SEP}\n`
|
|
114
|
+
+ 'Local main has commits that origin/main does not, so it is not a clean copy of the\n'
|
|
115
|
+
+ 'remote. Nothing was reset — those commits may be the only copy. Inspect them with\n'
|
|
116
|
+
+ '`git log origin/main..main` and decide what they are before going further.\n'
|
|
117
|
+
+ this.alsoStashed(stashed));
|
|
118
|
+
}
|
|
119
|
+
return stashed;
|
|
120
|
+
}
|
|
121
|
+
checkoutMain(repoRoot, untracked) {
|
|
122
|
+
const first = this.captureAndEcho(repoRoot, ['checkout', 'main']);
|
|
123
|
+
if (first.succeeded())
|
|
124
|
+
return new StashedFiles([]);
|
|
125
|
+
if (!this.isCollision(first, untracked))
|
|
126
|
+
throw this.checkoutFailed(first);
|
|
127
|
+
const stash = this.captureAndEcho(repoRoot, ['stash', 'push', '-u',
|
|
128
|
+
'-m', 'wp-checkout-clean-main: untracked files that collided with main']);
|
|
129
|
+
if (!stash.succeeded()) {
|
|
130
|
+
throw new rules_config_1.CliExitError(1, `${SEP}❌ Could not check out main, and could not stash\n${SEP}\n`
|
|
131
|
+
+ 'git refused the checkout because untracked files in this tree sit at paths main\n'
|
|
132
|
+
+ 'tracks, and `git stash -u` then failed too, so NOTHING WAS STASHED — the tree is\n'
|
|
133
|
+
+ 'exactly as you left it and you are still on your branch. Both git messages are\n'
|
|
134
|
+
+ 'above. Move or delete these files yourself, then re-run:\n\n'
|
|
135
|
+
+ this.list(untracked.paths));
|
|
136
|
+
}
|
|
137
|
+
const second = this.captureAndEcho(repoRoot, ['checkout', 'main']);
|
|
138
|
+
const stashed = new StashedFiles(untracked.paths);
|
|
139
|
+
if (!second.succeeded()) {
|
|
140
|
+
throw new rules_config_1.CliExitError(1, `${SEP}❌ Could not check out main even after stashing\n${SEP}\n`
|
|
141
|
+
+ 'The untracked files below WERE stashed — they are no longer in the working tree —\n'
|
|
142
|
+
+ 'and git still refused the checkout, so something else is blocking it. Its message is\n'
|
|
143
|
+
+ 'above. You are still on your branch. Recover the files with `git stash pop`, and\n'
|
|
144
|
+
+ 'read git\'s reason before re-running:\n\n'
|
|
145
|
+
+ this.list(stashed.paths));
|
|
146
|
+
}
|
|
147
|
+
return stashed;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Only the collision git NAMES, and only when there is something a stash could actually take.
|
|
151
|
+
* Untracked files with any other refusal, or a refusal with an empty untracked list, is a
|
|
152
|
+
* different problem and stashing would be a guess.
|
|
153
|
+
*/
|
|
154
|
+
isCollision(run, untracked) {
|
|
155
|
+
return run.output.includes(WOULD_BE_OVERWRITTEN) && !untracked.isEmpty();
|
|
156
|
+
}
|
|
157
|
+
checkoutFailed(run) {
|
|
158
|
+
return new rules_config_1.CliExitError(1, `${SEP}❌ Could not check out main\n${SEP}\n`
|
|
159
|
+
+ 'git refused the checkout for a reason this command does not resolve on your behalf — its\n'
|
|
160
|
+
+ 'message is above and is the thing to read. The states that reach here:\n\n'
|
|
161
|
+
+ ' • a merge or rebase is in progress — finish it or `git merge --abort` / `git rebase\n'
|
|
162
|
+
+ ' --abort`, then re-run\n'
|
|
163
|
+
+ ' • unmerged paths from a conflict — resolve them and `git add` them, then re-run\n'
|
|
164
|
+
+ ' • no local `main` and no `origin/main` to create it from — check `git remote -v`\n\n'
|
|
165
|
+
+ `git's output:\n${run.output}`);
|
|
166
|
+
}
|
|
167
|
+
/** Appended to a failure that happens AFTER the stash, so the stash is never silently forgotten. */
|
|
168
|
+
alsoStashed(stashed) {
|
|
169
|
+
if (stashed.isEmpty())
|
|
170
|
+
return '';
|
|
171
|
+
return '\nALSO: THESE UNTRACKED FILES WERE STASHED TO GET HERE — `git stash pop` RETURNS THEM:\n\n'
|
|
172
|
+
+ this.list(stashed.paths);
|
|
173
|
+
}
|
|
174
|
+
list(paths) {
|
|
175
|
+
return paths.map((path) => ` ${path}\n`).join('');
|
|
176
|
+
}
|
|
177
|
+
/** git piped, then echoed back verbatim — for the one command whose text is decided on. */
|
|
178
|
+
captureAndEcho(repoRoot, args) {
|
|
179
|
+
const result = (0, child_process_1.spawnSync)('git', ['-C', repoRoot, ...args], {
|
|
180
|
+
encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
|
|
181
|
+
});
|
|
182
|
+
const stdout = result.stdout ?? '';
|
|
183
|
+
const stderr = result.stderr ?? '';
|
|
184
|
+
process.stdout.write(stdout);
|
|
185
|
+
process.stderr.write(stderr);
|
|
186
|
+
return new GitRun(result.status === null ? 1 : result.status, `${stdout}${stderr}`);
|
|
187
|
+
}
|
|
188
|
+
/** git with its output going straight to the terminal — for the commands whose progress is the point. */
|
|
189
|
+
passThrough(repoRoot, args) {
|
|
190
|
+
const result = (0, child_process_1.spawnSync)('git', ['-C', repoRoot, ...args], { encoding: 'utf8', stdio: 'inherit' });
|
|
191
|
+
return result.status === null ? 1 : result.status;
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
exports.MainCheckout = MainCheckout;
|
|
195
|
+
exports.MainCheckout = MainCheckout = tslib_1.__decorate([
|
|
196
|
+
(0, inversify_1.injectable)(inversify_1.bindingScopeValues.Singleton)
|
|
197
|
+
], MainCheckout);
|
|
198
|
+
//# sourceMappingURL=main-checkout.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"main-checkout.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/pr-gate/src/scripts/commands/main-checkout.ts"],"names":[],"mappings":"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","sourcesContent":["import { spawnSync } from 'child_process';\nimport { injectable, bindingScopeValues } from 'inversify';\nimport { CliExitError } from '@webpieces/rules-config';\n\nimport { UntrackedFiles } from './working-tree-gate';\n\nconst SEP = '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\\n';\n\n/**\n * git's own words for the one checkout failure this class is allowed to react to. Matched on git's\n * TEXT rather than re-derived from the untracked list on purpose: git's rules here cover\n * directory-vs-file collisions, sparse checkouts and case-insensitive filesystems, and any\n * re-derivation of them drifts from the git actually installed. git's refusal is the authority.\n */\nconst WOULD_BE_OVERWRITTEN = 'untracked working tree files would be overwritten';\n\n/** One git invocation's exit status and the output it produced, for a caller that must decide on it. */\nexport class GitRun {\n constructor(readonly status: number, readonly output: string) {}\n\n succeeded(): boolean {\n return this.status === 0;\n }\n}\n\n/**\n * The files `git stash -u` took so the checkout could proceed — carried out so the caller can SHOUT\n * about them. Empty is the overwhelmingly common case: nothing collided, nothing was stashed.\n *\n * The message is ALL CAPS and banner-wrapped deliberately. This is the one path in the command where\n * files that were sitting in the working tree are no longer sitting in the working tree, and a reader\n * who skims past that line loses track of their own work. Everything else this command prints is\n * routine; this is not.\n */\nexport class StashedFiles {\n constructor(readonly paths: readonly string[]) {}\n\n isEmpty(): boolean {\n return this.paths.length === 0;\n }\n\n /** The human-facing banner, or '' when nothing was stashed. */\n render(): string {\n if (this.isEmpty()) return '';\n const list = this.paths.map((path: string): string => ` ${path}\\n`).join('');\n const quoted = this.paths.map((path: string): string => `'${path}'`).join(' ');\n return `${SEP}⚠️ UNTRACKED FILES WERE STASHED BECAUSE THEY CONFLICTED\\n${SEP}\\n`\n + 'GIT REFUSED TO CHECK OUT MAIN: AN UNTRACKED FILE HERE SITS AT A PATH MAIN TRACKS, AND\\n'\n + 'THE CHECKOUT WOULD HAVE OVERWRITTEN IT. `git stash -u` TAKES EVERY UNTRACKED FILE, NOT\\n'\n + 'ONLY THE ONE THAT COLLIDED, SO ALL OF THESE LEFT THE WORKING TREE. THE CHECKOUT WAS THEN\\n'\n + 'RETRIED AND YOU ARE NOW ON A CLEAN MAIN. NOTHING WAS DELETED — YOUR VERSION OF EVERY\\n'\n + 'FILE BELOW IS IN THE STASH:\\n\\n'\n + list\n + '\\nGET THEM BACK WITH:\\n\\n'\n + ' git stash pop\\n\\n'\n + 'EXPECT THAT POP TO REFUSE THE FIRST TIME. IT LANDS ON THE NEW MAIN, WHICH IS EXACTLY\\n'\n + 'WHERE A COMMITTED COPY OF THE COLLIDING PATH NOW SITS — THAT COLLISION IS WHY ANY OF\\n'\n + 'THIS HAPPENED — SO GIT SAYS \"already exists, no checkout\" AND KEEPS THE STASH. TO TAKE\\n'\n + 'YOUR VERSIONS BACK, DELETE MAIN\\'S COPIES AND POP ONTO THE HOLE:\\n\\n'\n + ` rm -f ${quoted} && git stash pop\\n\\n`\n + 'NOTHING IS DISCARDED WHILE THE POP HAS NOT SUCCEEDED — `git stash list` STILL SHOWS THE\\n'\n + 'ENTRY, AS MANY TRIES AS YOU NEED. IF THE FILE IS GENERATED, REGENERATING IT ON THE NEW\\n'\n + 'MAIN IS USUALLY BETTER THAN RESTORING A COPY BUILT FROM THE OLD ONE.\\n\\n'\n + 'WHY STASHED AND NOT REFUSED: REACHING A CLEAN MAIN HAS TO STAY POSSIBLE. A REFUSAL HERE\\n'\n + 'DEADLOCKS ANYONE — HUMAN OR AGENT — WHOSE NEXT STEP IS GATED ON BEING ON MAIN, AND AN\\n'\n + `AGENT MID-UPGRADE CANNOT ASK FOR HELP.\\n${SEP}`;\n }\n}\n\n/**\n * Get onto a current main, whatever the working tree is holding.\n *\n * ─── WHY A COLLISION STASHES RATHER THAN REFUSES ──────────────────────────────────────────────────\n * `WorkingTreeGate` deliberately lets untracked files through: they are on no branch, so the switch\n * does not move them. That is true about the FILES and not true about the CHECKOUT — git aborts when\n * an untracked file sits at a path the destination branch TRACKS (\"The following untracked working\n * tree files would be overwritten by checkout\"). Letting untracked files reach the checkout is\n * exactly what makes that abort reachable, and the field case that produced it is the common one: a\n * generated `design.html`, untracked in a working clone because it is regenerated, and committed on\n * main.\n *\n * A refusal there is a DEAD END for the one caller that most needs the command to work — an agent\n * mid-upgrade of webpieces, unable to use its tools until it reaches a clean main, with nobody\n * watching to move the file for it. So the collision is resolved rather than reported: stash with\n * `-u`, retry, and then say so loudly enough that no human loses track of the files. Losing a minute\n * to a `git stash pop` beats a state nothing can get out of.\n *\n * ─── WHY THE CHECKOUT'S OUTPUT IS CAPTURED AND RE-PRINTED ─────────────────────────────────────────\n * The decision above is made on git's own refusal text, which means the checkout cannot run on\n * `stdio: 'inherit'` — inherited output is never seen by this process. It is piped and echoed back\n * instead, so the reader still gets every byte git wrote. `fetch` and `pull` stay inherited: nothing\n * is decided from their text, and they are the two that render live progress, which a pipe flattens.\n */\n@injectable(bindingScopeValues.Singleton)\nexport class MainCheckout {\n /**\n * Fetch, check out main, fast-forward it. Returns the files that had to be stashed to get there —\n * empty in the ordinary case.\n *\n * `--ff-only` rather than a `reset --hard`, deliberately: a developer or an agent that\n * accidentally committed to local main would have that work silently destroyed by a reset, and\n * the whole point of this command is that it is safe to run without thinking. A refusal to\n * fast-forward is a real condition a human should see and decide about.\n */\n goToMain(repoRoot: string, untracked: UntrackedFiles): StashedFiles {\n this.passThrough(repoRoot, ['fetch', 'origin', 'main']);\n const stashed = this.checkoutMain(repoRoot, untracked);\n if (this.passThrough(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 + this.alsoStashed(stashed));\n }\n return stashed;\n }\n\n private checkoutMain(repoRoot: string, untracked: UntrackedFiles): StashedFiles {\n const first = this.captureAndEcho(repoRoot, ['checkout', 'main']);\n if (first.succeeded()) return new StashedFiles([]);\n if (!this.isCollision(first, untracked)) throw this.checkoutFailed(first);\n\n const stash = this.captureAndEcho(repoRoot, ['stash', 'push', '-u',\n '-m', 'wp-checkout-clean-main: untracked files that collided with main']);\n if (!stash.succeeded()) {\n throw new CliExitError(1, `${SEP}❌ Could not check out main, and could not stash\\n${SEP}\\n`\n + 'git refused the checkout because untracked files in this tree sit at paths main\\n'\n + 'tracks, and `git stash -u` then failed too, so NOTHING WAS STASHED — the tree is\\n'\n + 'exactly as you left it and you are still on your branch. Both git messages are\\n'\n + 'above. Move or delete these files yourself, then re-run:\\n\\n'\n + this.list(untracked.paths));\n }\n\n const second = this.captureAndEcho(repoRoot, ['checkout', 'main']);\n const stashed = new StashedFiles(untracked.paths);\n if (!second.succeeded()) {\n throw new CliExitError(1, `${SEP}❌ Could not check out main even after stashing\\n${SEP}\\n`\n + 'The untracked files below WERE stashed — they are no longer in the working tree —\\n'\n + 'and git still refused the checkout, so something else is blocking it. Its message is\\n'\n + 'above. You are still on your branch. Recover the files with `git stash pop`, and\\n'\n + 'read git\\'s reason before re-running:\\n\\n'\n + this.list(stashed.paths));\n }\n return stashed;\n }\n\n /**\n * Only the collision git NAMES, and only when there is something a stash could actually take.\n * Untracked files with any other refusal, or a refusal with an empty untracked list, is a\n * different problem and stashing would be a guess.\n */\n private isCollision(run: GitRun, untracked: UntrackedFiles): boolean {\n return run.output.includes(WOULD_BE_OVERWRITTEN) && !untracked.isEmpty();\n }\n\n private checkoutFailed(run: GitRun): CliExitError {\n return new CliExitError(1, `${SEP}❌ Could not check out main\\n${SEP}\\n`\n + 'git refused the checkout for a reason this command does not resolve on your behalf — its\\n'\n + 'message is above and is the thing to read. The states that reach here:\\n\\n'\n + ' • a merge or rebase is in progress — finish it or `git merge --abort` / `git rebase\\n'\n + ' --abort`, then re-run\\n'\n + ' • unmerged paths from a conflict — resolve them and `git add` them, then re-run\\n'\n + ' • no local `main` and no `origin/main` to create it from — check `git remote -v`\\n\\n'\n + `git's output:\\n${run.output}`);\n }\n\n /** Appended to a failure that happens AFTER the stash, so the stash is never silently forgotten. */\n private alsoStashed(stashed: StashedFiles): string {\n if (stashed.isEmpty()) return '';\n return '\\nALSO: THESE UNTRACKED FILES WERE STASHED TO GET HERE — `git stash pop` RETURNS THEM:\\n\\n'\n + this.list(stashed.paths);\n }\n\n private list(paths: readonly string[]): string {\n return paths.map((path: string): string => ` ${path}\\n`).join('');\n }\n\n /** git piped, then echoed back verbatim — for the one command whose text is decided on. */\n private captureAndEcho(repoRoot: string, args: string[]): GitRun {\n const result = spawnSync('git', ['-C', repoRoot, ...args], {\n encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],\n });\n const stdout = result.stdout ?? '';\n const stderr = result.stderr ?? '';\n process.stdout.write(stdout);\n process.stderr.write(stderr);\n return new GitRun(result.status === null ? 1 : result.status, `${stdout}${stderr}`);\n }\n\n /** git with its output going straight to the terminal — for the commands whose progress is the point. */\n private passThrough(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"]}
|
|
@@ -29,6 +29,12 @@ export declare class UntrackedFiles {
|
|
|
29
29
|
* Now the refusal only ever names tracked changes, so `git stash` — the cure it prints — always resolves
|
|
30
30
|
* it, by construction.
|
|
31
31
|
*
|
|
32
|
+
* That is true about the FILES and not about the CHECKOUT: git itself aborts a switch when an untracked
|
|
33
|
+
* file sits at a path the destination branch TRACKS. Letting untracked files through is what makes that
|
|
34
|
+
* abort reachable, and `MainCheckout` is where it is handled — it reacts to git's own refusal by stashing
|
|
35
|
+
* with `-u`, retrying, and shouting about it. This gate does not predict that case, deliberately: git's
|
|
36
|
+
* rules for it are the authority, and re-deriving them here would drift from the git actually installed.
|
|
37
|
+
*
|
|
32
38
|
* ─── WHY `--untracked-files=no` AND `ls-files -z` RATHER THAN PARSING `??` ─────────────────────────────
|
|
33
39
|
* The decision half asks git the tracked-only question directly, so no line of porcelain output is ever
|
|
34
40
|
* parsed to make it. The reporting half asks `ls-files --others --exclude-standard -z`, which emits bare
|
|
@@ -49,6 +49,12 @@ exports.UntrackedFiles = UntrackedFiles;
|
|
|
49
49
|
* Now the refusal only ever names tracked changes, so `git stash` — the cure it prints — always resolves
|
|
50
50
|
* it, by construction.
|
|
51
51
|
*
|
|
52
|
+
* That is true about the FILES and not about the CHECKOUT: git itself aborts a switch when an untracked
|
|
53
|
+
* file sits at a path the destination branch TRACKS. Letting untracked files through is what makes that
|
|
54
|
+
* abort reachable, and `MainCheckout` is where it is handled — it reacts to git's own refusal by stashing
|
|
55
|
+
* with `-u`, retrying, and shouting about it. This gate does not predict that case, deliberately: git's
|
|
56
|
+
* rules for it are the authority, and re-deriving them here would drift from the git actually installed.
|
|
57
|
+
*
|
|
52
58
|
* ─── WHY `--untracked-files=no` AND `ls-files -z` RATHER THAN PARSING `??` ─────────────────────────────
|
|
53
59
|
* The decision half asks git the tracked-only question directly, so no line of porcelain output is ever
|
|
54
60
|
* parsed to make it. The reporting half asks `ls-files --others --exclude-standard -z`, which emits bare
|
|
@@ -1 +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
|
|
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;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;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 * That is true about the FILES and not about the CHECKOUT: git itself aborts a switch when an untracked\n * file sits at a path the destination branch TRACKS. Letting untracked files through is what makes that\n * abort reachable, and `MainCheckout` is where it is handled — it reacts to git's own refusal by stashing\n * with `-u`, retrying, and shouting about it. This gate does not predict that case, deliberately: git's\n * rules for it are the authority, and re-deriving them here would drift from the git actually installed.\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"]}
|