@webpieces/pr-gate 0.4.618 → 0.4.619
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/reap-worktree-command.d.ts +16 -2
- package/src/scripts/commands/reap-worktree-command.js +27 -4
- package/src/scripts/commands/reap-worktree-command.js.map +1 -1
- package/src/scripts/workflow/landed-worktree-reaper.d.ts +15 -1
- package/src/scripts/workflow/landed-worktree-reaper.js +33 -8
- package/src/scripts/workflow/landed-worktree-reaper.js.map +1 -1
- package/src/scripts/workflow/reap-outcome.d.ts +53 -0
- package/src/scripts/workflow/reap-outcome.js +84 -0
- package/src/scripts/workflow/reap-outcome.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.619",
|
|
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.619",
|
|
19
19
|
"@inversifyjs/binding-decorators": "1.1.5",
|
|
20
20
|
"inversify": "7.10.4",
|
|
21
21
|
"reflect-metadata": "0.2.2"
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { RepoRootFinder } from '@webpieces/rules-config';
|
|
2
|
+
import { ReapOutcomeSignal } from '../workflow/reap-outcome';
|
|
2
3
|
import { WorktreeCleanupSection } from './worktree-cleanup';
|
|
3
4
|
/**
|
|
4
5
|
* The CHILD side of `wp-land-pr`'s re-exec: reap ONE named worktree, from a process whose cwd is the
|
|
@@ -23,12 +24,25 @@ import { WorktreeCleanupSection } from './worktree-cleanup';
|
|
|
23
24
|
export declare class ReapWorktreeCommand {
|
|
24
25
|
private readonly repoRootFinder;
|
|
25
26
|
private readonly worktreeSection;
|
|
26
|
-
|
|
27
|
+
private readonly signal;
|
|
28
|
+
constructor(repoRootFinder: RepoRootFinder, worktreeSection: WorktreeCleanupSection, signal: ReapOutcomeSignal);
|
|
29
|
+
/**
|
|
30
|
+
* Every exit from here states an OUTCOME, because the exit code cannot: refusals exit 0 (see
|
|
31
|
+
* `resolveTarget`), so a parent with only a status has no way to tell "removed" from "declined to
|
|
32
|
+
* remove". The token is the last thing written, and LandedWorktreeReaper strips it before printing.
|
|
33
|
+
*/
|
|
27
34
|
run(args: string[]): Promise<void>;
|
|
35
|
+
/**
|
|
36
|
+
* `reaped` is the only entry that means the DIRECTORY is gone. A tree that reached the reaper and
|
|
37
|
+
* came back in neither list was spared by one of WorktreeReaper's own rails (it is the cwd, it is
|
|
38
|
+
* the primary clone) — nothing was attempted, which is a refusal, not a failure.
|
|
39
|
+
*/
|
|
40
|
+
private outcomeOf;
|
|
28
41
|
/**
|
|
29
42
|
* The verdict for the requested path, or null after printing WHY it is not reapable. Refusals are
|
|
30
43
|
* printed rather than thrown: the PR they follow is already merged, and exiting non-zero after a
|
|
31
|
-
* successful landing would report a landed PR as a failed command.
|
|
44
|
+
* successful landing would report a landed PR as a failed command. That is exactly why `run` also
|
|
45
|
+
* emits a ReapOutcomeSignal — the caller must be able to tell this exit 0 from a successful one.
|
|
32
46
|
*/
|
|
33
47
|
private resolveTarget;
|
|
34
48
|
private render;
|
|
@@ -5,6 +5,7 @@ const tslib_1 = require("tslib");
|
|
|
5
5
|
const path = tslib_1.__importStar(require("path"));
|
|
6
6
|
const rules_config_1 = require("@webpieces/rules-config");
|
|
7
7
|
const inversify_1 = require("inversify");
|
|
8
|
+
const reap_outcome_1 = require("../workflow/reap-outcome");
|
|
8
9
|
const worktree_cleanup_1 = require("./worktree-cleanup");
|
|
9
10
|
const SEP = '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n';
|
|
10
11
|
/**
|
|
@@ -30,25 +31,46 @@ const SEP = '━━━━━━━━━━━━━━━━━━━━━━
|
|
|
30
31
|
let ReapWorktreeCommand = class ReapWorktreeCommand {
|
|
31
32
|
repoRootFinder;
|
|
32
33
|
worktreeSection;
|
|
33
|
-
|
|
34
|
+
signal;
|
|
35
|
+
constructor(repoRootFinder, worktreeSection, signal) {
|
|
34
36
|
this.repoRootFinder = repoRootFinder;
|
|
35
37
|
this.worktreeSection = worktreeSection;
|
|
38
|
+
this.signal = signal;
|
|
36
39
|
}
|
|
40
|
+
/**
|
|
41
|
+
* Every exit from here states an OUTCOME, because the exit code cannot: refusals exit 0 (see
|
|
42
|
+
* `resolveTarget`), so a parent with only a status has no way to tell "removed" from "declined to
|
|
43
|
+
* remove". The token is the last thing written, and LandedWorktreeReaper strips it before printing.
|
|
44
|
+
*/
|
|
37
45
|
async run(args) {
|
|
38
46
|
await Promise.resolve();
|
|
39
47
|
const request = this.parse(args);
|
|
40
48
|
const repoRoot = this.repoRootFinder.resolveRepoRoot(process.cwd());
|
|
41
49
|
const target = this.resolveTarget(repoRoot, request);
|
|
42
|
-
if (target === null)
|
|
50
|
+
if (target === null) {
|
|
51
|
+
process.stdout.write(this.signal.line(reap_outcome_1.REAP_OUTCOME_REFUSED));
|
|
43
52
|
return;
|
|
53
|
+
}
|
|
44
54
|
const retention = (0, rules_config_1.loadAndValidate)(repoRoot).prGate.landPr.branchRetention;
|
|
45
55
|
const result = this.worktreeSection.reap(repoRoot, 'wp-land-pr', [target], retention);
|
|
46
56
|
process.stdout.write(this.render(result));
|
|
57
|
+
process.stdout.write(this.signal.line(this.outcomeOf(result)));
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* `reaped` is the only entry that means the DIRECTORY is gone. A tree that reached the reaper and
|
|
61
|
+
* came back in neither list was spared by one of WorktreeReaper's own rails (it is the cwd, it is
|
|
62
|
+
* the primary clone) — nothing was attempted, which is a refusal, not a failure.
|
|
63
|
+
*/
|
|
64
|
+
outcomeOf(result) {
|
|
65
|
+
if (result.reaped.length > 0)
|
|
66
|
+
return reap_outcome_1.REAP_OUTCOME_REMOVED;
|
|
67
|
+
return result.failed.length > 0 ? reap_outcome_1.REAP_OUTCOME_FAILED : reap_outcome_1.REAP_OUTCOME_REFUSED;
|
|
47
68
|
}
|
|
48
69
|
/**
|
|
49
70
|
* The verdict for the requested path, or null after printing WHY it is not reapable. Refusals are
|
|
50
71
|
* printed rather than thrown: the PR they follow is already merged, and exiting non-zero after a
|
|
51
|
-
* successful landing would report a landed PR as a failed command.
|
|
72
|
+
* successful landing would report a landed PR as a failed command. That is exactly why `run` also
|
|
73
|
+
* emits a ReapOutcomeSignal — the caller must be able to tell this exit 0 from a successful one.
|
|
52
74
|
*/
|
|
53
75
|
resolveTarget(repoRoot, request) {
|
|
54
76
|
const wanted = path.resolve(request.worktreePath);
|
|
@@ -102,7 +124,8 @@ exports.ReapWorktreeCommand = ReapWorktreeCommand;
|
|
|
102
124
|
exports.ReapWorktreeCommand = ReapWorktreeCommand = tslib_1.__decorate([
|
|
103
125
|
(0, inversify_1.injectable)(inversify_1.bindingScopeValues.Singleton),
|
|
104
126
|
tslib_1.__metadata("design:paramtypes", [rules_config_1.RepoRootFinder,
|
|
105
|
-
worktree_cleanup_1.WorktreeCleanupSection
|
|
127
|
+
worktree_cleanup_1.WorktreeCleanupSection,
|
|
128
|
+
reap_outcome_1.ReapOutcomeSignal])
|
|
106
129
|
], ReapWorktreeCommand);
|
|
107
130
|
// Data-only (per CLAUDE.md, classes for data): the pair of argv values this entry point takes.
|
|
108
131
|
class ReapRequest {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"reap-worktree-command.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/pr-gate/src/scripts/commands/reap-worktree-command.ts"],"names":[],"mappings":"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","sourcesContent":["import * as path from 'path';\nimport {\n DeletableWorktree,\n InformAiError,\n RepoRootFinder,\n WorktreeReapResult,\n loadAndValidate,\n} from '@webpieces/rules-config';\nimport { injectable, bindingScopeValues } from 'inversify';\n\nimport { WorktreeCleanupSection } from './worktree-cleanup';\n\nconst SEP = '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\\n';\n\n/**\n * The CHILD side of `wp-land-pr`'s re-exec: reap ONE named worktree, from a process whose cwd is the\n * primary clone.\n *\n * It is not a published `bin`. Nothing about it is a verb a human should reach for — `pnpm wp-cleanup`\n * is that verb, and it does strictly more. This exists so the parent can hand off a SINGLE, already\n * identified corpse without also reaping every other agent's worktree as a side effect of landing one\n * PR. LandedWorktreeReaper spawns it by absolute path; see that class for why a child rather than a\n * `process.chdir`.\n *\n * WHAT IT WILL NOT TAKE ON TRUST. The parent tells it a path and a branch; it believes neither.\n * The verdict is recomputed here, from scratch, by the same MergedBranchesService that backs\n * `wp-cleanup` — so a worktree is removed only when it is PROVABLY dead (its PR is merged), and never\n * because a caller said so. That is what keeps \"never remove a tree still holding unmerged work\" true\n * even if the parent is wrong, out of date, or someone runs this by hand.\n *\n * The primary clone can never be a target: `classifyWorktrees` drops the main worktree from the\n * verdicts outright, so a path pointing at it simply does not resolve to a target — and WorktreeReaper\n * refuses it a second time by name. Neither rail is load-bearing alone.\n */\n@injectable(bindingScopeValues.Singleton)\nexport class ReapWorktreeCommand {\n constructor(\n private readonly repoRootFinder: RepoRootFinder,\n private readonly worktreeSection: WorktreeCleanupSection,\n ) {}\n\n async run(args: string[]): Promise<void> {\n await Promise.resolve();\n const request = this.parse(args);\n const repoRoot = this.repoRootFinder.resolveRepoRoot(process.cwd());\n\n const target = this.resolveTarget(repoRoot, request);\n if (target === null) return;\n\n const retention = loadAndValidate(repoRoot).prGate.landPr.branchRetention;\n const result = this.worktreeSection.reap(repoRoot, 'wp-land-pr', [target], retention);\n process.stdout.write(this.render(result));\n }\n\n /**\n * The verdict for the requested path, or null after printing WHY it is not reapable. Refusals are\n * printed rather than thrown: the PR they follow is already merged, and exiting non-zero after a\n * successful landing would report a landed PR as a failed command.\n */\n private resolveTarget(repoRoot: string, request: ReapRequest): DeletableWorktree | null {\n const wanted = path.resolve(request.worktreePath);\n const target = this.worktreeSection.verdicts(repoRoot)\n .find((tree: DeletableWorktree): boolean => path.resolve(tree.path) === wanted);\n\n if (target === undefined) {\n process.stdout.write(\n `\\n ℹ️ ${request.worktreePath} is not a removable worktree of this repo (it may be the\\n`\n + ' primary clone, or already gone). Nothing removed.\\n');\n return null;\n }\n // The branch moved under us between landing and reaping — somebody checked something else out\n // there. Whatever that is, it is not the corpse we were asked to bury.\n if (target.branch !== request.branch) {\n process.stdout.write(\n `\\n ⚠️ ${request.worktreePath} now holds '${target.branch}', not '${request.branch}'.\\n`\n + ' Refusing to remove a worktree whose branch changed since the PR landed.\\n');\n return null;\n }\n if (!target.deletable) {\n process.stdout.write(\n `\\n ⚠️ ${request.worktreePath} is not provably dead: ${target.reason}\\n`\n + ' Refusing to remove a worktree that may still hold unmerged work.\\n');\n return null;\n }\n return target;\n }\n\n // The reap report, plus the spared case — which report() renders as '' because from wp-cleanup's\n // point of view \"nothing happened\" is a whole answer. Here it never is: we asked for exactly one.\n private render(result: WorktreeReapResult): string {\n if (result.reaped.length === 0 && result.failed.length === 0) {\n let out = '\\n' + SEP + '🛑 Nothing removed\\n' + SEP + '\\n';\n for (const tree of result.spared) out += ` · ${tree.path} — ${tree.reason}\\n`;\n return out;\n }\n return this.worktreeSection.report(result);\n }\n\n // `<worktree-path> <branch>`, both required. Thrown rather than printed: bad argv means the caller\n // is broken, and there is no landed PR in this process whose success a non-zero exit could misreport.\n private parse(args: string[]): ReapRequest {\n const worktreePath = args[0] ?? '';\n const branch = args[1] ?? '';\n if (worktreePath === '' || branch === '') {\n throw new InformAiError(\n '\\n' + SEP + '❌ wp-reap-worktree: missing arguments\\n' + SEP + '\\n'\n + 'Usage: node wp-reap-worktree.js <worktree-path> <branch>\\n\\n'\n + 'This is an INTERNAL entry point, spawned by `pnpm wp-land-pr` with cwd set to the\\n'\n + 'primary clone. To clean up by hand, run `pnpm wp-cleanup` from the primary clone.\\n' + SEP);\n }\n return new ReapRequest(worktreePath, branch);\n }\n}\n\n// Data-only (per CLAUDE.md, classes for data): the pair of argv values this entry point takes.\nclass ReapRequest {\n worktreePath: string;\n branch: string;\n\n constructor(worktreePath: string, branch: string) {\n this.worktreePath = worktreePath;\n this.branch = branch;\n }\n}\n"]}
|
|
1
|
+
{"version":3,"file":"reap-worktree-command.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/pr-gate/src/scripts/commands/reap-worktree-command.ts"],"names":[],"mappings":"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","sourcesContent":["import * as path from 'path';\nimport {\n DeletableWorktree,\n InformAiError,\n RepoRootFinder,\n WorktreeReapResult,\n loadAndValidate,\n} from '@webpieces/rules-config';\nimport { injectable, bindingScopeValues } from 'inversify';\n\nimport {\n ReapOutcomeSignal,\n REAP_OUTCOME_FAILED,\n REAP_OUTCOME_REFUSED,\n REAP_OUTCOME_REMOVED,\n} from '../workflow/reap-outcome';\n\nimport { WorktreeCleanupSection } from './worktree-cleanup';\n\nconst SEP = '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\\n';\n\n/**\n * The CHILD side of `wp-land-pr`'s re-exec: reap ONE named worktree, from a process whose cwd is the\n * primary clone.\n *\n * It is not a published `bin`. Nothing about it is a verb a human should reach for — `pnpm wp-cleanup`\n * is that verb, and it does strictly more. This exists so the parent can hand off a SINGLE, already\n * identified corpse without also reaping every other agent's worktree as a side effect of landing one\n * PR. LandedWorktreeReaper spawns it by absolute path; see that class for why a child rather than a\n * `process.chdir`.\n *\n * WHAT IT WILL NOT TAKE ON TRUST. The parent tells it a path and a branch; it believes neither.\n * The verdict is recomputed here, from scratch, by the same MergedBranchesService that backs\n * `wp-cleanup` — so a worktree is removed only when it is PROVABLY dead (its PR is merged), and never\n * because a caller said so. That is what keeps \"never remove a tree still holding unmerged work\" true\n * even if the parent is wrong, out of date, or someone runs this by hand.\n *\n * The primary clone can never be a target: `classifyWorktrees` drops the main worktree from the\n * verdicts outright, so a path pointing at it simply does not resolve to a target — and WorktreeReaper\n * refuses it a second time by name. Neither rail is load-bearing alone.\n */\n@injectable(bindingScopeValues.Singleton)\nexport class ReapWorktreeCommand {\n constructor(\n private readonly repoRootFinder: RepoRootFinder,\n private readonly worktreeSection: WorktreeCleanupSection,\n private readonly signal: ReapOutcomeSignal,\n ) {}\n\n /**\n * Every exit from here states an OUTCOME, because the exit code cannot: refusals exit 0 (see\n * `resolveTarget`), so a parent with only a status has no way to tell \"removed\" from \"declined to\n * remove\". The token is the last thing written, and LandedWorktreeReaper strips it before printing.\n */\n async run(args: string[]): Promise<void> {\n await Promise.resolve();\n const request = this.parse(args);\n const repoRoot = this.repoRootFinder.resolveRepoRoot(process.cwd());\n\n const target = this.resolveTarget(repoRoot, request);\n if (target === null) {\n process.stdout.write(this.signal.line(REAP_OUTCOME_REFUSED));\n return;\n }\n\n const retention = loadAndValidate(repoRoot).prGate.landPr.branchRetention;\n const result = this.worktreeSection.reap(repoRoot, 'wp-land-pr', [target], retention);\n process.stdout.write(this.render(result));\n process.stdout.write(this.signal.line(this.outcomeOf(result)));\n }\n\n /**\n * `reaped` is the only entry that means the DIRECTORY is gone. A tree that reached the reaper and\n * came back in neither list was spared by one of WorktreeReaper's own rails (it is the cwd, it is\n * the primary clone) — nothing was attempted, which is a refusal, not a failure.\n */\n private outcomeOf(result: WorktreeReapResult): string {\n if (result.reaped.length > 0) return REAP_OUTCOME_REMOVED;\n return result.failed.length > 0 ? REAP_OUTCOME_FAILED : REAP_OUTCOME_REFUSED;\n }\n\n /**\n * The verdict for the requested path, or null after printing WHY it is not reapable. Refusals are\n * printed rather than thrown: the PR they follow is already merged, and exiting non-zero after a\n * successful landing would report a landed PR as a failed command. That is exactly why `run` also\n * emits a ReapOutcomeSignal — the caller must be able to tell this exit 0 from a successful one.\n */\n private resolveTarget(repoRoot: string, request: ReapRequest): DeletableWorktree | null {\n const wanted = path.resolve(request.worktreePath);\n const target = this.worktreeSection.verdicts(repoRoot)\n .find((tree: DeletableWorktree): boolean => path.resolve(tree.path) === wanted);\n\n if (target === undefined) {\n process.stdout.write(\n `\\n ℹ️ ${request.worktreePath} is not a removable worktree of this repo (it may be the\\n`\n + ' primary clone, or already gone). Nothing removed.\\n');\n return null;\n }\n // The branch moved under us between landing and reaping — somebody checked something else out\n // there. Whatever that is, it is not the corpse we were asked to bury.\n if (target.branch !== request.branch) {\n process.stdout.write(\n `\\n ⚠️ ${request.worktreePath} now holds '${target.branch}', not '${request.branch}'.\\n`\n + ' Refusing to remove a worktree whose branch changed since the PR landed.\\n');\n return null;\n }\n if (!target.deletable) {\n process.stdout.write(\n `\\n ⚠️ ${request.worktreePath} is not provably dead: ${target.reason}\\n`\n + ' Refusing to remove a worktree that may still hold unmerged work.\\n');\n return null;\n }\n return target;\n }\n\n // The reap report, plus the spared case — which report() renders as '' because from wp-cleanup's\n // point of view \"nothing happened\" is a whole answer. Here it never is: we asked for exactly one.\n private render(result: WorktreeReapResult): string {\n if (result.reaped.length === 0 && result.failed.length === 0) {\n let out = '\\n' + SEP + '🛑 Nothing removed\\n' + SEP + '\\n';\n for (const tree of result.spared) out += ` · ${tree.path} — ${tree.reason}\\n`;\n return out;\n }\n return this.worktreeSection.report(result);\n }\n\n // `<worktree-path> <branch>`, both required. Thrown rather than printed: bad argv means the caller\n // is broken, and there is no landed PR in this process whose success a non-zero exit could misreport.\n private parse(args: string[]): ReapRequest {\n const worktreePath = args[0] ?? '';\n const branch = args[1] ?? '';\n if (worktreePath === '' || branch === '') {\n throw new InformAiError(\n '\\n' + SEP + '❌ wp-reap-worktree: missing arguments\\n' + SEP + '\\n'\n + 'Usage: node wp-reap-worktree.js <worktree-path> <branch>\\n\\n'\n + 'This is an INTERNAL entry point, spawned by `pnpm wp-land-pr` with cwd set to the\\n'\n + 'primary clone. To clean up by hand, run `pnpm wp-cleanup` from the primary clone.\\n' + SEP);\n }\n return new ReapRequest(worktreePath, branch);\n }\n}\n\n// Data-only (per CLAUDE.md, classes for data): the pair of argv values this entry point takes.\nclass ReapRequest {\n worktreePath: string;\n branch: string;\n\n constructor(worktreePath: string, branch: string) {\n this.worktreePath = worktreePath;\n this.branch = branch;\n }\n}\n"]}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { WorktreeService } from '@webpieces/rules-config';
|
|
2
|
+
import { ReapOutcomeSignal } from './reap-outcome';
|
|
2
3
|
/**
|
|
3
4
|
* Data-only (per CLAUDE.md, classes for data). The decision about the just-landed worktree: which
|
|
4
5
|
* directory, which branch, where the reap must run FROM, and — when it cannot run — why not.
|
|
@@ -19,7 +20,8 @@ export declare class WorktreeReapHandoff {
|
|
|
19
20
|
}
|
|
20
21
|
export declare class LandedWorktreeReaper {
|
|
21
22
|
private readonly worktrees;
|
|
22
|
-
|
|
23
|
+
private readonly signal;
|
|
24
|
+
constructor(worktrees: WorktreeService, signal: ReapOutcomeSignal);
|
|
23
25
|
/**
|
|
24
26
|
* Is there a worktree to reap at all, and can the hand-off run?
|
|
25
27
|
*
|
|
@@ -35,8 +37,20 @@ export declare class LandedWorktreeReaper {
|
|
|
35
37
|
* stdin is `ignore` deliberately: the child must never be able to ask a question. A prompt printed
|
|
36
38
|
* into a landing recap nobody is watching is not consent, and this reap is authorised by the merge
|
|
37
39
|
* that just succeeded, not by an answer.
|
|
40
|
+
*
|
|
41
|
+
* THE EXIT CODE IS NOT THE ANSWER. The child refuses by PRINTING and exiting 0 on purpose — a
|
|
42
|
+
* non-zero exit after a successful merge would report a landed PR as a failed command. So `exit 0`
|
|
43
|
+
* only means "the child ran"; whether the DIRECTORY is gone is a separate statement it makes through
|
|
44
|
+
* ReapOutcomeSignal, and that is the one that gates the "your cwd no longer exists" notice.
|
|
38
45
|
*/
|
|
39
46
|
handOff(handoff: WorktreeReapHandoff): string;
|
|
47
|
+
/**
|
|
48
|
+
* WHY the directory is still there, distinguishing the two cases a single exit code conflated: the
|
|
49
|
+
* child broke (non-zero), versus the child worked perfectly and DECLINED (exit 0, outcome refused).
|
|
50
|
+
* `--force` is absent from both, and from every path below them: git refusing to remove a worktree
|
|
51
|
+
* holding untracked files is work no archive tag captured.
|
|
52
|
+
*/
|
|
53
|
+
private notRemoved;
|
|
40
54
|
/**
|
|
41
55
|
* The #512 notice, kept verbatim in spirit for every case the re-exec cannot cover. An honest
|
|
42
56
|
* limitation beats a command that deletes its own working directory mid-run.
|
|
@@ -7,6 +7,7 @@ const fs = tslib_1.__importStar(require("fs"));
|
|
|
7
7
|
const path = tslib_1.__importStar(require("path"));
|
|
8
8
|
const rules_config_1 = require("@webpieces/rules-config");
|
|
9
9
|
const inversify_1 = require("inversify");
|
|
10
|
+
const reap_outcome_1 = require("./reap-outcome");
|
|
10
11
|
/**
|
|
11
12
|
* The RE-EXEC half of `wp-land-pr`: reap the worktree the command is standing in, from a process that
|
|
12
13
|
* is NOT standing in it.
|
|
@@ -67,8 +68,10 @@ class WorktreeReapHandoff {
|
|
|
67
68
|
exports.WorktreeReapHandoff = WorktreeReapHandoff;
|
|
68
69
|
let LandedWorktreeReaper = class LandedWorktreeReaper {
|
|
69
70
|
worktrees;
|
|
70
|
-
|
|
71
|
+
signal;
|
|
72
|
+
constructor(worktrees, signal) {
|
|
71
73
|
this.worktrees = worktrees;
|
|
74
|
+
this.signal = signal;
|
|
72
75
|
}
|
|
73
76
|
/**
|
|
74
77
|
* Is there a worktree to reap at all, and can the hand-off run?
|
|
@@ -99,19 +102,40 @@ let LandedWorktreeReaper = class LandedWorktreeReaper {
|
|
|
99
102
|
* stdin is `ignore` deliberately: the child must never be able to ask a question. A prompt printed
|
|
100
103
|
* into a landing recap nobody is watching is not consent, and this reap is authorised by the merge
|
|
101
104
|
* that just succeeded, not by an answer.
|
|
105
|
+
*
|
|
106
|
+
* THE EXIT CODE IS NOT THE ANSWER. The child refuses by PRINTING and exiting 0 on purpose — a
|
|
107
|
+
* non-zero exit after a successful merge would report a landed PR as a failed command. So `exit 0`
|
|
108
|
+
* only means "the child ran"; whether the DIRECTORY is gone is a separate statement it makes through
|
|
109
|
+
* ReapOutcomeSignal, and that is the one that gates the "your cwd no longer exists" notice.
|
|
102
110
|
*/
|
|
103
111
|
handOff(handoff) {
|
|
104
112
|
const result = (0, child_process_1.spawnSync)(process.execPath, [handoff.entryScript, handoff.worktreePath, handoff.branch], { cwd: handoff.primaryPath, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
105
|
-
const
|
|
113
|
+
const report = this.signal.read(`${result.stdout ?? ''}${result.stderr ?? ''}`);
|
|
114
|
+
const output = report.text.trimEnd();
|
|
106
115
|
const head = '\n' + SEP + `🌲 Reaping this worktree from ${handoff.primaryPath}\n` + SEP + '\n'
|
|
107
116
|
+ ` The branch just landed, so this directory is dead. The reap runs in a child process\n`
|
|
108
117
|
+ ` whose cwd is the primary clone — nothing deletes the directory it is standing in.\n\n`;
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
118
|
+
const body = head + (output !== '' ? output + '\n' : '');
|
|
119
|
+
if (result.status !== 0 || !report.removed) {
|
|
120
|
+
return body + this.notRemoved(result.status, report) + this.manualNotice(handoff);
|
|
121
|
+
}
|
|
122
|
+
return body + this.afterReap(handoff);
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* WHY the directory is still there, distinguishing the two cases a single exit code conflated: the
|
|
126
|
+
* child broke (non-zero), versus the child worked perfectly and DECLINED (exit 0, outcome refused).
|
|
127
|
+
* `--force` is absent from both, and from every path below them: git refusing to remove a worktree
|
|
128
|
+
* holding untracked files is work no archive tag captured.
|
|
129
|
+
*/
|
|
130
|
+
notRemoved(status, report) {
|
|
131
|
+
if (status !== 0) {
|
|
132
|
+
return `\n ⚠️ The reap did not complete (exit ${String(status ?? -1)}). Nothing was forced.\n`;
|
|
113
133
|
}
|
|
114
|
-
|
|
134
|
+
const why = report.outcome === reap_outcome_1.REAP_OUTCOME_MISSING
|
|
135
|
+
? 'the child ended without reporting an outcome'
|
|
136
|
+
: `the child reported '${report.outcome}'`;
|
|
137
|
+
return `\n ⚠️ The worktree was NOT removed — ${why}. It is still on disk,\n`
|
|
138
|
+
+ ' and this shell is still standing in it. Nothing was forced.\n';
|
|
115
139
|
}
|
|
116
140
|
/**
|
|
117
141
|
* The #512 notice, kept verbatim in spirit for every case the re-exec cannot cover. An honest
|
|
@@ -155,6 +179,7 @@ let LandedWorktreeReaper = class LandedWorktreeReaper {
|
|
|
155
179
|
exports.LandedWorktreeReaper = LandedWorktreeReaper;
|
|
156
180
|
exports.LandedWorktreeReaper = LandedWorktreeReaper = tslib_1.__decorate([
|
|
157
181
|
(0, inversify_1.injectable)(inversify_1.bindingScopeValues.Singleton),
|
|
158
|
-
tslib_1.__metadata("design:paramtypes", [rules_config_1.WorktreeService
|
|
182
|
+
tslib_1.__metadata("design:paramtypes", [rules_config_1.WorktreeService,
|
|
183
|
+
reap_outcome_1.ReapOutcomeSignal])
|
|
159
184
|
], LandedWorktreeReaper);
|
|
160
185
|
//# sourceMappingURL=landed-worktree-reaper.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"landed-worktree-reaper.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/pr-gate/src/scripts/workflow/landed-worktree-reaper.ts"],"names":[],"mappings":";;;;AAAA,iDAA0C;AAC1C,+CAAyB;AACzB,mDAA6B;AAC7B,0DAA4E;AAC5E,yCAA2D;AAE3D;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AAEH,MAAM,GAAG,GAAG,0DAA0D,CAAC;AAEvE;;;GAGG;AACH,MAAa,mBAAmB;IAC5B,mEAAmE;IAC1D,YAAY,CAAS;IACrB,MAAM,CAAS;IACxB,gGAAgG;IACvF,WAAW,CAAS;IAC7B,kFAAkF;IACzE,WAAW,CAAS;IAC7B,kGAAkG;IACzF,cAAc,CAAS;IAChC,8FAA8F;IACrF,OAAO,CAAU;IAE1B,yDAAyD;IACzD,YACI,YAAoB,EACpB,MAAc,EACd,WAAmB,EACnB,WAAmB,EACnB,cAAsB;QAEtB,IAAI,CAAC,YAAY,GAAG,YAAY,CAAC;QACjC,IAAI,CAAC,MAAM,GAAG,MAAM,CAAC;QACrB,IAAI,CAAC,WAAW,GAAG,WAAW,CAAC;QAC/B,IAAI,CAAC,WAAW,GAAG,WAAW,CAAC;QAC/B,IAAI,CAAC,cAAc,GAAG,cAAc,CAAC;QACrC,IAAI,CAAC,OAAO,GAAG,cAAc,KAAK,EAAE,CAAC;IACzC,CAAC;CACJ;AA5BD,kDA4BC;AAGM,IAAM,oBAAoB,GAA1B,MAAM,oBAAoB;IACA;IAA7B,YAA6B,SAA0B;QAA1B,cAAS,GAAT,SAAS,CAAiB;IAAG,CAAC;IAE3D;;;;;;;OAOG;IACH,IAAI,CAAC,QAAgB,EAAE,YAAoB;QACvC,MAAM,IAAI,GAAG,IAAI,CAAC,SAAS,CAAC,eAAe,CAAC,QAAQ,CAAC,CAAC;QACtD,IAAI,IAAI,KAAK,IAAI,IAAI,IAAI,CAAC,MAAM,IAAI,IAAI,CAAC,MAAM,KAAK,YAAY;YAAE,OAAO,IAAI,CAAC;QAE9E,MAAM,OAAO,GAAG,IAAI,CAAC,SAAS,CAAC,aAAa,CAAC,QAAQ,CAAC;aACjD,IAAI,CAAC,CAAC,IAAc,EAAW,EAAE,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;QACpD,IAAI,OAAO,KAAK,SAAS,EAAE,CAAC;YACxB,OAAO,IAAI,mBAAmB,CAC1B,IAAI,CAAC,IAAI,EAAE,YAAY,EAAE,qBAAqB,EAAE,EAAE,EAClD,gFAAgF,CAAC,CAAC;QAC1F,CAAC;QAED,MAAM,KAAK,GAAG,IAAI,CAAC,eAAe,EAAE,CAAC;QACrC,IAAI,KAAK,KAAK,EAAE,EAAE,CAAC;YACf,OAAO,IAAI,mBAAmB,CAC1B,IAAI,CAAC,IAAI,EAAE,YAAY,EAAE,OAAO,CAAC,IAAI,EAAE,EAAE,EACzC,uEAAuE,CAAC,CAAC;QACjF,CAAC;QACD,OAAO,IAAI,mBAAmB,CAAC,IAAI,CAAC,IAAI,EAAE,YAAY,EAAE,OAAO,CAAC,IAAI,EAAE,KAAK,EAAE,EAAE,CAAC,CAAC;IACrF,CAAC;IAED;;;;;;OAMG;IACH,OAAO,CAAC,OAA4B;QAChC,MAAM,MAAM,GAAG,IAAA,yBAAS,EACpB,OAAO,CAAC,QAAQ,EAAE,CAAC,OAAO,CAAC,WAAW,EAAE,OAAO,CAAC,YAAY,EAAE,OAAO,CAAC,MAAM,CAAC,EAC7E,EAAE,GAAG,EAAE,OAAO,CAAC,WAAW,EAAE,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,CAAC,CAAC;QAEvF,MAAM,MAAM,GAAG,GAAG,MAAM,CAAC,MAAM,IAAI,EAAE,GAAG,MAAM,CAAC,MAAM,IAAI,EAAE,EAAE,CAAC,OAAO,EAAE,CAAC;QACxE,MAAM,IAAI,GAAG,IAAI,GAAG,GAAG,GAAG,iCAAiC,OAAO,CAAC,WAAW,IAAI,GAAG,GAAG,GAAG,IAAI;cACzF,0FAA0F;cAC1F,0FAA0F,CAAC;QAEjG,IAAI,MAAM,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YACtB,OAAO,IAAI,GAAG,CAAC,MAAM,KAAK,EAAE,CAAC,CAAC,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC;kBAC5C,4CAA4C,MAAM,CAAC,MAAM,CAAC,MAAM,IAAI,CAAC,CAAC,CAAC,0BAA0B;kBACjG,IAAI,CAAC,YAAY,CAAC,OAAO,CAAC,CAAC;QACrC,CAAC;QACD,OAAO,IAAI,GAAG,CAAC,MAAM,KAAK,EAAE,CAAC,CAAC,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,GAAG,IAAI,CAAC,SAAS,CAAC,OAAO,CAAC,CAAC;IACjF,CAAC;IAED;;;OAGG;IACH,YAAY,CAAC,OAA4B;QACrC,MAAM,GAAG,GAAG,OAAO,CAAC,cAAc,KAAK,EAAE,CAAC,CAAC,CAAC,aAAa,OAAO,CAAC,cAAc,KAAK,CAAC,CAAC,CAAC,EAAE,CAAC;QAC1F,OAAO,4FAA4F;cAC7F,iFAAiF;cACjF,cAAc,IAAA,qBAAM,EAAC,OAAO,CAAC,WAAW,EAAE,iBAAiB,CAAC,IAAI;cAChE,GAAG;cACH,wBAAwB,OAAO,CAAC,MAAM,sBAAsB,OAAO,CAAC,YAAY,8BAA8B,CAAC;IACzH,CAAC;IAED;;;;OAIG;IACK,SAAS,CAAC,OAA4B;QAC1C,OAAO,YAAY,OAAO,CAAC,YAAY,2DAA2D;cAC5F,2EAA2E;YAC7E,yFAAyF;YACzF,0FAA0F;cACxF,kBAAkB,OAAO,CAAC,WAAW,KAAK,CAAC;IACrD,CAAC;IAED;;;;;;;;;OASG;IACO,eAAe;QACrB,MAAM,KAAK,GAAG,IAAI,CAAC,IAAI,CAAC,SAAS,EAAE,IAAI,EAAE,qBAAqB,CAAC,CAAC;QAChE,OAAO,EAAE,CAAC,UAAU,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,CAAC;IAC7C,CAAC;CACJ,CAAA;AAjGY,oDAAoB;+BAApB,oBAAoB;IADhC,IAAA,sBAAU,EAAC,8BAAkB,CAAC,SAAS,CAAC;6CAEG,8BAAe;GAD9C,oBAAoB,CAiGhC","sourcesContent":["import { spawnSync } from 'child_process';\nimport * as fs from 'fs';\nimport * as path from 'path';\nimport { Worktree, WorktreeService, atRoot } from '@webpieces/rules-config';\nimport { injectable, bindingScopeValues } from 'inversify';\n\n/**\n * The RE-EXEC half of `wp-land-pr`: reap the worktree the command is standing in, from a process that\n * is NOT standing in it.\n *\n * THE PROBLEM. `wp-land-pr` squash-merges the branch checked out in the worktree it runs in. The\n * moment that lands, both the branch and the directory are dead — but removing them from in here means\n * deleting the files underneath the running process, which is exactly what every rail in WorktreeReaper\n * refuses. #512 took this to an honest halfway point: print `cd <primary> && pnpm wp-cleanup` and stop.\n * Correct, and the single most-skipped step in the whole flow, because the PR is already landed and the\n * work FEELS done. Every skipped one leaves a corpse, and corpses are what trip branch-creation-guard.\n *\n * THE FIX. Do not reap in this process at all — hand the reap to a CHILD process whose `cwd` is the\n * PRIMARY CLONE. The child's cwd is a directory nobody is deleting, so `git worktree remove` is an\n * ordinary removal of somebody else's directory, and every existing rail still holds inside the child\n * (it refuses its own cwd, which is now the primary clone, and it refuses the primary clone by name).\n *\n * WHY `WorktreeService` AND NOT `EffectiveTreeResolver`. EffectiveTreeResolver (ai-hook-rules) answers\n * \"which tree does this BASH COMMAND STRING act on\" — it takes a command, resolves its leading `cd`\n * run, and classifies foreign/outside repos for the PreToolUse guards. There is no command string\n * here, pr-gate does not (and must not) depend on ai-hook-rules, and the only question being asked is\n * \"which of git's worktrees is the primary clone, and which one holds this branch\". That is\n * WorktreeService's whole job, and it is already the authority WorktreeReaper itself uses to decide\n * what is protected — using a second resolver would risk the two disagreeing about which tree we are\n * in, which for a directory deletion is the one disagreement nobody survives.\n *\n * WHY A CHILD AND NOT AN IN-PROCESS `chdir`. `process.chdir(primary)` would leave this node process's\n * own module graph rooted in a directory that is about to be deleted, and its lazily-resolved requires\n * with it. The child is spawned from THIS package's own files (same version — no skew with whatever\n * the primary clone has installed), and it finishes loading them before it removes anything.\n */\n\nconst SEP = '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\\n';\n\n/**\n * Data-only (per CLAUDE.md, classes for data). The decision about the just-landed worktree: which\n * directory, which branch, where the reap must run FROM, and — when it cannot run — why not.\n */\nexport class WorktreeReapHandoff {\n /** The linked worktree holding the branch that was just landed. */\n readonly worktreePath: string;\n readonly branch: string;\n /** The primary clone — the child's `cwd`, and the directory a human must `cd` to afterwards. */\n readonly primaryPath: string;\n /** The compiled entry the child runs. '' when it could not be located on disk. */\n readonly entryScript: string;\n /** '' when the hand-off can run; otherwise the reason it cannot, in one human-readable clause. */\n readonly blockedBecause: string;\n /** Precomputed (as EffectiveTree does with `redirected`) so no caller re-derives the rule. */\n readonly canReap: boolean;\n\n // eslint-disable-next-line @typescript-eslint/max-params\n constructor(\n worktreePath: string,\n branch: string,\n primaryPath: string,\n entryScript: string,\n blockedBecause: string,\n ) {\n this.worktreePath = worktreePath;\n this.branch = branch;\n this.primaryPath = primaryPath;\n this.entryScript = entryScript;\n this.blockedBecause = blockedBecause;\n this.canReap = blockedBecause === '';\n }\n}\n\n@injectable(bindingScopeValues.Singleton)\nexport class LandedWorktreeReaper {\n constructor(private readonly worktrees: WorktreeService) {}\n\n /**\n * Is there a worktree to reap at all, and can the hand-off run?\n *\n * `null` means \"nothing to do here\" — we are in the primary clone, or in a worktree that does not\n * hold the branch that just landed. That is the ordinary `pnpm wp-cleanup` case and needs no\n * special sentence. A non-null handoff with `canReap === false` means there IS a corpse but the\n * re-exec is not safely achievable, which is the case that must keep the manual notice.\n */\n plan(repoRoot: string, landedBranch: string): WorktreeReapHandoff | null {\n const here = this.worktrees.currentWorktree(repoRoot);\n if (here === null || here.isMain || here.branch !== landedBranch) return null;\n\n const primary = this.worktrees.listWorktrees(repoRoot)\n .find((tree: Worktree): boolean => tree.isMain);\n if (primary === undefined) {\n return new WorktreeReapHandoff(\n here.path, landedBranch, '<the primary clone>', '',\n 'git did not report a primary clone, so there is no safe directory to reap from');\n }\n\n const entry = this.reapEntryScript();\n if (entry === '') {\n return new WorktreeReapHandoff(\n here.path, landedBranch, primary.path, '',\n 'the reap entry point is not on disk (this package is running unbuilt)');\n }\n return new WorktreeReapHandoff(here.path, landedBranch, primary.path, entry, '');\n }\n\n /**\n * Run the reap in a child process rooted in the primary clone, and render what happened.\n *\n * stdin is `ignore` deliberately: the child must never be able to ask a question. A prompt printed\n * into a landing recap nobody is watching is not consent, and this reap is authorised by the merge\n * that just succeeded, not by an answer.\n */\n handOff(handoff: WorktreeReapHandoff): string {\n const result = spawnSync(\n process.execPath, [handoff.entryScript, handoff.worktreePath, handoff.branch],\n { cwd: handoff.primaryPath, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });\n\n const output = `${result.stdout ?? ''}${result.stderr ?? ''}`.trimEnd();\n const head = '\\n' + SEP + `🌲 Reaping this worktree from ${handoff.primaryPath}\\n` + SEP + '\\n'\n + ` The branch just landed, so this directory is dead. The reap runs in a child process\\n`\n + ` whose cwd is the primary clone — nothing deletes the directory it is standing in.\\n\\n`;\n\n if (result.status !== 0) {\n return head + (output !== '' ? output + '\\n' : '')\n + `\\n ⚠️ The reap did not complete (exit ${String(result.status ?? -1)}). Nothing was forced.\\n`\n + this.manualNotice(handoff);\n }\n return head + (output !== '' ? output + '\\n' : '') + this.afterReap(handoff);\n }\n\n /**\n * The #512 notice, kept verbatim in spirit for every case the re-exec cannot cover. An honest\n * limitation beats a command that deletes its own working directory mid-run.\n */\n manualNotice(handoff: WorktreeReapHandoff): string {\n const why = handoff.blockedBecause !== '' ? ` (${handoff.blockedBecause})\\n` : '';\n return ' Next: this branch is checked out in THIS worktree, so neither it nor the worktree can\\n'\n + ' be removed from in here. Run cleanup from the primary clone instead:\\n'\n + ` ${atRoot(handoff.primaryPath, 'pnpm wp-cleanup')}\\n`\n + why\n + ` It archives ${handoff.branch} as a tag, removes ${handoff.worktreePath}, then deletes the branch.\\n`;\n }\n\n /**\n * The one thing a human or agent MUST be told after a successful reap: the shell they typed this\n * into is now sitting in a directory that no longer exists. Every relative path from here on is a\n * mystery ENOENT unless they move.\n */\n private afterReap(handoff: WorktreeReapHandoff): string {\n return `\\n ⚠️ ${handoff.worktreePath} NO LONGER EXISTS — your shell is standing in a deleted\\n`\n + ' directory, and every following command will fail until you move:\\n'\n // Single-quoted for the same reason atRoot() quotes: a primary clone under a path with a\n // space (`/Users/dean hiller/…`, \"Google Drive\", iCloud) makes a bare `cd` two arguments.\n + ` cd '${handoff.primaryPath}'\\n`;\n }\n\n /**\n * WHERE the child's entry point lives — a seam, overridden in the spec, because under vitest this\n * package runs from `.ts` sources and the compiled sibling does not exist.\n *\n * Resolved from `__dirname`, i.e. from THIS package's own installed files, NOT by remapping a path\n * into the primary clone. The primary clone may have a different @webpieces/pr-gate version\n * installed, and re-exec'ing a build that predates this feature would silently do something else.\n * The files are read into the child before it removes anything, so the directory going away\n * underneath them afterwards is harmless.\n */\n protected reapEntryScript(): string {\n const entry = path.join(__dirname, '..', 'wp-reap-worktree.js');\n return fs.existsSync(entry) ? entry : '';\n }\n}\n"]}
|
|
1
|
+
{"version":3,"file":"landed-worktree-reaper.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/pr-gate/src/scripts/workflow/landed-worktree-reaper.ts"],"names":[],"mappings":"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","sourcesContent":["import { spawnSync } from 'child_process';\nimport * as fs from 'fs';\nimport * as path from 'path';\nimport { Worktree, WorktreeService, atRoot } from '@webpieces/rules-config';\nimport { injectable, bindingScopeValues } from 'inversify';\n\nimport { ReapOutcomeReport, ReapOutcomeSignal, REAP_OUTCOME_MISSING } from './reap-outcome';\n\n/**\n * The RE-EXEC half of `wp-land-pr`: reap the worktree the command is standing in, from a process that\n * is NOT standing in it.\n *\n * THE PROBLEM. `wp-land-pr` squash-merges the branch checked out in the worktree it runs in. The\n * moment that lands, both the branch and the directory are dead — but removing them from in here means\n * deleting the files underneath the running process, which is exactly what every rail in WorktreeReaper\n * refuses. #512 took this to an honest halfway point: print `cd <primary> && pnpm wp-cleanup` and stop.\n * Correct, and the single most-skipped step in the whole flow, because the PR is already landed and the\n * work FEELS done. Every skipped one leaves a corpse, and corpses are what trip branch-creation-guard.\n *\n * THE FIX. Do not reap in this process at all — hand the reap to a CHILD process whose `cwd` is the\n * PRIMARY CLONE. The child's cwd is a directory nobody is deleting, so `git worktree remove` is an\n * ordinary removal of somebody else's directory, and every existing rail still holds inside the child\n * (it refuses its own cwd, which is now the primary clone, and it refuses the primary clone by name).\n *\n * WHY `WorktreeService` AND NOT `EffectiveTreeResolver`. EffectiveTreeResolver (ai-hook-rules) answers\n * \"which tree does this BASH COMMAND STRING act on\" — it takes a command, resolves its leading `cd`\n * run, and classifies foreign/outside repos for the PreToolUse guards. There is no command string\n * here, pr-gate does not (and must not) depend on ai-hook-rules, and the only question being asked is\n * \"which of git's worktrees is the primary clone, and which one holds this branch\". That is\n * WorktreeService's whole job, and it is already the authority WorktreeReaper itself uses to decide\n * what is protected — using a second resolver would risk the two disagreeing about which tree we are\n * in, which for a directory deletion is the one disagreement nobody survives.\n *\n * WHY A CHILD AND NOT AN IN-PROCESS `chdir`. `process.chdir(primary)` would leave this node process's\n * own module graph rooted in a directory that is about to be deleted, and its lazily-resolved requires\n * with it. The child is spawned from THIS package's own files (same version — no skew with whatever\n * the primary clone has installed), and it finishes loading them before it removes anything.\n */\n\nconst SEP = '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\\n';\n\n/**\n * Data-only (per CLAUDE.md, classes for data). The decision about the just-landed worktree: which\n * directory, which branch, where the reap must run FROM, and — when it cannot run — why not.\n */\nexport class WorktreeReapHandoff {\n /** The linked worktree holding the branch that was just landed. */\n readonly worktreePath: string;\n readonly branch: string;\n /** The primary clone — the child's `cwd`, and the directory a human must `cd` to afterwards. */\n readonly primaryPath: string;\n /** The compiled entry the child runs. '' when it could not be located on disk. */\n readonly entryScript: string;\n /** '' when the hand-off can run; otherwise the reason it cannot, in one human-readable clause. */\n readonly blockedBecause: string;\n /** Precomputed (as EffectiveTree does with `redirected`) so no caller re-derives the rule. */\n readonly canReap: boolean;\n\n // eslint-disable-next-line @typescript-eslint/max-params\n constructor(\n worktreePath: string,\n branch: string,\n primaryPath: string,\n entryScript: string,\n blockedBecause: string,\n ) {\n this.worktreePath = worktreePath;\n this.branch = branch;\n this.primaryPath = primaryPath;\n this.entryScript = entryScript;\n this.blockedBecause = blockedBecause;\n this.canReap = blockedBecause === '';\n }\n}\n\n@injectable(bindingScopeValues.Singleton)\nexport class LandedWorktreeReaper {\n constructor(\n private readonly worktrees: WorktreeService,\n private readonly signal: ReapOutcomeSignal,\n ) {}\n\n /**\n * Is there a worktree to reap at all, and can the hand-off run?\n *\n * `null` means \"nothing to do here\" — we are in the primary clone, or in a worktree that does not\n * hold the branch that just landed. That is the ordinary `pnpm wp-cleanup` case and needs no\n * special sentence. A non-null handoff with `canReap === false` means there IS a corpse but the\n * re-exec is not safely achievable, which is the case that must keep the manual notice.\n */\n plan(repoRoot: string, landedBranch: string): WorktreeReapHandoff | null {\n const here = this.worktrees.currentWorktree(repoRoot);\n if (here === null || here.isMain || here.branch !== landedBranch) return null;\n\n const primary = this.worktrees.listWorktrees(repoRoot)\n .find((tree: Worktree): boolean => tree.isMain);\n if (primary === undefined) {\n return new WorktreeReapHandoff(\n here.path, landedBranch, '<the primary clone>', '',\n 'git did not report a primary clone, so there is no safe directory to reap from');\n }\n\n const entry = this.reapEntryScript();\n if (entry === '') {\n return new WorktreeReapHandoff(\n here.path, landedBranch, primary.path, '',\n 'the reap entry point is not on disk (this package is running unbuilt)');\n }\n return new WorktreeReapHandoff(here.path, landedBranch, primary.path, entry, '');\n }\n\n /**\n * Run the reap in a child process rooted in the primary clone, and render what happened.\n *\n * stdin is `ignore` deliberately: the child must never be able to ask a question. A prompt printed\n * into a landing recap nobody is watching is not consent, and this reap is authorised by the merge\n * that just succeeded, not by an answer.\n *\n * THE EXIT CODE IS NOT THE ANSWER. The child refuses by PRINTING and exiting 0 on purpose — a\n * non-zero exit after a successful merge would report a landed PR as a failed command. So `exit 0`\n * only means \"the child ran\"; whether the DIRECTORY is gone is a separate statement it makes through\n * ReapOutcomeSignal, and that is the one that gates the \"your cwd no longer exists\" notice.\n */\n handOff(handoff: WorktreeReapHandoff): string {\n const result = spawnSync(\n process.execPath, [handoff.entryScript, handoff.worktreePath, handoff.branch],\n { cwd: handoff.primaryPath, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });\n\n const report = this.signal.read(`${result.stdout ?? ''}${result.stderr ?? ''}`);\n const output = report.text.trimEnd();\n const head = '\\n' + SEP + `🌲 Reaping this worktree from ${handoff.primaryPath}\\n` + SEP + '\\n'\n + ` The branch just landed, so this directory is dead. The reap runs in a child process\\n`\n + ` whose cwd is the primary clone — nothing deletes the directory it is standing in.\\n\\n`;\n const body = head + (output !== '' ? output + '\\n' : '');\n\n if (result.status !== 0 || !report.removed) {\n return body + this.notRemoved(result.status, report) + this.manualNotice(handoff);\n }\n return body + this.afterReap(handoff);\n }\n\n /**\n * WHY the directory is still there, distinguishing the two cases a single exit code conflated: the\n * child broke (non-zero), versus the child worked perfectly and DECLINED (exit 0, outcome refused).\n * `--force` is absent from both, and from every path below them: git refusing to remove a worktree\n * holding untracked files is work no archive tag captured.\n */\n private notRemoved(status: number | null, report: ReapOutcomeReport): string {\n if (status !== 0) {\n return `\\n ⚠️ The reap did not complete (exit ${String(status ?? -1)}). Nothing was forced.\\n`;\n }\n const why = report.outcome === REAP_OUTCOME_MISSING\n ? 'the child ended without reporting an outcome'\n : `the child reported '${report.outcome}'`;\n return `\\n ⚠️ The worktree was NOT removed — ${why}. It is still on disk,\\n`\n + ' and this shell is still standing in it. Nothing was forced.\\n';\n }\n\n /**\n * The #512 notice, kept verbatim in spirit for every case the re-exec cannot cover. An honest\n * limitation beats a command that deletes its own working directory mid-run.\n */\n manualNotice(handoff: WorktreeReapHandoff): string {\n const why = handoff.blockedBecause !== '' ? ` (${handoff.blockedBecause})\\n` : '';\n return ' Next: this branch is checked out in THIS worktree, so neither it nor the worktree can\\n'\n + ' be removed from in here. Run cleanup from the primary clone instead:\\n'\n + ` ${atRoot(handoff.primaryPath, 'pnpm wp-cleanup')}\\n`\n + why\n + ` It archives ${handoff.branch} as a tag, removes ${handoff.worktreePath}, then deletes the branch.\\n`;\n }\n\n /**\n * The one thing a human or agent MUST be told after a successful reap: the shell they typed this\n * into is now sitting in a directory that no longer exists. Every relative path from here on is a\n * mystery ENOENT unless they move.\n */\n private afterReap(handoff: WorktreeReapHandoff): string {\n return `\\n ⚠️ ${handoff.worktreePath} NO LONGER EXISTS — your shell is standing in a deleted\\n`\n + ' directory, and every following command will fail until you move:\\n'\n // Single-quoted for the same reason atRoot() quotes: a primary clone under a path with a\n // space (`/Users/dean hiller/…`, \"Google Drive\", iCloud) makes a bare `cd` two arguments.\n + ` cd '${handoff.primaryPath}'\\n`;\n }\n\n /**\n * WHERE the child's entry point lives — a seam, overridden in the spec, because under vitest this\n * package runs from `.ts` sources and the compiled sibling does not exist.\n *\n * Resolved from `__dirname`, i.e. from THIS package's own installed files, NOT by remapping a path\n * into the primary clone. The primary clone may have a different @webpieces/pr-gate version\n * installed, and re-exec'ing a build that predates this feature would silently do something else.\n * The files are read into the child before it removes anything, so the directory going away\n * underneath them afterwards is harmless.\n */\n protected reapEntryScript(): string {\n const entry = path.join(__dirname, '..', 'wp-reap-worktree.js');\n return fs.existsSync(entry) ? entry : '';\n }\n}\n"]}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The CONTRACT between `wp-land-pr` and the reap child it spawns: what actually happened to the
|
|
3
|
+
* directory, said in one machine-readable token.
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS EXISTS AND AN EXIT CODE DOES NOT DO. The child (ReapWorktreeCommand) runs AFTER the PR has
|
|
6
|
+
* already merged, so it deliberately PRINTS its refusals and exits 0 — exiting non-zero there would
|
|
7
|
+
* report a landed PR as a failed command, which is a worse lie than the one this class fixes. That
|
|
8
|
+
* makes `exit 0` mean "the child ran to completion", never "the worktree is gone", and those two are
|
|
9
|
+
* different answers: "not provably dead", "now holds a different branch" and "not a removable worktree
|
|
10
|
+
* of this repo" are all exit-0 refusals in which the directory is still on disk.
|
|
11
|
+
*
|
|
12
|
+
* A parent reading only the status therefore announced a removal that never happened — it told the
|
|
13
|
+
* caller their cwd NO LONGER EXISTS while they were standing in it, and swallowed the one instruction
|
|
14
|
+
* (`pnpm wp-cleanup` from the primary clone) that would have finished the job. So the child states the
|
|
15
|
+
* outcome explicitly and the parent believes THAT, with the exit code kept for what it is actually
|
|
16
|
+
* evidence of: the child crashed or git failed.
|
|
17
|
+
*
|
|
18
|
+
* WHY A STDOUT TOKEN AND NOT A TEMP FILE. The parent already captures the child's stdout in full and
|
|
19
|
+
* reprints it; a token on that stream needs no path to agree on, no cleanup, and cannot be left behind
|
|
20
|
+
* by a crash to be misread by the next run. Version skew cannot desynchronise the two halves either:
|
|
21
|
+
* LandedWorktreeReaper spawns the child from ITS OWN package files (`__dirname`), never from whatever
|
|
22
|
+
* the primary clone has installed, so writer and reader are always the same build.
|
|
23
|
+
*/
|
|
24
|
+
/** The directory was removed. The ONLY outcome that authorises the "your cwd is gone" notice. */
|
|
25
|
+
export declare const REAP_OUTCOME_REMOVED = "removed";
|
|
26
|
+
/** Nothing was attempted: the target did not survive the child's own re-verification. */
|
|
27
|
+
export declare const REAP_OUTCOME_REFUSED = "refused";
|
|
28
|
+
/** A removal was attempted and git declined it (uncommitted or untracked files, typically). */
|
|
29
|
+
export declare const REAP_OUTCOME_FAILED = "failed";
|
|
30
|
+
/** No token in the child's output at all — it died before it could say. Treated as "not removed". */
|
|
31
|
+
export declare const REAP_OUTCOME_MISSING = "missing";
|
|
32
|
+
/**
|
|
33
|
+
* Data-only (per CLAUDE.md, classes for data): what the child said, and its output with the token
|
|
34
|
+
* removed so no human ever reads the wire format.
|
|
35
|
+
*/
|
|
36
|
+
export declare class ReapOutcomeReport {
|
|
37
|
+
readonly outcome: string;
|
|
38
|
+
/** The child's stdout+stderr, minus the token line(s). */
|
|
39
|
+
readonly text: string;
|
|
40
|
+
/** Precomputed so no caller re-derives the rule that only `removed` counts. */
|
|
41
|
+
readonly removed: boolean;
|
|
42
|
+
constructor(outcome: string, text: string);
|
|
43
|
+
}
|
|
44
|
+
export declare class ReapOutcomeSignal {
|
|
45
|
+
private static readonly PREFIX;
|
|
46
|
+
/** What the child writes, last, on stdout. */
|
|
47
|
+
line(outcome: string): string;
|
|
48
|
+
/**
|
|
49
|
+
* What the parent reads. The LAST token wins — a child that somehow emitted two has done something
|
|
50
|
+
* after the first, and the later statement is the more recent truth about the directory.
|
|
51
|
+
*/
|
|
52
|
+
read(childOutput: string): ReapOutcomeReport;
|
|
53
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var ReapOutcomeSignal_1;
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
exports.ReapOutcomeSignal = exports.ReapOutcomeReport = exports.REAP_OUTCOME_MISSING = exports.REAP_OUTCOME_FAILED = exports.REAP_OUTCOME_REFUSED = exports.REAP_OUTCOME_REMOVED = void 0;
|
|
5
|
+
const tslib_1 = require("tslib");
|
|
6
|
+
const inversify_1 = require("inversify");
|
|
7
|
+
/**
|
|
8
|
+
* The CONTRACT between `wp-land-pr` and the reap child it spawns: what actually happened to the
|
|
9
|
+
* directory, said in one machine-readable token.
|
|
10
|
+
*
|
|
11
|
+
* WHY THIS EXISTS AND AN EXIT CODE DOES NOT DO. The child (ReapWorktreeCommand) runs AFTER the PR has
|
|
12
|
+
* already merged, so it deliberately PRINTS its refusals and exits 0 — exiting non-zero there would
|
|
13
|
+
* report a landed PR as a failed command, which is a worse lie than the one this class fixes. That
|
|
14
|
+
* makes `exit 0` mean "the child ran to completion", never "the worktree is gone", and those two are
|
|
15
|
+
* different answers: "not provably dead", "now holds a different branch" and "not a removable worktree
|
|
16
|
+
* of this repo" are all exit-0 refusals in which the directory is still on disk.
|
|
17
|
+
*
|
|
18
|
+
* A parent reading only the status therefore announced a removal that never happened — it told the
|
|
19
|
+
* caller their cwd NO LONGER EXISTS while they were standing in it, and swallowed the one instruction
|
|
20
|
+
* (`pnpm wp-cleanup` from the primary clone) that would have finished the job. So the child states the
|
|
21
|
+
* outcome explicitly and the parent believes THAT, with the exit code kept for what it is actually
|
|
22
|
+
* evidence of: the child crashed or git failed.
|
|
23
|
+
*
|
|
24
|
+
* WHY A STDOUT TOKEN AND NOT A TEMP FILE. The parent already captures the child's stdout in full and
|
|
25
|
+
* reprints it; a token on that stream needs no path to agree on, no cleanup, and cannot be left behind
|
|
26
|
+
* by a crash to be misread by the next run. Version skew cannot desynchronise the two halves either:
|
|
27
|
+
* LandedWorktreeReaper spawns the child from ITS OWN package files (`__dirname`), never from whatever
|
|
28
|
+
* the primary clone has installed, so writer and reader are always the same build.
|
|
29
|
+
*/
|
|
30
|
+
/** The directory was removed. The ONLY outcome that authorises the "your cwd is gone" notice. */
|
|
31
|
+
exports.REAP_OUTCOME_REMOVED = 'removed';
|
|
32
|
+
/** Nothing was attempted: the target did not survive the child's own re-verification. */
|
|
33
|
+
exports.REAP_OUTCOME_REFUSED = 'refused';
|
|
34
|
+
/** A removal was attempted and git declined it (uncommitted or untracked files, typically). */
|
|
35
|
+
exports.REAP_OUTCOME_FAILED = 'failed';
|
|
36
|
+
/** No token in the child's output at all — it died before it could say. Treated as "not removed". */
|
|
37
|
+
exports.REAP_OUTCOME_MISSING = 'missing';
|
|
38
|
+
/**
|
|
39
|
+
* Data-only (per CLAUDE.md, classes for data): what the child said, and its output with the token
|
|
40
|
+
* removed so no human ever reads the wire format.
|
|
41
|
+
*/
|
|
42
|
+
class ReapOutcomeReport {
|
|
43
|
+
outcome;
|
|
44
|
+
/** The child's stdout+stderr, minus the token line(s). */
|
|
45
|
+
text;
|
|
46
|
+
/** Precomputed so no caller re-derives the rule that only `removed` counts. */
|
|
47
|
+
removed;
|
|
48
|
+
constructor(outcome, text) {
|
|
49
|
+
this.outcome = outcome;
|
|
50
|
+
this.text = text;
|
|
51
|
+
this.removed = outcome === exports.REAP_OUTCOME_REMOVED;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
exports.ReapOutcomeReport = ReapOutcomeReport;
|
|
55
|
+
let ReapOutcomeSignal = class ReapOutcomeSignal {
|
|
56
|
+
static { ReapOutcomeSignal_1 = this; }
|
|
57
|
+
static PREFIX = 'WP_REAP_OUTCOME=';
|
|
58
|
+
/** What the child writes, last, on stdout. */
|
|
59
|
+
line(outcome) {
|
|
60
|
+
return `${ReapOutcomeSignal_1.PREFIX}${outcome}\n`;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* What the parent reads. The LAST token wins — a child that somehow emitted two has done something
|
|
64
|
+
* after the first, and the later statement is the more recent truth about the directory.
|
|
65
|
+
*/
|
|
66
|
+
read(childOutput) {
|
|
67
|
+
let outcome = exports.REAP_OUTCOME_MISSING;
|
|
68
|
+
const kept = [];
|
|
69
|
+
for (const rawLine of childOutput.split('\n')) {
|
|
70
|
+
const trimmed = rawLine.trim();
|
|
71
|
+
if (trimmed.startsWith(ReapOutcomeSignal_1.PREFIX)) {
|
|
72
|
+
outcome = trimmed.slice(ReapOutcomeSignal_1.PREFIX.length);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
kept.push(rawLine);
|
|
76
|
+
}
|
|
77
|
+
return new ReapOutcomeReport(outcome, kept.join('\n'));
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
exports.ReapOutcomeSignal = ReapOutcomeSignal;
|
|
81
|
+
exports.ReapOutcomeSignal = ReapOutcomeSignal = ReapOutcomeSignal_1 = tslib_1.__decorate([
|
|
82
|
+
(0, inversify_1.injectable)(inversify_1.bindingScopeValues.Singleton)
|
|
83
|
+
], ReapOutcomeSignal);
|
|
84
|
+
//# sourceMappingURL=reap-outcome.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"reap-outcome.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/pr-gate/src/scripts/workflow/reap-outcome.ts"],"names":[],"mappings":";;;;;AAAA,yCAA2D;AAE3D;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,iGAAiG;AACpF,QAAA,oBAAoB,GAAG,SAAS,CAAC;AAC9C,yFAAyF;AAC5E,QAAA,oBAAoB,GAAG,SAAS,CAAC;AAC9C,+FAA+F;AAClF,QAAA,mBAAmB,GAAG,QAAQ,CAAC;AAC5C,qGAAqG;AACxF,QAAA,oBAAoB,GAAG,SAAS,CAAC;AAE9C;;;GAGG;AACH,MAAa,iBAAiB;IACjB,OAAO,CAAS;IACzB,0DAA0D;IACjD,IAAI,CAAS;IACtB,+EAA+E;IACtE,OAAO,CAAU;IAE1B,YAAY,OAAe,EAAE,IAAY;QACrC,IAAI,CAAC,OAAO,GAAG,OAAO,CAAC;QACvB,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC;QACjB,IAAI,CAAC,OAAO,GAAG,OAAO,KAAK,4BAAoB,CAAC;IACpD,CAAC;CACJ;AAZD,8CAYC;AAGM,IAAM,iBAAiB,GAAvB,MAAM,iBAAiB;;IAClB,MAAM,CAAU,MAAM,GAAG,kBAAkB,CAAC;IAEpD,8CAA8C;IAC9C,IAAI,CAAC,OAAe;QAChB,OAAO,GAAG,mBAAiB,CAAC,MAAM,GAAG,OAAO,IAAI,CAAC;IACrD,CAAC;IAED;;;OAGG;IACH,IAAI,CAAC,WAAmB;QACpB,IAAI,OAAO,GAAG,4BAAoB,CAAC;QACnC,MAAM,IAAI,GAAa,EAAE,CAAC;QAC1B,KAAK,MAAM,OAAO,IAAI,WAAW,CAAC,KAAK,CAAC,IAAI,CAAC,EAAE,CAAC;YAC5C,MAAM,OAAO,GAAG,OAAO,CAAC,IAAI,EAAE,CAAC;YAC/B,IAAI,OAAO,CAAC,UAAU,CAAC,mBAAiB,CAAC,MAAM,CAAC,EAAE,CAAC;gBAC/C,OAAO,GAAG,OAAO,CAAC,KAAK,CAAC,mBAAiB,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC;gBACzD,SAAS;YACb,CAAC;YACD,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;QACvB,CAAC;QACD,OAAO,IAAI,iBAAiB,CAAC,OAAO,EAAE,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC;IAC3D,CAAC;;AAxBQ,8CAAiB;4BAAjB,iBAAiB;IAD7B,IAAA,sBAAU,EAAC,8BAAkB,CAAC,SAAS,CAAC;GAC5B,iBAAiB,CAyB7B","sourcesContent":["import { injectable, bindingScopeValues } from 'inversify';\n\n/**\n * The CONTRACT between `wp-land-pr` and the reap child it spawns: what actually happened to the\n * directory, said in one machine-readable token.\n *\n * WHY THIS EXISTS AND AN EXIT CODE DOES NOT DO. The child (ReapWorktreeCommand) runs AFTER the PR has\n * already merged, so it deliberately PRINTS its refusals and exits 0 — exiting non-zero there would\n * report a landed PR as a failed command, which is a worse lie than the one this class fixes. That\n * makes `exit 0` mean \"the child ran to completion\", never \"the worktree is gone\", and those two are\n * different answers: \"not provably dead\", \"now holds a different branch\" and \"not a removable worktree\n * of this repo\" are all exit-0 refusals in which the directory is still on disk.\n *\n * A parent reading only the status therefore announced a removal that never happened — it told the\n * caller their cwd NO LONGER EXISTS while they were standing in it, and swallowed the one instruction\n * (`pnpm wp-cleanup` from the primary clone) that would have finished the job. So the child states the\n * outcome explicitly and the parent believes THAT, with the exit code kept for what it is actually\n * evidence of: the child crashed or git failed.\n *\n * WHY A STDOUT TOKEN AND NOT A TEMP FILE. The parent already captures the child's stdout in full and\n * reprints it; a token on that stream needs no path to agree on, no cleanup, and cannot be left behind\n * by a crash to be misread by the next run. Version skew cannot desynchronise the two halves either:\n * LandedWorktreeReaper spawns the child from ITS OWN package files (`__dirname`), never from whatever\n * the primary clone has installed, so writer and reader are always the same build.\n */\n\n/** The directory was removed. The ONLY outcome that authorises the \"your cwd is gone\" notice. */\nexport const REAP_OUTCOME_REMOVED = 'removed';\n/** Nothing was attempted: the target did not survive the child's own re-verification. */\nexport const REAP_OUTCOME_REFUSED = 'refused';\n/** A removal was attempted and git declined it (uncommitted or untracked files, typically). */\nexport const REAP_OUTCOME_FAILED = 'failed';\n/** No token in the child's output at all — it died before it could say. Treated as \"not removed\". */\nexport const REAP_OUTCOME_MISSING = 'missing';\n\n/**\n * Data-only (per CLAUDE.md, classes for data): what the child said, and its output with the token\n * removed so no human ever reads the wire format.\n */\nexport class ReapOutcomeReport {\n readonly outcome: string;\n /** The child's stdout+stderr, minus the token line(s). */\n readonly text: string;\n /** Precomputed so no caller re-derives the rule that only `removed` counts. */\n readonly removed: boolean;\n\n constructor(outcome: string, text: string) {\n this.outcome = outcome;\n this.text = text;\n this.removed = outcome === REAP_OUTCOME_REMOVED;\n }\n}\n\n@injectable(bindingScopeValues.Singleton)\nexport class ReapOutcomeSignal {\n private static readonly PREFIX = 'WP_REAP_OUTCOME=';\n\n /** What the child writes, last, on stdout. */\n line(outcome: string): string {\n return `${ReapOutcomeSignal.PREFIX}${outcome}\\n`;\n }\n\n /**\n * What the parent reads. The LAST token wins — a child that somehow emitted two has done something\n * after the first, and the later statement is the more recent truth about the directory.\n */\n read(childOutput: string): ReapOutcomeReport {\n let outcome = REAP_OUTCOME_MISSING;\n const kept: string[] = [];\n for (const rawLine of childOutput.split('\\n')) {\n const trimmed = rawLine.trim();\n if (trimmed.startsWith(ReapOutcomeSignal.PREFIX)) {\n outcome = trimmed.slice(ReapOutcomeSignal.PREFIX.length);\n continue;\n }\n kept.push(rawLine);\n }\n return new ReapOutcomeReport(outcome, kept.join('\\n'));\n }\n}\n"]}
|