@webpieces/pr-gate 0.4.497 → 0.4.498
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/cleanup-command.d.ts +42 -3
- package/src/scripts/commands/cleanup-command.js +69 -11
- package/src/scripts/commands/cleanup-command.js.map +1 -1
- package/src/scripts/commands/land-pr-command.d.ts +17 -2
- package/src/scripts/commands/land-pr-command.js +32 -3
- package/src/scripts/commands/land-pr-command.js.map +1 -1
- package/src/scripts/commands/worktree-cleanup.d.ts +45 -0
- package/src/scripts/commands/worktree-cleanup.js +130 -0
- package/src/scripts/commands/worktree-cleanup.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.498",
|
|
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",
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"directory": "packages/tooling/pr-gate"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@webpieces/rules-config": "0.4.
|
|
28
|
+
"@webpieces/rules-config": "0.4.498",
|
|
29
29
|
"@inversifyjs/binding-decorators": "1.1.5",
|
|
30
30
|
"inversify": "7.10.4",
|
|
31
31
|
"reflect-metadata": "0.2.2"
|
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
import { BranchArchiver, BranchReaper, RepoRootFinder } from '@webpieces/rules-config';
|
|
2
|
+
import { WorktreeCleanupSection } from './worktree-cleanup';
|
|
2
3
|
/**
|
|
3
|
-
* wp-cleanup: delete the local branches whose PR is already merged (or that
|
|
4
|
-
* about the ones that are merely probably-dead.
|
|
4
|
+
* wp-cleanup: remove the dead WORKTREES, delete the local branches whose PR is already merged (or that
|
|
5
|
+
* hold no commits), then ASK about the ones that are merely probably-dead.
|
|
6
|
+
*
|
|
7
|
+
* WHY WORKTREES ARE PART OF THIS: they were the half that never got reaped. The verdicts existed —
|
|
8
|
+
* merged-branches.ts has been writing a full `DeletableWorktree[]` into the cache all along — and their
|
|
9
|
+
* only consumer used them to BLOCK the next `git worktree add`, never to remove anything. Meanwhile a
|
|
10
|
+
* live worktree pins its branch, so the branch was spared too. Two things the tooling could PROVE were
|
|
11
|
+
* dead, accumulating forever, until the guard refused to create the next branch and the only remedy it
|
|
12
|
+
* could offer was loosening its own cap. See WorktreeCleanupSection and WorktreeReaper.
|
|
5
13
|
*
|
|
6
14
|
* WHY a named command instead of the `git branch -D a b c` the guards used to print: an AI agent
|
|
7
15
|
* reads a raw `-D` as destructive, so it asks permission and stops — which is exactly why branches
|
|
@@ -24,10 +32,41 @@ export declare class CleanupCommand {
|
|
|
24
32
|
private readonly repoRootFinder;
|
|
25
33
|
private readonly branchReaper;
|
|
26
34
|
private readonly archiver;
|
|
27
|
-
|
|
35
|
+
private readonly worktreeSection;
|
|
36
|
+
constructor(repoRootFinder: RepoRootFinder, branchReaper: BranchReaper, archiver: BranchArchiver, worktreeSection: WorktreeCleanupSection);
|
|
37
|
+
/**
|
|
38
|
+
* WORKTREES FIRST, then branches. The order is the fix, not a detail: a worktree HOLDS its branch,
|
|
39
|
+
* so that branch is spared `in-use` ("remove that worktree before deleting the branch") and nothing
|
|
40
|
+
* used to remove the worktree — so both piled up until branch-creation-guard refused to make the
|
|
41
|
+
* next one. Reaping the worktree takes its branch with it, and the branch pass then recomputes its
|
|
42
|
+
* verdicts from scratch against the post-removal truth.
|
|
43
|
+
*/
|
|
28
44
|
run(): Promise<void>;
|
|
45
|
+
private cleanUpBranches;
|
|
46
|
+
/**
|
|
47
|
+
* Reap the provably-dead worktrees, then ASK about the probably-dead ones — the same two-tier
|
|
48
|
+
* posture the branch half has, because it is the same verdict on the same branch.
|
|
49
|
+
*
|
|
50
|
+
* WorktreeReaper enforces the safety rails regardless of what is passed or answered: never the
|
|
51
|
+
* primary clone, never the tree this command is running in, and never `--force` (git's refusal to
|
|
52
|
+
* remove a worktree holding untracked or modified files is a feature, and forcing it is how a
|
|
53
|
+
* cleanup command becomes a data-loss command).
|
|
54
|
+
*/
|
|
55
|
+
private cleanUpWorktrees;
|
|
29
56
|
private report;
|
|
30
57
|
private reapedLine;
|
|
58
|
+
/**
|
|
59
|
+
* The spared branches a human can meaningfully rule on, grouped and ordered most-safe first.
|
|
60
|
+
*
|
|
61
|
+
* Branches spared as IN_USE — checked out in a worktree — are still excluded here, but the reason
|
|
62
|
+
* is no longer "git would simply refuse". That premise died the moment worktrees became reapable.
|
|
63
|
+
* The real reason is the ORDER in run(): the worktree pass has already run, so an IN_USE branch is
|
|
64
|
+
* one of exactly two things. Either its worktree was dead and the reap took the branch with it (so
|
|
65
|
+
* it is not in this list at all), or its worktree is one we are deliberately keeping — locked, held
|
|
66
|
+
* open by uncommitted work, or the one we are standing in — and offering to delete the branch out
|
|
67
|
+
* from under a live checkout is not a question worth asking. The pair is offered TOGETHER by the
|
|
68
|
+
* worktree prompt, which shows both the path and the branch it holds, or it is not offered at all.
|
|
69
|
+
*/
|
|
31
70
|
private promptable;
|
|
32
71
|
private classifiedBlock;
|
|
33
72
|
/**
|
|
@@ -5,6 +5,7 @@ const tslib_1 = require("tslib");
|
|
|
5
5
|
const readline = tslib_1.__importStar(require("readline"));
|
|
6
6
|
const rules_config_1 = require("@webpieces/rules-config");
|
|
7
7
|
const inversify_1 = require("inversify");
|
|
8
|
+
const worktree_cleanup_1 = require("./worktree-cleanup");
|
|
8
9
|
const SEP = '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n';
|
|
9
10
|
// One line of human-facing explanation per promptable classification, ordered most-safe first. These
|
|
10
11
|
// replace the single string `no merged PR found — a human must decide`, which covered all three of
|
|
@@ -18,8 +19,15 @@ const CLASSIFICATION_HEADINGS = {
|
|
|
18
19
|
+ ' Read the unique-commit counts before answering. This is the group to say no to if unsure.',
|
|
19
20
|
};
|
|
20
21
|
/**
|
|
21
|
-
* wp-cleanup: delete the local branches whose PR is already merged (or that
|
|
22
|
-
* about the ones that are merely probably-dead.
|
|
22
|
+
* wp-cleanup: remove the dead WORKTREES, delete the local branches whose PR is already merged (or that
|
|
23
|
+
* hold no commits), then ASK about the ones that are merely probably-dead.
|
|
24
|
+
*
|
|
25
|
+
* WHY WORKTREES ARE PART OF THIS: they were the half that never got reaped. The verdicts existed —
|
|
26
|
+
* merged-branches.ts has been writing a full `DeletableWorktree[]` into the cache all along — and their
|
|
27
|
+
* only consumer used them to BLOCK the next `git worktree add`, never to remove anything. Meanwhile a
|
|
28
|
+
* live worktree pins its branch, so the branch was spared too. Two things the tooling could PROVE were
|
|
29
|
+
* dead, accumulating forever, until the guard refused to create the next branch and the only remedy it
|
|
30
|
+
* could offer was loosening its own cap. See WorktreeCleanupSection and WorktreeReaper.
|
|
23
31
|
*
|
|
24
32
|
* WHY a named command instead of the `git branch -D a b c` the guards used to print: an AI agent
|
|
25
33
|
* reads a raw `-D` as destructive, so it asks permission and stops — which is exactly why branches
|
|
@@ -42,14 +50,27 @@ let CleanupCommand = class CleanupCommand {
|
|
|
42
50
|
repoRootFinder;
|
|
43
51
|
branchReaper;
|
|
44
52
|
archiver;
|
|
45
|
-
|
|
53
|
+
worktreeSection;
|
|
54
|
+
constructor(repoRootFinder, branchReaper, archiver, worktreeSection) {
|
|
46
55
|
this.repoRootFinder = repoRootFinder;
|
|
47
56
|
this.branchReaper = branchReaper;
|
|
48
57
|
this.archiver = archiver;
|
|
58
|
+
this.worktreeSection = worktreeSection;
|
|
49
59
|
}
|
|
60
|
+
/**
|
|
61
|
+
* WORKTREES FIRST, then branches. The order is the fix, not a detail: a worktree HOLDS its branch,
|
|
62
|
+
* so that branch is spared `in-use` ("remove that worktree before deleting the branch") and nothing
|
|
63
|
+
* used to remove the worktree — so both piled up until branch-creation-guard refused to make the
|
|
64
|
+
* next one. Reaping the worktree takes its branch with it, and the branch pass then recomputes its
|
|
65
|
+
* verdicts from scratch against the post-removal truth.
|
|
66
|
+
*/
|
|
50
67
|
async run() {
|
|
51
68
|
const repoRoot = this.repoRootFinder.resolveRepoRoot(process.cwd());
|
|
52
69
|
const retention = (0, rules_config_1.loadAndValidate)(repoRoot).prGate.landPr.branchRetention;
|
|
70
|
+
await this.cleanUpWorktrees(repoRoot, retention);
|
|
71
|
+
await this.cleanUpBranches(repoRoot, retention);
|
|
72
|
+
}
|
|
73
|
+
async cleanUpBranches(repoRoot, retention) {
|
|
53
74
|
// No cache argument: wp-cleanup recomputes the verdicts itself. The file on disk is allowed to
|
|
54
75
|
// go stale, and stale evidence is fine for BLOCKING but never for DELETING.
|
|
55
76
|
const result = this.branchReaper.reap(repoRoot, 'wp-cleanup', null, retention);
|
|
@@ -58,7 +79,7 @@ let CleanupCommand = class CleanupCommand {
|
|
|
58
79
|
if (promptable.length === 0)
|
|
59
80
|
return;
|
|
60
81
|
process.stdout.write(this.classifiedBlock(promptable));
|
|
61
|
-
const approved = await this.askWhichToDelete(promptable);
|
|
82
|
+
const approved = await this.askWhichToDelete(promptable, 'branch');
|
|
62
83
|
if (approved.length === 0) {
|
|
63
84
|
process.stdout.write('\nNothing deleted — the branches above were kept.\n');
|
|
64
85
|
return;
|
|
@@ -66,6 +87,33 @@ let CleanupCommand = class CleanupCommand {
|
|
|
66
87
|
const second = this.branchReaper.reapApproved(repoRoot, 'wp-cleanup', approved, retention);
|
|
67
88
|
process.stdout.write(this.report(second));
|
|
68
89
|
}
|
|
90
|
+
/**
|
|
91
|
+
* Reap the provably-dead worktrees, then ASK about the probably-dead ones — the same two-tier
|
|
92
|
+
* posture the branch half has, because it is the same verdict on the same branch.
|
|
93
|
+
*
|
|
94
|
+
* WorktreeReaper enforces the safety rails regardless of what is passed or answered: never the
|
|
95
|
+
* primary clone, never the tree this command is running in, and never `--force` (git's refusal to
|
|
96
|
+
* remove a worktree holding untracked or modified files is a feature, and forcing it is how a
|
|
97
|
+
* cleanup command becomes a data-loss command).
|
|
98
|
+
*/
|
|
99
|
+
async cleanUpWorktrees(repoRoot, retention) {
|
|
100
|
+
const verdicts = this.worktreeSection.verdicts(repoRoot);
|
|
101
|
+
const dead = this.worktreeSection.provablyDead(verdicts);
|
|
102
|
+
if (dead.length > 0) {
|
|
103
|
+
process.stdout.write(this.worktreeSection.report(this.worktreeSection.reap(repoRoot, 'wp-cleanup', dead, retention)));
|
|
104
|
+
}
|
|
105
|
+
process.stdout.write(this.worktreeSection.sparedBlock(verdicts, dead));
|
|
106
|
+
const promptable = this.worktreeSection.promptable(verdicts);
|
|
107
|
+
if (promptable.length === 0)
|
|
108
|
+
return;
|
|
109
|
+
process.stdout.write(this.worktreeSection.promptBlock(promptable));
|
|
110
|
+
const approved = await this.askWhichToDelete(promptable, 'worktree');
|
|
111
|
+
if (approved.length === 0) {
|
|
112
|
+
process.stdout.write('\nNothing removed — the worktrees above were kept.\n');
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
process.stdout.write(this.worktreeSection.report(this.worktreeSection.reap(repoRoot, 'wp-cleanup', approved, retention)));
|
|
116
|
+
}
|
|
69
117
|
report(result) {
|
|
70
118
|
if (result.reaped.length === 0 && result.failed.length === 0) {
|
|
71
119
|
return '\n✅ Nothing to clean up — no local branch is provably dead.\n';
|
|
@@ -93,9 +141,18 @@ let CleanupCommand = class CleanupCommand {
|
|
|
93
141
|
: '';
|
|
94
142
|
return ` ✓ ${entry.branch}${sha} — ${entry.reason}${archived}\n`;
|
|
95
143
|
}
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
144
|
+
/**
|
|
145
|
+
* The spared branches a human can meaningfully rule on, grouped and ordered most-safe first.
|
|
146
|
+
*
|
|
147
|
+
* Branches spared as IN_USE — checked out in a worktree — are still excluded here, but the reason
|
|
148
|
+
* is no longer "git would simply refuse". That premise died the moment worktrees became reapable.
|
|
149
|
+
* The real reason is the ORDER in run(): the worktree pass has already run, so an IN_USE branch is
|
|
150
|
+
* one of exactly two things. Either its worktree was dead and the reap took the branch with it (so
|
|
151
|
+
* it is not in this list at all), or its worktree is one we are deliberately keeping — locked, held
|
|
152
|
+
* open by uncommitted work, or the one we are standing in — and offering to delete the branch out
|
|
153
|
+
* from under a live checkout is not a question worth asking. The pair is offered TOGETHER by the
|
|
154
|
+
* worktree prompt, which shows both the path and the branch it holds, or it is not offered at all.
|
|
155
|
+
*/
|
|
99
156
|
promptable(spared) {
|
|
100
157
|
const out = [];
|
|
101
158
|
for (const classification of rules_config_1.PROMPTABLE_CLASSIFICATIONS) {
|
|
@@ -130,13 +187,13 @@ let CleanupCommand = class CleanupCommand {
|
|
|
130
187
|
* nobody can see must never be read as consent, and this is the one place in the tooling where a
|
|
131
188
|
* deletion is not backed by a proof.
|
|
132
189
|
*/
|
|
133
|
-
async askWhichToDelete(promptable) {
|
|
190
|
+
async askWhichToDelete(promptable, kind) {
|
|
134
191
|
if (process.stdin.isTTY !== true) {
|
|
135
|
-
process.stdout.write(
|
|
192
|
+
process.stdout.write(`\nNot a terminal — no ${kind} was deleted and nothing was assumed.\n`
|
|
136
193
|
+ 'Run `pnpm wp-cleanup` in an interactive shell to answer, or delete individually.\n');
|
|
137
194
|
return [];
|
|
138
195
|
}
|
|
139
|
-
const answer = (await this.question(`\nDelete which? [all / none / e.g. "1,3"] (default none): `)).trim().toLowerCase();
|
|
196
|
+
const answer = (await this.question(`\nDelete which ${kind}(s)? [all / none / e.g. "1,3"] (default none): `)).trim().toLowerCase();
|
|
140
197
|
if (answer === '' || answer === 'none' || answer === 'n')
|
|
141
198
|
return [];
|
|
142
199
|
if (answer === 'all' || answer === 'a')
|
|
@@ -171,6 +228,7 @@ exports.CleanupCommand = CleanupCommand = tslib_1.__decorate([
|
|
|
171
228
|
(0, inversify_1.injectable)(inversify_1.bindingScopeValues.Singleton),
|
|
172
229
|
tslib_1.__metadata("design:paramtypes", [rules_config_1.RepoRootFinder,
|
|
173
230
|
rules_config_1.BranchReaper,
|
|
174
|
-
rules_config_1.BranchArchiver
|
|
231
|
+
rules_config_1.BranchArchiver,
|
|
232
|
+
worktree_cleanup_1.WorktreeCleanupSection])
|
|
175
233
|
], CleanupCommand);
|
|
176
234
|
//# sourceMappingURL=cleanup-command.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cleanup-command.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/pr-gate/src/scripts/commands/cleanup-command.ts"],"names":[],"mappings":";;;;AAAA,2DAAqC;AACrC,0DAYiC;AACjC,yCAA2D;AAE3D,MAAM,GAAG,GAAG,0DAA0D,CAAC;AAEvE,qGAAqG;AACrG,mGAAmG;AACnG,8EAA8E;AAC9E,MAAM,uBAAuB,GAAqC;IAC9D,CAAC,wCAAyB,CAAC,EACvB,kGAAkG;UAChG,qGAAqG;IAC3G,CAAC,6CAA8B,CAAC,EAC5B,8FAA8F;UAC5F,uEAAuE;IAC7E,CAAC,4CAA6B,CAAC,EAC3B,gGAAgG;UAC9F,6FAA6F;CACtG,CAAC;AAEF;;;;;;;;;;;;;;;;;;;;GAoBG;AAEI,IAAM,cAAc,GAApB,MAAM,cAAc;IAEF;IACA;IACA;IAHrB,YACqB,cAA8B,EAC9B,YAA0B,EAC1B,QAAwB;QAFxB,mBAAc,GAAd,cAAc,CAAgB;QAC9B,iBAAY,GAAZ,YAAY,CAAc;QAC1B,aAAQ,GAAR,QAAQ,CAAgB;IAC1C,CAAC;IAEJ,KAAK,CAAC,GAAG;QACL,MAAM,QAAQ,GAAG,IAAI,CAAC,cAAc,CAAC,eAAe,CAAC,OAAO,CAAC,GAAG,EAAE,CAAC,CAAC;QACpE,MAAM,SAAS,GAAG,IAAA,8BAAe,EAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,MAAM,CAAC,eAAe,CAAC;QAC1E,+FAA+F;QAC/F,4EAA4E;QAC5E,MAAM,MAAM,GAAG,IAAI,CAAC,YAAY,CAAC,IAAI,CAAC,QAAQ,EAAE,YAAY,EAAE,IAAI,EAAE,SAAS,CAAC,CAAC;QAC/E,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC;QAE1C,MAAM,UAAU,GAAG,IAAI,CAAC,UAAU,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC;QAClD,IAAI,UAAU,CAAC,MAAM,KAAK,CAAC;YAAE,OAAO;QACpC,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC,eAAe,CAAC,UAAU,CAAC,CAAC,CAAC;QACvD,MAAM,QAAQ,GAAG,MAAM,IAAI,CAAC,gBAAgB,CAAC,UAAU,CAAC,CAAC;QACzD,IAAI,QAAQ,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YACxB,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,qDAAqD,CAAC,CAAC;YAC5E,OAAO;QACX,CAAC;QACD,MAAM,MAAM,GAAG,IAAI,CAAC,YAAY,CAAC,YAAY,CAAC,QAAQ,EAAE,YAAY,EAAE,QAAQ,EAAE,SAAS,CAAC,CAAC;QAC3F,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC;IAC9C,CAAC;IAEO,MAAM,CAAC,MAAkB;QAC7B,IAAI,MAAM,CAAC,MAAM,CAAC,MAAM,KAAK,CAAC,IAAI,MAAM,CAAC,MAAM,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YAC3D,OAAO,+DAA+D,CAAC;QAC3E,CAAC;QAED,IAAI,GAAG,GAAG,IAAI,GAAG,GAAG,GAAG,iBAAiB,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,0BAA0B,GAAG,GAAG,GAAG,IAAI,CAAC;QAC5G,KAAK,MAAM,KAAK,IAAI,MAAM,CAAC,MAAM;YAAE,GAAG,IAAI,IAAI,CAAC,UAAU,CAAC,KAAK,CAAC,CAAC;QAEjE,IAAI,MAAM,CAAC,MAAM,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;YAC3B,GAAG,IAAI,SAAS,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,qCAAqC,CAAC;YAClF,KAAK,MAAM,KAAK,IAAI,MAAM,CAAC,MAAM;gBAAE,GAAG,IAAI,OAAO,KAAK,CAAC,MAAM,MAAM,KAAK,CAAC,KAAK,IAAI,CAAC;QACvF,CAAC;QAED,6FAA6F;QAC7F,+EAA+E;QAC/E,GAAG,IAAI,iGAAiG;cAClG,gEAAgE,CAAC;QACvE,OAAO,GAAG,CAAC;IACf,CAAC;IAEO,UAAU,CAAC,KAAmB;QAClC,MAAM,GAAG,GAAG,KAAK,CAAC,GAAG,KAAK,EAAE,CAAC,CAAC,CAAC,SAAS,KAAK,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC;QACtE,gGAAgG;QAChG,4FAA4F;QAC5F,MAAM,QAAQ,GAAG,KAAK,CAAC,UAAU,KAAK,EAAE;YACpC,CAAC,CAAC,sBAAsB,KAAK,CAAC,UAAU,gBAAgB,IAAI,CAAC,QAAQ,CAAC,cAAc,CAAC,KAAK,CAAC,MAAM,EAAE,KAAK,CAAC,UAAU,CAAC,GAAG;YACvH,CAAC,CAAC,EAAE,CAAC;QACT,OAAO,OAAO,KAAK,CAAC,MAAM,GAAG,GAAG,MAAM,KAAK,CAAC,MAAM,GAAG,QAAQ,IAAI,CAAC;IACtE,CAAC;IAED,6FAA6F;IAC7F,iGAAiG;IACjG,0DAA0D;IAClD,UAAU,CAAC,MAAyB;QACxC,MAAM,GAAG,GAAsB,EAAE,CAAC;QAClC,KAAK,MAAM,cAAc,IAAI,yCAA0B,EAAE,CAAC;YACtD,KAAK,MAAM,KAAK,IAAI,MAAM,EAAE,CAAC;gBACzB,IAAI,KAAK,CAAC,cAAc,KAAK,cAAc;oBAAE,GAAG,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;YACjE,CAAC;QACL,CAAC;QACD,OAAO,GAAG,CAAC;IACf,CAAC;IAED,oGAAoG;IACpG,0FAA0F;IAClF,eAAe,CAAC,UAA6B;QACjD,IAAI,GAAG,GAAG,IAAI,GAAG,GAAG,GAAG,MAAM,MAAM,CAAC,UAAU,CAAC,MAAM,CAAC,6CAA6C,GAAG,GAAG,CAAC;QAC1G,IAAI,OAAO,GAAG,EAAE,CAAC;QACjB,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,UAAU,CAAC,MAAM,EAAE,CAAC,IAAI,CAAC,EAAE,CAAC;YAC5C,MAAM,KAAK,GAAG,UAAU,CAAC,CAAC,CAAC,CAAC;YAC5B,IAAI,KAAK,CAAC,cAAc,KAAK,OAAO,EAAE,CAAC;gBACnC,OAAO,GAAG,KAAK,CAAC,cAAc,CAAC;gBAC/B,GAAG,IAAI,KAAK,uBAAuB,CAAC,OAAO,CAAC,IAAI,OAAO,MAAM,CAAC;YAClE,CAAC;YACD,MAAM,OAAO,GAAG,KAAK,CAAC,OAAO,IAAI,CAAC,CAAC,CAAC,CAAC,GAAG,MAAM,CAAC,KAAK,CAAC,OAAO,CAAC,mBAAmB,CAAC,CAAC,CAAC,wBAAwB,CAAC;YAC5G,GAAG,IAAI,MAAM,MAAM,CAAC,CAAC,GAAG,CAAC,CAAC,KAAK,KAAK,CAAC,MAAM,aAAa,OAAO,MAAM,KAAK,CAAC,MAAM,IAAI,CAAC;QAC1F,CAAC;QACD,OAAO,GAAG,CAAC;IACf,CAAC;IAED;;;;;;;OAOG;IACK,KAAK,CAAC,gBAAgB,CAAC,UAA6B;QACxD,IAAI,OAAO,CAAC,KAAK,CAAC,KAAK,KAAK,IAAI,EAAE,CAAC;YAC/B,OAAO,CAAC,MAAM,CAAC,KAAK,CAChB,mEAAmE;kBACjE,oFAAoF,CACzF,CAAC;YACF,OAAO,EAAE,CAAC;QACd,CAAC;QACD,MAAM,MAAM,GAAG,CAAC,MAAM,IAAI,CAAC,QAAQ,CAC/B,4DAA4D,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC,WAAW,EAAE,CAAC;QACxF,IAAI,MAAM,KAAK,EAAE,IAAI,MAAM,KAAK,MAAM,IAAI,MAAM,KAAK,GAAG;YAAE,OAAO,EAAE,CAAC;QACpE,IAAI,MAAM,KAAK,KAAK,IAAI,MAAM,KAAK,GAAG;YAAE,OAAO,UAAU,CAAC;QAC1D,OAAO,IAAI,CAAC,YAAY,CAAC,UAAU,EAAE,MAAM,CAAC,CAAC;IACjD,CAAC;IAED,mGAAmG;IACnG,2EAA2E;IACnE,YAAY,CAAC,UAA6B,EAAE,MAAc;QAC9D,MAAM,GAAG,GAAsB,EAAE,CAAC;QAClC,KAAK,MAAM,KAAK,IAAI,MAAM,CAAC,KAAK,CAAC,QAAQ,CAAC,EAAE,CAAC;YACzC,MAAM,KAAK,GAAG,MAAM,CAAC,KAAK,CAAC,CAAC;YAC5B,IAAI,CAAC,MAAM,CAAC,SAAS,CAAC,KAAK,CAAC,IAAI,KAAK,GAAG,CAAC,IAAI,KAAK,GAAG,UAAU,CAAC,MAAM;gBAAE,SAAS;YACjF,GAAG,CAAC,IAAI,CAAC,UAAU,CAAC,KAAK,GAAG,CAAC,CAAC,CAAC,CAAC;QACpC,CAAC;QACD,OAAO,GAAG,CAAC;IACf,CAAC;IAED,mFAAmF;IACzE,QAAQ,CAAC,MAAc;QAC7B,MAAM,EAAE,GAAG,QAAQ,CAAC,eAAe,CAAC,EAAE,KAAK,EAAE,OAAO,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;QACtF,OAAO,IAAI,OAAO,CAAS,CAAC,OAAgC,EAAQ,EAAE;YAClE,EAAE,CAAC,QAAQ,CAAC,MAAM,EAAE,CAAC,MAAc,EAAQ,EAAE;gBACzC,EAAE,CAAC,KAAK,EAAE,CAAC;gBACX,OAAO,CAAC,MAAM,CAAC,CAAC;YACpB,CAAC,CAAC,CAAC;QACP,CAAC,CAAC,CAAC;IACP,CAAC;CACJ,CAAA;AApIY,wCAAc;yBAAd,cAAc;IAD1B,IAAA,sBAAU,EAAC,8BAAkB,CAAC,SAAS,CAAC;6CAGA,6BAAc;QAChB,2BAAY;QAChB,6BAAc;GAJpC,cAAc,CAoI1B","sourcesContent":["import * as readline from 'readline';\nimport {\n BranchArchiver,\n BranchReaper,\n DeletableBranch,\n ReapResult,\n ReapedBranch,\n RepoRootFinder,\n loadAndValidate,\n CLASSIFICATION_SUPERSEDED,\n CLASSIFICATION_CONTENT_IN_MAIN,\n CLASSIFICATION_NEVER_PROPOSED,\n PROMPTABLE_CLASSIFICATIONS,\n} from '@webpieces/rules-config';\nimport { injectable, bindingScopeValues } from 'inversify';\n\nconst SEP = '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\\n';\n\n// One line of human-facing explanation per promptable classification, ordered most-safe first. These\n// replace the single string `no merged PR found — a human must decide`, which covered all three of\n// these situations identically and so told a human nothing they could act on.\nconst CLASSIFICATION_HEADINGS: Readonly<Record<string, string>> = {\n [CLASSIFICATION_SUPERSEDED]:\n 'SUPERSEDED — the PR was closed WITHOUT merging and later PRs have merged since. Near-certainly\\n'\n + ' the abandoned first attempt at work that landed under a different number. Safest group to delete.',\n [CLASSIFICATION_CONTENT_IN_MAIN]:\n 'CONTENT ALREADY IN MAIN — every commit has a patch-equivalent in origin/main (git cherry).\\n'\n + ' The work is not unique to this branch; only the commit objects are.',\n [CLASSIFICATION_NEVER_PROPOSED]:\n 'NEVER PROPOSED — no PR was ever opened, and these commits may be the ONLY copy in existence.\\n'\n + ' Read the unique-commit counts before answering. This is the group to say no to if unsure.',\n};\n\n/**\n * wp-cleanup: delete the local branches whose PR is already merged (or that hold no commits), then ASK\n * about the ones that are merely probably-dead.\n *\n * WHY a named command instead of the `git branch -D a b c` the guards used to print: an AI agent\n * reads a raw `-D` as destructive, so it asks permission and stops — which is exactly why branches\n * piled up despite the tooling knowing precisely which ones were dead. `pnpm wp-cleanup` is one\n * boring, allowlistable verb whose safety is a property of the command itself rather than of the\n * agent's judgement about a git flag.\n *\n * WHY IT NOW PROMPTS: sparing silently was the other half of the same problem. Every spared branch\n * reported the identical `no merged PR found — a human must decide`, so the human could not decide,\n * so nothing got deleted, so the pile grew until branch-creation-guard refused to make the next branch\n * and an agent went looking for a config knob to loosen. Shown a real classification with unique-commit\n * counts, the human in that session answered in five words: \"these should all be delete branches\".\n * The prompt is cheap because archiving happens FIRST — a yes costs a tag, not the history.\n *\n * All the danger still lives in the verdicts, not here — see BranchReaper for why every AUTOMATICALLY\n * deleted branch is provably dead and recoverable, and note that nothing in the prompted group is ever\n * deleted without an explicit typed answer.\n */\n@injectable(bindingScopeValues.Singleton)\nexport class CleanupCommand {\n constructor(\n private readonly repoRootFinder: RepoRootFinder,\n private readonly branchReaper: BranchReaper,\n private readonly archiver: BranchArchiver,\n ) {}\n\n async run(): Promise<void> {\n const repoRoot = this.repoRootFinder.resolveRepoRoot(process.cwd());\n const retention = loadAndValidate(repoRoot).prGate.landPr.branchRetention;\n // No cache argument: wp-cleanup recomputes the verdicts itself. The file on disk is allowed to\n // go stale, and stale evidence is fine for BLOCKING but never for DELETING.\n const result = this.branchReaper.reap(repoRoot, 'wp-cleanup', null, retention);\n process.stdout.write(this.report(result));\n\n const promptable = this.promptable(result.spared);\n if (promptable.length === 0) return;\n process.stdout.write(this.classifiedBlock(promptable));\n const approved = await this.askWhichToDelete(promptable);\n if (approved.length === 0) {\n process.stdout.write('\\nNothing deleted — the branches above were kept.\\n');\n return;\n }\n const second = this.branchReaper.reapApproved(repoRoot, 'wp-cleanup', approved, retention);\n process.stdout.write(this.report(second));\n }\n\n private report(result: ReapResult): string {\n if (result.reaped.length === 0 && result.failed.length === 0) {\n return '\\n✅ Nothing to clean up — no local branch is provably dead.\\n';\n }\n\n let out = '\\n' + SEP + `🧹 Cleaned up ${String(result.reaped.length)} dead local branch(es)\\n` + SEP + '\\n';\n for (const entry of result.reaped) out += this.reapedLine(entry);\n\n if (result.failed.length > 0) {\n out += `\\n⚠️ ${String(result.failed.length)} branch(es) could not be deleted:\\n`;\n for (const entry of result.failed) out += ` ✗ ${entry.branch} — ${entry.error}\\n`;\n }\n\n // Printed even on success: a deletion the human cannot undo is a deletion they have to trust\n // blindly, and the whole argument for auto-cleanup is that they never have to.\n out += '\\nEvery deletion is logged with its pre-delete SHA in .webpieces/hooks/branch-mutations.log —\\n'\n + 'recover any of them with the `recover=` command on its line.\\n';\n return out;\n }\n\n private reapedLine(entry: ReapedBranch): string {\n const sha = entry.sha !== '' ? ` (was ${entry.sha.slice(0, 8)})` : '';\n // The archive tag is printed inline because it is the ONE thing that makes this delete casually\n // reversible — a name a human can type, rather than a sha they have to go dig out of a log.\n const archived = entry.archiveTag !== ''\n ? `\\n archived → ${entry.archiveTag} (restore: ${this.archiver.restoreCommand(entry.branch, entry.archiveTag)})`\n : '';\n return ` ✓ ${entry.branch}${sha} — ${entry.reason}${archived}\\n`;\n }\n\n // The spared branches a human can meaningfully rule on, grouped and ordered most-safe first.\n // Branches spared for a MECHANICAL reason (checked out in a worktree) are deliberately excluded:\n // there is no judgement to make, git would simply refuse.\n private promptable(spared: DeletableBranch[]): DeletableBranch[] {\n const out: DeletableBranch[] = [];\n for (const classification of PROMPTABLE_CLASSIFICATIONS) {\n for (const entry of spared) {\n if (entry.classification === classification) out.push(entry);\n }\n }\n return out;\n }\n\n // The classification table: what each group means, and per branch its unique-commit count — the one\n // number that says whether a yes costs nothing or costs the only copy of somebody's work.\n private classifiedBlock(promptable: DeletableBranch[]): string {\n let out = '\\n' + SEP + `🤔 ${String(promptable.length)} branch(es) are probably dead — your call\\n` + SEP;\n let current = '';\n for (let i = 0; i < promptable.length; i += 1) {\n const entry = promptable[i];\n if (entry.classification !== current) {\n current = entry.classification;\n out += `\\n${CLASSIFICATION_HEADINGS[current] ?? current}\\n\\n`;\n }\n const commits = entry.commits >= 0 ? `${String(entry.commits)} unique commit(s)` : 'unique commits unknown';\n out += ` [${String(i + 1)}] ${entry.branch}\\n ${commits} — ${entry.reason}\\n`;\n }\n return out;\n }\n\n /**\n * Ask which of the classified branches to delete. Answers: `all`, `none` (default), or a\n * comma/space-separated list of the numbers shown.\n *\n * NON-INTERACTIVE (no TTY — CI, a hook, a piped agent shell) answers NONE and says so. A prompt\n * nobody can see must never be read as consent, and this is the one place in the tooling where a\n * deletion is not backed by a proof.\n */\n private async askWhichToDelete(promptable: DeletableBranch[]): Promise<DeletableBranch[]> {\n if (process.stdin.isTTY !== true) {\n process.stdout.write(\n '\\nNot a terminal — nothing was deleted and nothing was assumed.\\n'\n + 'Run `pnpm wp-cleanup` in an interactive shell to answer, or delete individually.\\n',\n );\n return [];\n }\n const answer = (await this.question(\n `\\nDelete which? [all / none / e.g. \"1,3\"] (default none): `)).trim().toLowerCase();\n if (answer === '' || answer === 'none' || answer === 'n') return [];\n if (answer === 'all' || answer === 'a') return promptable;\n return this.pickByNumber(promptable, answer);\n }\n\n // Parse `1,3` / `1 3` into branches, ignoring anything out of range. An unparseable answer selects\n // nothing, which is the fail-safe direction for a question about deleting.\n private pickByNumber(promptable: DeletableBranch[], answer: string): DeletableBranch[] {\n const out: DeletableBranch[] = [];\n for (const token of answer.split(/[\\s,]+/)) {\n const index = Number(token);\n if (!Number.isInteger(index) || index < 1 || index > promptable.length) continue;\n out.push(promptable[index - 1]);\n }\n return out;\n }\n\n // Seam: overridden in the spec so the prompt parsing is testable with no terminal.\n protected question(prompt: string): Promise<string> {\n const rl = readline.createInterface({ input: process.stdin, output: process.stdout });\n return new Promise<string>((resolve: (value: string) => void): void => {\n rl.question(prompt, (answer: string): void => {\n rl.close();\n resolve(answer);\n });\n });\n }\n}\n"]}
|
|
1
|
+
{"version":3,"file":"cleanup-command.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/pr-gate/src/scripts/commands/cleanup-command.ts"],"names":[],"mappings":"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","sourcesContent":["import * as readline from 'readline';\nimport {\n BranchArchiver,\n BranchReaper,\n DeletableBranch,\n DeletableWorktree,\n ReapResult,\n ReapedBranch,\n RepoRootFinder,\n loadAndValidate,\n CLASSIFICATION_SUPERSEDED,\n CLASSIFICATION_CONTENT_IN_MAIN,\n CLASSIFICATION_NEVER_PROPOSED,\n PROMPTABLE_CLASSIFICATIONS,\n} from '@webpieces/rules-config';\nimport { injectable, bindingScopeValues } from 'inversify';\n\nimport { WorktreeCleanupSection } from './worktree-cleanup';\n\nconst SEP = '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\\n';\n\n// One line of human-facing explanation per promptable classification, ordered most-safe first. These\n// replace the single string `no merged PR found — a human must decide`, which covered all three of\n// these situations identically and so told a human nothing they could act on.\nconst CLASSIFICATION_HEADINGS: Readonly<Record<string, string>> = {\n [CLASSIFICATION_SUPERSEDED]:\n 'SUPERSEDED — the PR was closed WITHOUT merging and later PRs have merged since. Near-certainly\\n'\n + ' the abandoned first attempt at work that landed under a different number. Safest group to delete.',\n [CLASSIFICATION_CONTENT_IN_MAIN]:\n 'CONTENT ALREADY IN MAIN — every commit has a patch-equivalent in origin/main (git cherry).\\n'\n + ' The work is not unique to this branch; only the commit objects are.',\n [CLASSIFICATION_NEVER_PROPOSED]:\n 'NEVER PROPOSED — no PR was ever opened, and these commits may be the ONLY copy in existence.\\n'\n + ' Read the unique-commit counts before answering. This is the group to say no to if unsure.',\n};\n\n/**\n * wp-cleanup: remove the dead WORKTREES, delete the local branches whose PR is already merged (or that\n * hold no commits), then ASK about the ones that are merely probably-dead.\n *\n * WHY WORKTREES ARE PART OF THIS: they were the half that never got reaped. The verdicts existed —\n * merged-branches.ts has been writing a full `DeletableWorktree[]` into the cache all along — and their\n * only consumer used them to BLOCK the next `git worktree add`, never to remove anything. Meanwhile a\n * live worktree pins its branch, so the branch was spared too. Two things the tooling could PROVE were\n * dead, accumulating forever, until the guard refused to create the next branch and the only remedy it\n * could offer was loosening its own cap. See WorktreeCleanupSection and WorktreeReaper.\n *\n * WHY a named command instead of the `git branch -D a b c` the guards used to print: an AI agent\n * reads a raw `-D` as destructive, so it asks permission and stops — which is exactly why branches\n * piled up despite the tooling knowing precisely which ones were dead. `pnpm wp-cleanup` is one\n * boring, allowlistable verb whose safety is a property of the command itself rather than of the\n * agent's judgement about a git flag.\n *\n * WHY IT NOW PROMPTS: sparing silently was the other half of the same problem. Every spared branch\n * reported the identical `no merged PR found — a human must decide`, so the human could not decide,\n * so nothing got deleted, so the pile grew until branch-creation-guard refused to make the next branch\n * and an agent went looking for a config knob to loosen. Shown a real classification with unique-commit\n * counts, the human in that session answered in five words: \"these should all be delete branches\".\n * The prompt is cheap because archiving happens FIRST — a yes costs a tag, not the history.\n *\n * All the danger still lives in the verdicts, not here — see BranchReaper for why every AUTOMATICALLY\n * deleted branch is provably dead and recoverable, and note that nothing in the prompted group is ever\n * deleted without an explicit typed answer.\n */\n@injectable(bindingScopeValues.Singleton)\nexport class CleanupCommand {\n constructor(\n private readonly repoRootFinder: RepoRootFinder,\n private readonly branchReaper: BranchReaper,\n private readonly archiver: BranchArchiver,\n private readonly worktreeSection: WorktreeCleanupSection,\n ) {}\n\n /**\n * WORKTREES FIRST, then branches. The order is the fix, not a detail: a worktree HOLDS its branch,\n * so that branch is spared `in-use` (\"remove that worktree before deleting the branch\") and nothing\n * used to remove the worktree — so both piled up until branch-creation-guard refused to make the\n * next one. Reaping the worktree takes its branch with it, and the branch pass then recomputes its\n * verdicts from scratch against the post-removal truth.\n */\n async run(): Promise<void> {\n const repoRoot = this.repoRootFinder.resolveRepoRoot(process.cwd());\n const retention = loadAndValidate(repoRoot).prGate.landPr.branchRetention;\n await this.cleanUpWorktrees(repoRoot, retention);\n await this.cleanUpBranches(repoRoot, retention);\n }\n\n private async cleanUpBranches(repoRoot: string, retention: string): Promise<void> {\n // No cache argument: wp-cleanup recomputes the verdicts itself. The file on disk is allowed to\n // go stale, and stale evidence is fine for BLOCKING but never for DELETING.\n const result = this.branchReaper.reap(repoRoot, 'wp-cleanup', null, retention);\n process.stdout.write(this.report(result));\n\n const promptable = this.promptable(result.spared);\n if (promptable.length === 0) return;\n process.stdout.write(this.classifiedBlock(promptable));\n const approved = await this.askWhichToDelete(promptable, 'branch');\n if (approved.length === 0) {\n process.stdout.write('\\nNothing deleted — the branches above were kept.\\n');\n return;\n }\n const second = this.branchReaper.reapApproved(repoRoot, 'wp-cleanup', approved, retention);\n process.stdout.write(this.report(second));\n }\n\n /**\n * Reap the provably-dead worktrees, then ASK about the probably-dead ones — the same two-tier\n * posture the branch half has, because it is the same verdict on the same branch.\n *\n * WorktreeReaper enforces the safety rails regardless of what is passed or answered: never the\n * primary clone, never the tree this command is running in, and never `--force` (git's refusal to\n * remove a worktree holding untracked or modified files is a feature, and forcing it is how a\n * cleanup command becomes a data-loss command).\n */\n private async cleanUpWorktrees(repoRoot: string, retention: string): Promise<void> {\n const verdicts = this.worktreeSection.verdicts(repoRoot);\n const dead = this.worktreeSection.provablyDead(verdicts);\n if (dead.length > 0) {\n process.stdout.write(\n this.worktreeSection.report(\n this.worktreeSection.reap(repoRoot, 'wp-cleanup', dead, retention)));\n }\n process.stdout.write(this.worktreeSection.sparedBlock(verdicts, dead));\n\n const promptable = this.worktreeSection.promptable(verdicts);\n if (promptable.length === 0) return;\n process.stdout.write(this.worktreeSection.promptBlock(promptable));\n const approved = await this.askWhichToDelete(promptable, 'worktree');\n if (approved.length === 0) {\n process.stdout.write('\\nNothing removed — the worktrees above were kept.\\n');\n return;\n }\n process.stdout.write(\n this.worktreeSection.report(\n this.worktreeSection.reap(repoRoot, 'wp-cleanup', approved, retention)));\n }\n\n private report(result: ReapResult): string {\n if (result.reaped.length === 0 && result.failed.length === 0) {\n return '\\n✅ Nothing to clean up — no local branch is provably dead.\\n';\n }\n\n let out = '\\n' + SEP + `🧹 Cleaned up ${String(result.reaped.length)} dead local branch(es)\\n` + SEP + '\\n';\n for (const entry of result.reaped) out += this.reapedLine(entry);\n\n if (result.failed.length > 0) {\n out += `\\n⚠️ ${String(result.failed.length)} branch(es) could not be deleted:\\n`;\n for (const entry of result.failed) out += ` ✗ ${entry.branch} — ${entry.error}\\n`;\n }\n\n // Printed even on success: a deletion the human cannot undo is a deletion they have to trust\n // blindly, and the whole argument for auto-cleanup is that they never have to.\n out += '\\nEvery deletion is logged with its pre-delete SHA in .webpieces/hooks/branch-mutations.log —\\n'\n + 'recover any of them with the `recover=` command on its line.\\n';\n return out;\n }\n\n private reapedLine(entry: ReapedBranch): string {\n const sha = entry.sha !== '' ? ` (was ${entry.sha.slice(0, 8)})` : '';\n // The archive tag is printed inline because it is the ONE thing that makes this delete casually\n // reversible — a name a human can type, rather than a sha they have to go dig out of a log.\n const archived = entry.archiveTag !== ''\n ? `\\n archived → ${entry.archiveTag} (restore: ${this.archiver.restoreCommand(entry.branch, entry.archiveTag)})`\n : '';\n return ` ✓ ${entry.branch}${sha} — ${entry.reason}${archived}\\n`;\n }\n\n /**\n * The spared branches a human can meaningfully rule on, grouped and ordered most-safe first.\n *\n * Branches spared as IN_USE — checked out in a worktree — are still excluded here, but the reason\n * is no longer \"git would simply refuse\". That premise died the moment worktrees became reapable.\n * The real reason is the ORDER in run(): the worktree pass has already run, so an IN_USE branch is\n * one of exactly two things. Either its worktree was dead and the reap took the branch with it (so\n * it is not in this list at all), or its worktree is one we are deliberately keeping — locked, held\n * open by uncommitted work, or the one we are standing in — and offering to delete the branch out\n * from under a live checkout is not a question worth asking. The pair is offered TOGETHER by the\n * worktree prompt, which shows both the path and the branch it holds, or it is not offered at all.\n */\n private promptable(spared: DeletableBranch[]): DeletableBranch[] {\n const out: DeletableBranch[] = [];\n for (const classification of PROMPTABLE_CLASSIFICATIONS) {\n for (const entry of spared) {\n if (entry.classification === classification) out.push(entry);\n }\n }\n return out;\n }\n\n // The classification table: what each group means, and per branch its unique-commit count — the one\n // number that says whether a yes costs nothing or costs the only copy of somebody's work.\n private classifiedBlock(promptable: DeletableBranch[]): string {\n let out = '\\n' + SEP + `🤔 ${String(promptable.length)} branch(es) are probably dead — your call\\n` + SEP;\n let current = '';\n for (let i = 0; i < promptable.length; i += 1) {\n const entry = promptable[i];\n if (entry.classification !== current) {\n current = entry.classification;\n out += `\\n${CLASSIFICATION_HEADINGS[current] ?? current}\\n\\n`;\n }\n const commits = entry.commits >= 0 ? `${String(entry.commits)} unique commit(s)` : 'unique commits unknown';\n out += ` [${String(i + 1)}] ${entry.branch}\\n ${commits} — ${entry.reason}\\n`;\n }\n return out;\n }\n\n /**\n * Ask which of the classified branches to delete. Answers: `all`, `none` (default), or a\n * comma/space-separated list of the numbers shown.\n *\n * NON-INTERACTIVE (no TTY — CI, a hook, a piped agent shell) answers NONE and says so. A prompt\n * nobody can see must never be read as consent, and this is the one place in the tooling where a\n * deletion is not backed by a proof.\n */\n private async askWhichToDelete<T extends DeletableBranch | DeletableWorktree>(\n promptable: T[], kind: string,\n ): Promise<T[]> {\n if (process.stdin.isTTY !== true) {\n process.stdout.write(\n `\\nNot a terminal — no ${kind} was deleted and nothing was assumed.\\n`\n + 'Run `pnpm wp-cleanup` in an interactive shell to answer, or delete individually.\\n',\n );\n return [];\n }\n const answer = (await this.question(\n `\\nDelete which ${kind}(s)? [all / none / e.g. \"1,3\"] (default none): `)).trim().toLowerCase();\n if (answer === '' || answer === 'none' || answer === 'n') return [];\n if (answer === 'all' || answer === 'a') return promptable;\n return this.pickByNumber(promptable, answer);\n }\n\n // Parse `1,3` / `1 3` into branches, ignoring anything out of range. An unparseable answer selects\n // nothing, which is the fail-safe direction for a question about deleting.\n private pickByNumber<T extends DeletableBranch | DeletableWorktree>(promptable: T[], answer: string): T[] {\n const out: T[] = [];\n for (const token of answer.split(/[\\s,]+/)) {\n const index = Number(token);\n if (!Number.isInteger(index) || index < 1 || index > promptable.length) continue;\n out.push(promptable[index - 1]);\n }\n return out;\n }\n\n // Seam: overridden in the spec so the prompt parsing is testable with no terminal.\n protected question(prompt: string): Promise<string> {\n const rl = readline.createInterface({ input: process.stdin, output: process.stdout });\n return new Promise<string>((resolve: (value: string) => void): void => {\n rl.question(prompt, (answer: string): void => {\n rl.close();\n resolve(answer);\n });\n });\n }\n}\n"]}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { RepoRootFinder, BranchArchiver } from '@webpieces/rules-config';
|
|
1
|
+
import { RepoRootFinder, BranchArchiver, WorktreeService } from '@webpieces/rules-config';
|
|
2
2
|
import { AiBranchName } from '../workflow/git-readAiBranchName';
|
|
3
3
|
import { BranchNaming } from '../workflow/branch-naming';
|
|
4
4
|
import { MergeInfoIndex } from '../workflow/merge-info-index';
|
|
@@ -25,8 +25,23 @@ export declare class LandPrCommand {
|
|
|
25
25
|
private readonly prMerger;
|
|
26
26
|
private readonly archiver;
|
|
27
27
|
private readonly mergeInfoIndex;
|
|
28
|
-
|
|
28
|
+
private readonly worktrees;
|
|
29
|
+
constructor(repoRootFinder: RepoRootFinder, aiBranchName: AiBranchName, branchNaming: BranchNaming, prMerger: PrMerger, archiver: BranchArchiver, mergeInfoIndex: MergeInfoIndex, worktrees: WorktreeService);
|
|
29
30
|
run(): Promise<void>;
|
|
31
|
+
/**
|
|
32
|
+
* What to run next — which is NOT the same sentence when you landed from a worktree.
|
|
33
|
+
*
|
|
34
|
+
* Landing from a linked worktree always used to leave a corpse: the merged branch is checked out
|
|
35
|
+
* here, so `git branch -D` refuses, `wp-cleanup`'s branch pass spares it as in-use, and nothing
|
|
36
|
+
* removed the worktree — so the pair sat there until branch-creation-guard hit its cap. wp-cleanup
|
|
37
|
+
* now reaps dead worktrees, but it cannot reap the one it is RUNNING IN (removing your own cwd
|
|
38
|
+
* mid-command is a self-destruct), so the instruction has to say where to run it from.
|
|
39
|
+
*
|
|
40
|
+
* We deliberately do NOT remove the worktree here. This command is executing inside it, and a
|
|
41
|
+
* process that deletes the directory underneath itself is exactly the thing every safety rail in
|
|
42
|
+
* WorktreeReaper exists to refuse.
|
|
43
|
+
*/
|
|
44
|
+
private nextStep;
|
|
30
45
|
/**
|
|
31
46
|
* The landed branch's post-merge bookkeeping, in one place:
|
|
32
47
|
* 1. ARCHIVE the pre-squash tip as `archive/<date>/<branch>` — the tag makes the original history
|
|
@@ -34,13 +34,15 @@ let LandPrCommand = class LandPrCommand {
|
|
|
34
34
|
prMerger;
|
|
35
35
|
archiver;
|
|
36
36
|
mergeInfoIndex;
|
|
37
|
-
|
|
37
|
+
worktrees;
|
|
38
|
+
constructor(repoRootFinder, aiBranchName, branchNaming, prMerger, archiver, mergeInfoIndex, worktrees) {
|
|
38
39
|
this.repoRootFinder = repoRootFinder;
|
|
39
40
|
this.aiBranchName = aiBranchName;
|
|
40
41
|
this.branchNaming = branchNaming;
|
|
41
42
|
this.prMerger = prMerger;
|
|
42
43
|
this.archiver = archiver;
|
|
43
44
|
this.mergeInfoIndex = mergeInfoIndex;
|
|
45
|
+
this.worktrees = worktrees;
|
|
44
46
|
}
|
|
45
47
|
async run() {
|
|
46
48
|
const repoRoot = this.repoRootFinder.resolveRepoRoot(process.cwd());
|
|
@@ -78,12 +80,38 @@ let LandPrCommand = class LandPrCommand {
|
|
|
78
80
|
process.stdout.write('\n' + SEP + (outcome.merged ? '✅ Landed\n' : 'ℹ️ Not landed yet\n') + SEP + '\n' +
|
|
79
81
|
` ${outcome.message}\n` +
|
|
80
82
|
archived +
|
|
81
|
-
(outcome.merged ?
|
|
83
|
+
(outcome.merged ? this.nextStep(repoRoot, base) : '') +
|
|
82
84
|
(policy === rules_config_1.MERGE_MODE_AUTO
|
|
83
85
|
? ''
|
|
84
86
|
: ` (pr-gate.mergeMode is ${policy} — wp-finish-upsert-pr will keep leaving PRs for a human.)\n`) +
|
|
85
87
|
'\n');
|
|
86
88
|
}
|
|
89
|
+
/**
|
|
90
|
+
* What to run next — which is NOT the same sentence when you landed from a worktree.
|
|
91
|
+
*
|
|
92
|
+
* Landing from a linked worktree always used to leave a corpse: the merged branch is checked out
|
|
93
|
+
* here, so `git branch -D` refuses, `wp-cleanup`'s branch pass spares it as in-use, and nothing
|
|
94
|
+
* removed the worktree — so the pair sat there until branch-creation-guard hit its cap. wp-cleanup
|
|
95
|
+
* now reaps dead worktrees, but it cannot reap the one it is RUNNING IN (removing your own cwd
|
|
96
|
+
* mid-command is a self-destruct), so the instruction has to say where to run it from.
|
|
97
|
+
*
|
|
98
|
+
* We deliberately do NOT remove the worktree here. This command is executing inside it, and a
|
|
99
|
+
* process that deletes the directory underneath itself is exactly the thing every safety rail in
|
|
100
|
+
* WorktreeReaper exists to refuse.
|
|
101
|
+
*/
|
|
102
|
+
nextStep(repoRoot, base) {
|
|
103
|
+
const here = this.worktrees.currentWorktree(repoRoot);
|
|
104
|
+
if (here === null || here.isMain || here.branch !== base) {
|
|
105
|
+
return ' Next: `pnpm wp-cleanup` to delete the merged branch.\n';
|
|
106
|
+
}
|
|
107
|
+
const main = this.worktrees.listWorktrees(repoRoot)
|
|
108
|
+
.find((tree) => tree.isMain);
|
|
109
|
+
const from = main ? main.path : '<the primary clone>';
|
|
110
|
+
return ' Next: this branch is checked out in THIS worktree, so neither it nor the worktree can\n'
|
|
111
|
+
+ ` be removed from in here. Run cleanup from the primary clone instead:\n`
|
|
112
|
+
+ ` cd ${from} && pnpm wp-cleanup\n`
|
|
113
|
+
+ ` It archives ${base} as a tag, removes ${here.path}, then deletes the branch.\n`;
|
|
114
|
+
}
|
|
87
115
|
/**
|
|
88
116
|
* The landed branch's post-merge bookkeeping, in one place:
|
|
89
117
|
* 1. ARCHIVE the pre-squash tip as `archive/<date>/<branch>` — the tag makes the original history
|
|
@@ -141,7 +169,8 @@ exports.LandPrCommand = LandPrCommand = tslib_1.__decorate([
|
|
|
141
169
|
branch_naming_1.BranchNaming,
|
|
142
170
|
pr_merger_1.PrMerger,
|
|
143
171
|
rules_config_1.BranchArchiver,
|
|
144
|
-
merge_info_index_1.MergeInfoIndex
|
|
172
|
+
merge_info_index_1.MergeInfoIndex,
|
|
173
|
+
rules_config_1.WorktreeService])
|
|
145
174
|
], LandPrCommand);
|
|
146
175
|
// The open PR's number + title, as read back from GitHub.
|
|
147
176
|
class PrIdentity {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"land-pr-command.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/pr-gate/src/scripts/commands/land-pr-command.ts"],"names":[],"mappings":"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","sourcesContent":["import { execSync, spawnSync } from 'child_process';\nimport * as fs from 'fs';\nimport * as path from 'path';\nimport {\n prDirFor, InformAiError, RepoRootFinder, MERGE_MODE_AUTO, loadAndValidate,\n BranchArchiver, BRANCH_RETENTION_ARCHIVE_TAG, BRANCH_RETENTION_KEEP,\n} from '@webpieces/rules-config';\nimport { injectable, bindingScopeValues } from 'inversify';\nimport { AiBranchName } from '../workflow/git-readAiBranchName';\nimport { BranchNaming } from '../workflow/branch-naming';\nimport { ArchiveRecord, MergeInfoIndex } from '../workflow/merge-info-index';\nimport { PrMerger } from '../workflow/pr-merger';\n\nconst SEP = '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\\n';\n\n/**\n * `wp-land-pr`: squash-merge THIS branch's already-posted PR into main with the compact commit body.\n *\n * It exists because a merge clicked in the GitHub UI cannot produce that body. A UI merge is limited\n * to the repo's squash_merge_commit_title/message settings, and none of their values yields the\n * shortened risk/flags summary — only an explicit `gh pr merge --subject --body-file` does. So when a\n * merge has to happen outside `wp-finish-upsert-pr` (a mergeMode=NONE repo, or a PR whose checks were\n * still running when finish ran), this is the command that keeps main's history consistent.\n *\n * It deliberately does NOT re-run the build gate or re-render the dashboard: `wp-finish-upsert-pr`\n * already did both and left `merge-commit-body.md` on disk. Landing is a separate, later act, and\n * rebuilding here would mean a second authoritative gate whose result nobody reads. If that file is\n * missing, the honest answer is that finish has not run — so this fails and says so, rather than\n * inventing a body that would not match the PR.\n */\n@injectable(bindingScopeValues.Singleton)\nexport class LandPrCommand {\n constructor(\n private readonly repoRootFinder: RepoRootFinder,\n private readonly aiBranchName: AiBranchName,\n private readonly branchNaming: BranchNaming,\n private readonly prMerger: PrMerger,\n private readonly archiver: BranchArchiver,\n private readonly mergeInfoIndex: MergeInfoIndex,\n ) {}\n\n async run(): Promise<void> {\n const repoRoot = this.repoRootFinder.resolveRepoRoot(process.cwd());\n const base = this.branchNaming.baseBranchName(execSync('git branch --show-current', { encoding: 'utf8' }).trim());\n const prDir = prDirFor(repoRoot, this.aiBranchName.getFeatureName());\n const mergeBodyFile = path.join(prDir, 'merge-commit-body.md');\n\n if (!fs.existsSync(mergeBodyFile)) {\n throw new InformAiError(\n '\\n' + SEP + '❌ Nothing to land — no rendered merge body\\n' + SEP + '\\n' +\n `Expected the compact squash-commit body at:\\n ${mergeBodyFile}\\n\\n` +\n 'That file is written by `pnpm wp-finish-upsert-pr`, which is also what posts the PR.\\n' +\n 'Its absence means finish has not run on this branch, so there is no reviewed PR to\\n' +\n 'land. Run the gated flow first:\\n\\n' +\n ' pnpm wp-start-upsert-pr # update from main, push, build gate\\n' +\n ' # write .webpieces/review.json\\n' +\n ' pnpm wp-finish-upsert-pr # build gate, dashboard, create/update the PR\\n\\n' +\n 'Then re-run `pnpm wp-land-pr` if the PR still needs landing.\\n' + SEP,\n );\n }\n\n const ref = this.prNumberAndTitle(base);\n if (ref === null) {\n throw new InformAiError(\n '\\n' + SEP + '❌ No open PR found for this branch\\n' + SEP + '\\n' +\n `No open PR has head branch \"${base}\". Nothing to land.\\n` +\n 'If the PR is already merged, run `pnpm wp-cleanup`.\\n' + SEP,\n );\n }\n\n process.stdout.write('\\n' + SEP + `🚀 Landing PR #${ref.number}\\n` + SEP + '\\n');\n // Reuse the SAME merge logic wp-finish-upsert-pr uses, so a PR lands identically whichever\n // command lands it — including the auto-merge fallback when the checks are still running.\n // MERGE_MODE_AUTO is passed explicitly: running this command IS the intent to merge, so it is\n // not gated on pr-gate.mergeMode (a NONE repo runs this precisely to land one PR by hand).\n const outcome = this.prMerger.merge(base, `${ref.title} (#${ref.number})`, mergeBodyFile, MERGE_MODE_AUTO);\n\n const config = loadAndValidate(repoRoot).prGate;\n const policy = config.mergeMode;\n const archived = outcome.merged\n ? this.archiveAndPromote(repoRoot, base, ref, config.landPr.branchRetention)\n : '';\n process.stdout.write(\n '\\n' + SEP + (outcome.merged ? '✅ Landed\\n' : 'ℹ️ Not landed yet\\n') + SEP + '\\n' +\n ` ${outcome.message}\\n` +\n archived +\n (outcome.merged ? ' Next: `pnpm wp-cleanup` to delete the merged branch.\\n' : '') +\n (policy === MERGE_MODE_AUTO\n ? ''\n : ` (pr-gate.mergeMode is ${policy} — wp-finish-upsert-pr will keep leaving PRs for a human.)\\n`) +\n '\\n',\n );\n }\n\n /**\n * The landed branch's post-merge bookkeeping, in one place:\n * 1. ARCHIVE the pre-squash tip as `archive/<date>/<branch>` — the tag makes the original history\n * permanently restorable (`git checkout -b <branch> <tag>` gives back the exact objects) while\n * costing one ref, so the branch itself no longer has to survive as a `*PreMerge` husk that\n * counts toward the branch cap. See BranchArchiver for why a tag beats a patch or the reflog.\n * 2. PROMOTE `merge-info/staged/<feature>/` to `merge-info/merged/<feature>/` and rebuild\n * `index.json`, so `staged/` holds only branches that are still in flight.\n *\n * Never throws: the PR is already merged by the time we get here, and failing the command after a\n * successful merge would report a landed PR as a failure. Problems are reported in the recap.\n */\n private archiveAndPromote(repoRoot: string, base: string, ref: PrIdentity, retention: string): string {\n if (retention === BRANCH_RETENTION_KEEP) return ' Branch retention is \"keep\" — nothing archived.\\n';\n\n let line = '';\n let tag = '';\n if (retention === BRANCH_RETENTION_ARCHIVE_TAG) {\n const archive = this.archiver.archive(repoRoot, base);\n tag = archive.tag;\n line = archive.ok\n ? ` Archived ${base} → ${archive.tag} (restore: ${this.archiver.restoreCommand(base, archive.tag)})\\n`\n : ` ⚠️ Could not archive ${base}: ${archive.error} — the branch was left alone.\\n`;\n }\n\n const feature = this.aiBranchName.getFeatureName();\n const record = new ArchiveRecord(\n tag, this.revParse(repoRoot, base), this.revParse(repoRoot, 'origin/main'),\n Number(ref.number), new Date().toISOString(),\n );\n if (this.mergeInfoIndex.promoteToMerged(repoRoot, feature, record)) {\n line += ` merge-info: staged/${feature} → merged/${feature} (index.json rebuilt)\\n`;\n }\n return line;\n }\n\n // Best-effort sha of a ref — '' when it cannot resolve. Recorded in archive.json for provenance.\n private revParse(repoRoot: string, ref: string): string {\n const result = spawnSync('git', ['rev-parse', ref], { cwd: repoRoot, encoding: 'utf8' });\n return result.status === 0 ? (result.stdout ?? '').trim() : '';\n }\n\n // The open PR's number + title for this head branch, or null when there is none. The TITLE comes\n // from the PR itself, not review.json, so the squash subject matches what a reviewer approved even\n // if review.json was edited afterwards.\n private prNumberAndTitle(baseBranch: string): PrIdentity | null {\n const result = spawnSync(\n 'gh', ['pr', 'view', baseBranch, '--json', 'number,title', '--jq', '\"\\\\(.number)\\\\t\\\\(.title)\"'],\n { encoding: 'utf8' },\n );\n if (result.status !== 0) return null;\n const parts = (result.stdout ?? '').trim().split('\\t');\n if ((parts[0] ?? '') === '') return null;\n return new PrIdentity(parts[0] ?? '', parts[1] ?? '');\n }\n}\n\n// The open PR's number + title, as read back from GitHub.\nclass PrIdentity {\n number: string;\n title: string;\n\n constructor(number: string, title: string) {\n this.number = number;\n this.title = title;\n }\n}\n"]}
|
|
1
|
+
{"version":3,"file":"land-pr-command.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/pr-gate/src/scripts/commands/land-pr-command.ts"],"names":[],"mappings":"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","sourcesContent":["import { execSync, spawnSync } from 'child_process';\nimport * as fs from 'fs';\nimport * as path from 'path';\nimport {\n prDirFor, InformAiError, RepoRootFinder, MERGE_MODE_AUTO, loadAndValidate,\n BranchArchiver, BRANCH_RETENTION_ARCHIVE_TAG, BRANCH_RETENTION_KEEP,\n Worktree, WorktreeService,\n} from '@webpieces/rules-config';\nimport { injectable, bindingScopeValues } from 'inversify';\nimport { AiBranchName } from '../workflow/git-readAiBranchName';\nimport { BranchNaming } from '../workflow/branch-naming';\nimport { ArchiveRecord, MergeInfoIndex } from '../workflow/merge-info-index';\nimport { PrMerger } from '../workflow/pr-merger';\n\nconst SEP = '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\\n';\n\n/**\n * `wp-land-pr`: squash-merge THIS branch's already-posted PR into main with the compact commit body.\n *\n * It exists because a merge clicked in the GitHub UI cannot produce that body. A UI merge is limited\n * to the repo's squash_merge_commit_title/message settings, and none of their values yields the\n * shortened risk/flags summary — only an explicit `gh pr merge --subject --body-file` does. So when a\n * merge has to happen outside `wp-finish-upsert-pr` (a mergeMode=NONE repo, or a PR whose checks were\n * still running when finish ran), this is the command that keeps main's history consistent.\n *\n * It deliberately does NOT re-run the build gate or re-render the dashboard: `wp-finish-upsert-pr`\n * already did both and left `merge-commit-body.md` on disk. Landing is a separate, later act, and\n * rebuilding here would mean a second authoritative gate whose result nobody reads. If that file is\n * missing, the honest answer is that finish has not run — so this fails and says so, rather than\n * inventing a body that would not match the PR.\n */\n@injectable(bindingScopeValues.Singleton)\nexport class LandPrCommand {\n constructor(\n private readonly repoRootFinder: RepoRootFinder,\n private readonly aiBranchName: AiBranchName,\n private readonly branchNaming: BranchNaming,\n private readonly prMerger: PrMerger,\n private readonly archiver: BranchArchiver,\n private readonly mergeInfoIndex: MergeInfoIndex,\n private readonly worktrees: WorktreeService,\n ) {}\n\n async run(): Promise<void> {\n const repoRoot = this.repoRootFinder.resolveRepoRoot(process.cwd());\n const base = this.branchNaming.baseBranchName(execSync('git branch --show-current', { encoding: 'utf8' }).trim());\n const prDir = prDirFor(repoRoot, this.aiBranchName.getFeatureName());\n const mergeBodyFile = path.join(prDir, 'merge-commit-body.md');\n\n if (!fs.existsSync(mergeBodyFile)) {\n throw new InformAiError(\n '\\n' + SEP + '❌ Nothing to land — no rendered merge body\\n' + SEP + '\\n' +\n `Expected the compact squash-commit body at:\\n ${mergeBodyFile}\\n\\n` +\n 'That file is written by `pnpm wp-finish-upsert-pr`, which is also what posts the PR.\\n' +\n 'Its absence means finish has not run on this branch, so there is no reviewed PR to\\n' +\n 'land. Run the gated flow first:\\n\\n' +\n ' pnpm wp-start-upsert-pr # update from main, push, build gate\\n' +\n ' # write .webpieces/review.json\\n' +\n ' pnpm wp-finish-upsert-pr # build gate, dashboard, create/update the PR\\n\\n' +\n 'Then re-run `pnpm wp-land-pr` if the PR still needs landing.\\n' + SEP,\n );\n }\n\n const ref = this.prNumberAndTitle(base);\n if (ref === null) {\n throw new InformAiError(\n '\\n' + SEP + '❌ No open PR found for this branch\\n' + SEP + '\\n' +\n `No open PR has head branch \"${base}\". Nothing to land.\\n` +\n 'If the PR is already merged, run `pnpm wp-cleanup`.\\n' + SEP,\n );\n }\n\n process.stdout.write('\\n' + SEP + `🚀 Landing PR #${ref.number}\\n` + SEP + '\\n');\n // Reuse the SAME merge logic wp-finish-upsert-pr uses, so a PR lands identically whichever\n // command lands it — including the auto-merge fallback when the checks are still running.\n // MERGE_MODE_AUTO is passed explicitly: running this command IS the intent to merge, so it is\n // not gated on pr-gate.mergeMode (a NONE repo runs this precisely to land one PR by hand).\n const outcome = this.prMerger.merge(base, `${ref.title} (#${ref.number})`, mergeBodyFile, MERGE_MODE_AUTO);\n\n const config = loadAndValidate(repoRoot).prGate;\n const policy = config.mergeMode;\n const archived = outcome.merged\n ? this.archiveAndPromote(repoRoot, base, ref, config.landPr.branchRetention)\n : '';\n process.stdout.write(\n '\\n' + SEP + (outcome.merged ? '✅ Landed\\n' : 'ℹ️ Not landed yet\\n') + SEP + '\\n' +\n ` ${outcome.message}\\n` +\n archived +\n (outcome.merged ? this.nextStep(repoRoot, base) : '') +\n (policy === MERGE_MODE_AUTO\n ? ''\n : ` (pr-gate.mergeMode is ${policy} — wp-finish-upsert-pr will keep leaving PRs for a human.)\\n`) +\n '\\n',\n );\n }\n\n /**\n * What to run next — which is NOT the same sentence when you landed from a worktree.\n *\n * Landing from a linked worktree always used to leave a corpse: the merged branch is checked out\n * here, so `git branch -D` refuses, `wp-cleanup`'s branch pass spares it as in-use, and nothing\n * removed the worktree — so the pair sat there until branch-creation-guard hit its cap. wp-cleanup\n * now reaps dead worktrees, but it cannot reap the one it is RUNNING IN (removing your own cwd\n * mid-command is a self-destruct), so the instruction has to say where to run it from.\n *\n * We deliberately do NOT remove the worktree here. This command is executing inside it, and a\n * process that deletes the directory underneath itself is exactly the thing every safety rail in\n * WorktreeReaper exists to refuse.\n */\n private nextStep(repoRoot: string, base: string): string {\n const here = this.worktrees.currentWorktree(repoRoot);\n if (here === null || here.isMain || here.branch !== base) {\n return ' Next: `pnpm wp-cleanup` to delete the merged branch.\\n';\n }\n const main = this.worktrees.listWorktrees(repoRoot)\n .find((tree: Worktree): boolean => tree.isMain);\n const from = main ? main.path : '<the primary clone>';\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 + ` cd ${from} && pnpm wp-cleanup\\n`\n + ` It archives ${base} as a tag, removes ${here.path}, then deletes the branch.\\n`;\n }\n\n /**\n * The landed branch's post-merge bookkeeping, in one place:\n * 1. ARCHIVE the pre-squash tip as `archive/<date>/<branch>` — the tag makes the original history\n * permanently restorable (`git checkout -b <branch> <tag>` gives back the exact objects) while\n * costing one ref, so the branch itself no longer has to survive as a `*PreMerge` husk that\n * counts toward the branch cap. See BranchArchiver for why a tag beats a patch or the reflog.\n * 2. PROMOTE `merge-info/staged/<feature>/` to `merge-info/merged/<feature>/` and rebuild\n * `index.json`, so `staged/` holds only branches that are still in flight.\n *\n * Never throws: the PR is already merged by the time we get here, and failing the command after a\n * successful merge would report a landed PR as a failure. Problems are reported in the recap.\n */\n private archiveAndPromote(repoRoot: string, base: string, ref: PrIdentity, retention: string): string {\n if (retention === BRANCH_RETENTION_KEEP) return ' Branch retention is \"keep\" — nothing archived.\\n';\n\n let line = '';\n let tag = '';\n if (retention === BRANCH_RETENTION_ARCHIVE_TAG) {\n const archive = this.archiver.archive(repoRoot, base);\n tag = archive.tag;\n line = archive.ok\n ? ` Archived ${base} → ${archive.tag} (restore: ${this.archiver.restoreCommand(base, archive.tag)})\\n`\n : ` ⚠️ Could not archive ${base}: ${archive.error} — the branch was left alone.\\n`;\n }\n\n const feature = this.aiBranchName.getFeatureName();\n const record = new ArchiveRecord(\n tag, this.revParse(repoRoot, base), this.revParse(repoRoot, 'origin/main'),\n Number(ref.number), new Date().toISOString(),\n );\n if (this.mergeInfoIndex.promoteToMerged(repoRoot, feature, record)) {\n line += ` merge-info: staged/${feature} → merged/${feature} (index.json rebuilt)\\n`;\n }\n return line;\n }\n\n // Best-effort sha of a ref — '' when it cannot resolve. Recorded in archive.json for provenance.\n private revParse(repoRoot: string, ref: string): string {\n const result = spawnSync('git', ['rev-parse', ref], { cwd: repoRoot, encoding: 'utf8' });\n return result.status === 0 ? (result.stdout ?? '').trim() : '';\n }\n\n // The open PR's number + title for this head branch, or null when there is none. The TITLE comes\n // from the PR itself, not review.json, so the squash subject matches what a reviewer approved even\n // if review.json was edited afterwards.\n private prNumberAndTitle(baseBranch: string): PrIdentity | null {\n const result = spawnSync(\n 'gh', ['pr', 'view', baseBranch, '--json', 'number,title', '--jq', '\"\\\\(.number)\\\\t\\\\(.title)\"'],\n { encoding: 'utf8' },\n );\n if (result.status !== 0) return null;\n const parts = (result.stdout ?? '').trim().split('\\t');\n if ((parts[0] ?? '') === '') return null;\n return new PrIdentity(parts[0] ?? '', parts[1] ?? '');\n }\n}\n\n// The open PR's number + title, as read back from GitHub.\nclass PrIdentity {\n number: string;\n title: string;\n\n constructor(number: string, title: string) {\n this.number = number;\n this.title = title;\n }\n}\n"]}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { MutationVerb, DeletableWorktree, MergedBranchesService, WorktreeReapResult, WorktreeReaper } from '@webpieces/rules-config';
|
|
2
|
+
/**
|
|
3
|
+
* The WORKTREE half of `wp-cleanup` — the verdicts, the reap and the human-facing text.
|
|
4
|
+
*
|
|
5
|
+
* Split out of CleanupCommand rather than bolted onto it because the two halves answer different
|
|
6
|
+
* questions: a branch is a ref, a worktree is a DIRECTORY OF FILES, and the second one needs its own
|
|
7
|
+
* report (paths, not just names), its own restore command (`git worktree add`, not `git checkout -b`)
|
|
8
|
+
* and its own spared vocabulary (locked, detached, "you are standing in it"). Keeping them in one class
|
|
9
|
+
* meant every string had to hedge about which kind of thing it was talking about.
|
|
10
|
+
*
|
|
11
|
+
* WHY worktrees are reaped BEFORE branches in wp-cleanup: a worktree HOLDS its branch, so that branch
|
|
12
|
+
* is spared as `in-use` with "remove that worktree before deleting the branch". Reaping the worktree
|
|
13
|
+
* first is what makes the branch reapable — the reap takes the branch with it, and the branch pass that
|
|
14
|
+
* follows recomputes its verdicts against the post-removal truth. Run the other way round, every
|
|
15
|
+
* worktree-held branch survives forever, which is exactly the deadlock this change exists to break.
|
|
16
|
+
*/
|
|
17
|
+
export declare class WorktreeCleanupSection {
|
|
18
|
+
private readonly mergedBranches;
|
|
19
|
+
private readonly reaper;
|
|
20
|
+
constructor(mergedBranches: MergedBranchesService, reaper: WorktreeReaper);
|
|
21
|
+
/**
|
|
22
|
+
* FRESH verdicts — never the cache on disk. Same rule the branch half follows: the cached file is
|
|
23
|
+
* deliberately allowed to go stale, which is fine for BLOCKING a `git worktree add` and never fine
|
|
24
|
+
* for removing a directory, since the tree may have gained uncommitted work since it was written.
|
|
25
|
+
*/
|
|
26
|
+
verdicts(repoRoot: string): DeletableWorktree[];
|
|
27
|
+
provablyDead(verdicts: DeletableWorktree[]): DeletableWorktree[];
|
|
28
|
+
/**
|
|
29
|
+
* The spared worktrees a human can meaningfully rule on, grouped most-safe first — the same
|
|
30
|
+
* classification order the branch prompt uses, because it is literally the same verdict on the
|
|
31
|
+
* branch the worktree holds.
|
|
32
|
+
*
|
|
33
|
+
* Deliberately excluded: LOCKED (a human already said do not touch), CURRENT (removing your own cwd
|
|
34
|
+
* is not a thing to offer), DETACHED (no branch, so nothing to archive and nothing to judge) and
|
|
35
|
+
* PRUNABLE (already provably dead — it is in the auto-reap list, not this one).
|
|
36
|
+
*/
|
|
37
|
+
promptable(verdicts: DeletableWorktree[]): DeletableWorktree[];
|
|
38
|
+
reap(repoRoot: string, verb: MutationVerb, targets: DeletableWorktree[], retention: string): WorktreeReapResult;
|
|
39
|
+
report(result: WorktreeReapResult): string;
|
|
40
|
+
private reapedLine;
|
|
41
|
+
/** The spared worktrees, with WHY — including the ones nobody will ever be asked about. */
|
|
42
|
+
sparedBlock(verdicts: DeletableWorktree[], removed: DeletableWorktree[]): string;
|
|
43
|
+
private isMechanical;
|
|
44
|
+
promptBlock(promptable: DeletableWorktree[]): string;
|
|
45
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.WorktreeCleanupSection = void 0;
|
|
4
|
+
const tslib_1 = require("tslib");
|
|
5
|
+
const rules_config_1 = require("@webpieces/rules-config");
|
|
6
|
+
const inversify_1 = require("inversify");
|
|
7
|
+
const SEP = '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n';
|
|
8
|
+
/**
|
|
9
|
+
* The WORKTREE half of `wp-cleanup` — the verdicts, the reap and the human-facing text.
|
|
10
|
+
*
|
|
11
|
+
* Split out of CleanupCommand rather than bolted onto it because the two halves answer different
|
|
12
|
+
* questions: a branch is a ref, a worktree is a DIRECTORY OF FILES, and the second one needs its own
|
|
13
|
+
* report (paths, not just names), its own restore command (`git worktree add`, not `git checkout -b`)
|
|
14
|
+
* and its own spared vocabulary (locked, detached, "you are standing in it"). Keeping them in one class
|
|
15
|
+
* meant every string had to hedge about which kind of thing it was talking about.
|
|
16
|
+
*
|
|
17
|
+
* WHY worktrees are reaped BEFORE branches in wp-cleanup: a worktree HOLDS its branch, so that branch
|
|
18
|
+
* is spared as `in-use` with "remove that worktree before deleting the branch". Reaping the worktree
|
|
19
|
+
* first is what makes the branch reapable — the reap takes the branch with it, and the branch pass that
|
|
20
|
+
* follows recomputes its verdicts against the post-removal truth. Run the other way round, every
|
|
21
|
+
* worktree-held branch survives forever, which is exactly the deadlock this change exists to break.
|
|
22
|
+
*/
|
|
23
|
+
let WorktreeCleanupSection = class WorktreeCleanupSection {
|
|
24
|
+
mergedBranches;
|
|
25
|
+
reaper;
|
|
26
|
+
constructor(mergedBranches, reaper) {
|
|
27
|
+
this.mergedBranches = mergedBranches;
|
|
28
|
+
this.reaper = reaper;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* FRESH verdicts — never the cache on disk. Same rule the branch half follows: the cached file is
|
|
32
|
+
* deliberately allowed to go stale, which is fine for BLOCKING a `git worktree add` and never fine
|
|
33
|
+
* for removing a directory, since the tree may have gained uncommitted work since it was written.
|
|
34
|
+
*/
|
|
35
|
+
verdicts(repoRoot) {
|
|
36
|
+
return this.mergedBranches.computeMergedBranches(repoRoot).worktrees;
|
|
37
|
+
}
|
|
38
|
+
provablyDead(verdicts) {
|
|
39
|
+
return verdicts.filter((tree) => tree.deletable);
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* The spared worktrees a human can meaningfully rule on, grouped most-safe first — the same
|
|
43
|
+
* classification order the branch prompt uses, because it is literally the same verdict on the
|
|
44
|
+
* branch the worktree holds.
|
|
45
|
+
*
|
|
46
|
+
* Deliberately excluded: LOCKED (a human already said do not touch), CURRENT (removing your own cwd
|
|
47
|
+
* is not a thing to offer), DETACHED (no branch, so nothing to archive and nothing to judge) and
|
|
48
|
+
* PRUNABLE (already provably dead — it is in the auto-reap list, not this one).
|
|
49
|
+
*/
|
|
50
|
+
promptable(verdicts) {
|
|
51
|
+
const out = [];
|
|
52
|
+
for (const classification of rules_config_1.PROMPTABLE_CLASSIFICATIONS) {
|
|
53
|
+
for (const tree of verdicts) {
|
|
54
|
+
if (!tree.deletable && tree.classification === classification)
|
|
55
|
+
out.push(tree);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
reap(repoRoot, verb, targets, retention) {
|
|
61
|
+
return this.reaper.reapWorktrees(repoRoot, process.cwd(), verb, targets, retention);
|
|
62
|
+
}
|
|
63
|
+
report(result) {
|
|
64
|
+
if (result.reaped.length === 0 && result.failed.length === 0)
|
|
65
|
+
return '';
|
|
66
|
+
let out = '\n' + SEP + `🌲 Removed ${String(result.reaped.length)} dead worktree(s)\n` + SEP + '\n';
|
|
67
|
+
for (const entry of result.reaped)
|
|
68
|
+
out += this.reapedLine(entry);
|
|
69
|
+
if (result.failed.length > 0) {
|
|
70
|
+
out += `\n⚠️ ${String(result.failed.length)} worktree(s) could not be removed:\n`;
|
|
71
|
+
for (const entry of result.failed)
|
|
72
|
+
out += ` ✗ ${entry.path} — ${entry.error}\n`;
|
|
73
|
+
}
|
|
74
|
+
// Printed on success too: removing a worktree deletes real files, and a human who cannot see
|
|
75
|
+
// how to undo that has to take it on trust — which is precisely what nobody should have to do.
|
|
76
|
+
out += '\nEvery removal is logged in .webpieces/hooks/branch-mutations.log (phase REAP_WORKTREE)\n'
|
|
77
|
+
+ 'with the `recover=` command that brings back both the directory and its branch.\n';
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
reapedLine(entry) {
|
|
81
|
+
const branch = entry.branch !== '' ? ` [${entry.branch}]` : ' [detached]';
|
|
82
|
+
// The restore command is printed inline for the same reason the branch half prints the archive
|
|
83
|
+
// tag: it is the one thing that makes this reversible without going and digging in a log.
|
|
84
|
+
const restore = `\n restore: ${this.reaper.restoreCommand(entry)}`;
|
|
85
|
+
// A directory that went while its branch survived is a real half-state and must not read as done.
|
|
86
|
+
const partial = entry.branch !== '' && !entry.branchDeleted
|
|
87
|
+
? `\n ⚠️ the branch '${entry.branch}' was NOT deleted — git refused it`
|
|
88
|
+
: '';
|
|
89
|
+
return ` ✓ ${entry.path}${branch} — ${entry.reason}${restore}${partial}\n`;
|
|
90
|
+
}
|
|
91
|
+
/** The spared worktrees, with WHY — including the ones nobody will ever be asked about. */
|
|
92
|
+
sparedBlock(verdicts, removed) {
|
|
93
|
+
const gone = new Set(removed.map((tree) => tree.path));
|
|
94
|
+
const spared = verdicts.filter((tree) => !tree.deletable && !gone.has(tree.path)
|
|
95
|
+
&& this.isMechanical(tree.classification));
|
|
96
|
+
if (spared.length === 0)
|
|
97
|
+
return '';
|
|
98
|
+
let out = '\nWorktrees deliberately left alone:\n';
|
|
99
|
+
for (const tree of spared)
|
|
100
|
+
out += ` · ${tree.path} — ${tree.reason}\n`;
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
isMechanical(classification) {
|
|
104
|
+
return classification === rules_config_1.CLASSIFICATION_LOCKED
|
|
105
|
+
|| classification === rules_config_1.CLASSIFICATION_CURRENT
|
|
106
|
+
|| classification === rules_config_1.CLASSIFICATION_DETACHED
|
|
107
|
+
|| classification === rules_config_1.CLASSIFICATION_PRUNABLE;
|
|
108
|
+
}
|
|
109
|
+
// The table a human answers: path, branch, and the same reason the branch prompt would show, since
|
|
110
|
+
// the verdict IS the branch's verdict.
|
|
111
|
+
promptBlock(promptable) {
|
|
112
|
+
let out = '\n' + SEP
|
|
113
|
+
+ `🤔 ${String(promptable.length)} worktree(s) are probably dead — your call\n` + SEP + '\n'
|
|
114
|
+
+ 'Removing one deletes its DIRECTORY and its branch. The branch is archived as a tag first,\n'
|
|
115
|
+
+ 'so both come back with one `git worktree add -b …` — but uncommitted or untracked files in\n'
|
|
116
|
+
+ 'that directory are NOT archived, and git will refuse the removal if any exist.\n\n';
|
|
117
|
+
for (let i = 0; i < promptable.length; i += 1) {
|
|
118
|
+
const tree = promptable[i];
|
|
119
|
+
out += ` [${String(i + 1)}] ${tree.path}\n [${tree.branch}] — ${tree.reason}\n`;
|
|
120
|
+
}
|
|
121
|
+
return out;
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
exports.WorktreeCleanupSection = WorktreeCleanupSection;
|
|
125
|
+
exports.WorktreeCleanupSection = WorktreeCleanupSection = tslib_1.__decorate([
|
|
126
|
+
(0, inversify_1.injectable)(inversify_1.bindingScopeValues.Singleton),
|
|
127
|
+
tslib_1.__metadata("design:paramtypes", [rules_config_1.MergedBranchesService,
|
|
128
|
+
rules_config_1.WorktreeReaper])
|
|
129
|
+
], WorktreeCleanupSection);
|
|
130
|
+
//# sourceMappingURL=worktree-cleanup.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"worktree-cleanup.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/pr-gate/src/scripts/commands/worktree-cleanup.ts"],"names":[],"mappings":"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","sourcesContent":["import {\n MutationVerb,\n DeletableWorktree,\n MergedBranchesService,\n ReapedWorktree,\n WorktreeReapResult,\n WorktreeReaper,\n CLASSIFICATION_LOCKED,\n CLASSIFICATION_CURRENT,\n CLASSIFICATION_DETACHED,\n CLASSIFICATION_PRUNABLE,\n PROMPTABLE_CLASSIFICATIONS,\n} from '@webpieces/rules-config';\nimport { injectable, bindingScopeValues } from 'inversify';\n\nconst SEP = '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\\n';\n\n/**\n * The WORKTREE half of `wp-cleanup` — the verdicts, the reap and the human-facing text.\n *\n * Split out of CleanupCommand rather than bolted onto it because the two halves answer different\n * questions: a branch is a ref, a worktree is a DIRECTORY OF FILES, and the second one needs its own\n * report (paths, not just names), its own restore command (`git worktree add`, not `git checkout -b`)\n * and its own spared vocabulary (locked, detached, \"you are standing in it\"). Keeping them in one class\n * meant every string had to hedge about which kind of thing it was talking about.\n *\n * WHY worktrees are reaped BEFORE branches in wp-cleanup: a worktree HOLDS its branch, so that branch\n * is spared as `in-use` with \"remove that worktree before deleting the branch\". Reaping the worktree\n * first is what makes the branch reapable — the reap takes the branch with it, and the branch pass that\n * follows recomputes its verdicts against the post-removal truth. Run the other way round, every\n * worktree-held branch survives forever, which is exactly the deadlock this change exists to break.\n */\n@injectable(bindingScopeValues.Singleton)\nexport class WorktreeCleanupSection {\n constructor(\n private readonly mergedBranches: MergedBranchesService,\n private readonly reaper: WorktreeReaper,\n ) {}\n\n /**\n * FRESH verdicts — never the cache on disk. Same rule the branch half follows: the cached file is\n * deliberately allowed to go stale, which is fine for BLOCKING a `git worktree add` and never fine\n * for removing a directory, since the tree may have gained uncommitted work since it was written.\n */\n verdicts(repoRoot: string): DeletableWorktree[] {\n return this.mergedBranches.computeMergedBranches(repoRoot).worktrees;\n }\n\n provablyDead(verdicts: DeletableWorktree[]): DeletableWorktree[] {\n return verdicts.filter((tree: DeletableWorktree): boolean => tree.deletable);\n }\n\n /**\n * The spared worktrees a human can meaningfully rule on, grouped most-safe first — the same\n * classification order the branch prompt uses, because it is literally the same verdict on the\n * branch the worktree holds.\n *\n * Deliberately excluded: LOCKED (a human already said do not touch), CURRENT (removing your own cwd\n * is not a thing to offer), DETACHED (no branch, so nothing to archive and nothing to judge) and\n * PRUNABLE (already provably dead — it is in the auto-reap list, not this one).\n */\n promptable(verdicts: DeletableWorktree[]): DeletableWorktree[] {\n const out: DeletableWorktree[] = [];\n for (const classification of PROMPTABLE_CLASSIFICATIONS) {\n for (const tree of verdicts) {\n if (!tree.deletable && tree.classification === classification) out.push(tree);\n }\n }\n return out;\n }\n\n reap(\n repoRoot: string,\n verb: MutationVerb,\n targets: DeletableWorktree[],\n retention: string,\n ): WorktreeReapResult {\n return this.reaper.reapWorktrees(repoRoot, process.cwd(), verb, targets, retention);\n }\n\n report(result: WorktreeReapResult): string {\n if (result.reaped.length === 0 && result.failed.length === 0) return '';\n\n let out = '\\n' + SEP + `🌲 Removed ${String(result.reaped.length)} dead worktree(s)\\n` + SEP + '\\n';\n for (const entry of result.reaped) out += this.reapedLine(entry);\n\n if (result.failed.length > 0) {\n out += `\\n⚠️ ${String(result.failed.length)} worktree(s) could not be removed:\\n`;\n for (const entry of result.failed) out += ` ✗ ${entry.path} — ${entry.error}\\n`;\n }\n // Printed on success too: removing a worktree deletes real files, and a human who cannot see\n // how to undo that has to take it on trust — which is precisely what nobody should have to do.\n out += '\\nEvery removal is logged in .webpieces/hooks/branch-mutations.log (phase REAP_WORKTREE)\\n'\n + 'with the `recover=` command that brings back both the directory and its branch.\\n';\n return out;\n }\n\n private reapedLine(entry: ReapedWorktree): string {\n const branch = entry.branch !== '' ? ` [${entry.branch}]` : ' [detached]';\n // The restore command is printed inline for the same reason the branch half prints the archive\n // tag: it is the one thing that makes this reversible without going and digging in a log.\n const restore = `\\n restore: ${this.reaper.restoreCommand(entry)}`;\n // A directory that went while its branch survived is a real half-state and must not read as done.\n const partial = entry.branch !== '' && !entry.branchDeleted\n ? `\\n ⚠️ the branch '${entry.branch}' was NOT deleted — git refused it`\n : '';\n return ` ✓ ${entry.path}${branch} — ${entry.reason}${restore}${partial}\\n`;\n }\n\n /** The spared worktrees, with WHY — including the ones nobody will ever be asked about. */\n sparedBlock(verdicts: DeletableWorktree[], removed: DeletableWorktree[]): string {\n const gone = new Set(removed.map((tree: DeletableWorktree): string => tree.path));\n const spared = verdicts.filter(\n (tree: DeletableWorktree): boolean => !tree.deletable && !gone.has(tree.path)\n && this.isMechanical(tree.classification));\n if (spared.length === 0) return '';\n let out = '\\nWorktrees deliberately left alone:\\n';\n for (const tree of spared) out += ` · ${tree.path} — ${tree.reason}\\n`;\n return out;\n }\n\n private isMechanical(classification: string): boolean {\n return classification === CLASSIFICATION_LOCKED\n || classification === CLASSIFICATION_CURRENT\n || classification === CLASSIFICATION_DETACHED\n || classification === CLASSIFICATION_PRUNABLE;\n }\n\n // The table a human answers: path, branch, and the same reason the branch prompt would show, since\n // the verdict IS the branch's verdict.\n promptBlock(promptable: DeletableWorktree[]): string {\n let out = '\\n' + SEP\n + `🤔 ${String(promptable.length)} worktree(s) are probably dead — your call\\n` + SEP + '\\n'\n + 'Removing one deletes its DIRECTORY and its branch. The branch is archived as a tag first,\\n'\n + 'so both come back with one `git worktree add -b …` — but uncommitted or untracked files in\\n'\n + 'that directory are NOT archived, and git will refuse the removal if any exist.\\n\\n';\n for (let i = 0; i < promptable.length; i += 1) {\n const tree = promptable[i];\n out += ` [${String(i + 1)}] ${tree.path}\\n [${tree.branch}] — ${tree.reason}\\n`;\n }\n return out;\n }\n}\n"]}
|