@webpieces/repo-workflow-core 0.4.976 → 0.4.978
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 +5 -2
- package/src/branch-archiver.d.ts +7 -4
- package/src/branch-archiver.js +40 -24
- package/src/branch-archiver.js.map +1 -1
- package/src/branch-reaper.d.ts +5 -3
- package/src/branch-reaper.js +33 -20
- package/src/branch-reaper.js.map +1 -1
- package/src/command-timeouts.d.ts +27 -7
- package/src/command-timeouts.js +64 -9
- package/src/command-timeouts.js.map +1 -1
- package/src/main-sync-status.d.ts +6 -9
- package/src/main-sync-status.js +66 -70
- package/src/main-sync-status.js.map +1 -1
- package/src/merged-branches.d.ts +5 -4
- package/src/merged-branches.js +55 -42
- package/src/merged-branches.js.map +1 -1
- package/src/worktree-lock-verdicts.d.ts +1 -1
- package/src/worktree-lock-verdicts.js +2 -2
- package/src/worktree-lock-verdicts.js.map +1 -1
- package/src/worktree-reaper.d.ts +4 -2
- package/src/worktree-reaper.js +35 -22
- package/src/worktree-reaper.js.map +1 -1
- package/src/worktrees.d.ts +7 -2
- package/src/worktrees.js +18 -15
- package/src/worktrees.js.map +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webpieces/repo-workflow-core",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.978",
|
|
4
4
|
"description": "Shared repository branch, worktree, sync, and identity-stamp mechanics.",
|
|
5
5
|
"type": "commonjs",
|
|
6
6
|
"main": "./src/index.js",
|
|
@@ -21,11 +21,14 @@
|
|
|
21
21
|
"dependencies": {
|
|
22
22
|
"inversify": "7.10.4",
|
|
23
23
|
"reflect-metadata": "0.2.2",
|
|
24
|
-
"@webpieces/tooling-common": "0.4.
|
|
24
|
+
"@webpieces/tooling-common": "0.4.978",
|
|
25
25
|
"tslib": "2.8.1"
|
|
26
26
|
},
|
|
27
27
|
"publishConfig": {
|
|
28
28
|
"access": "public"
|
|
29
29
|
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@webpieces/tooling-testkit": "0.4.978"
|
|
32
|
+
},
|
|
30
33
|
"types": "./src/index.d.ts"
|
|
31
34
|
}
|
package/src/branch-archiver.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { GitProcessApi } from '@webpieces/tooling-common';
|
|
1
2
|
/**
|
|
2
3
|
* Archive a branch tip as a git TAG immediately before the branch is deleted.
|
|
3
4
|
*
|
|
@@ -17,7 +18,7 @@
|
|
|
17
18
|
*
|
|
18
19
|
* Cost: one ref, zero new objects — the commits are already in the object store.
|
|
19
20
|
*
|
|
20
|
-
* Round trip (verified by hand
|
|
21
|
+
* Round trip (verified by hand; branch-archiver.spec.ts asserts the commands issued, against scripted git):
|
|
21
22
|
* git tag archive/2026-07-30/dean/foo dean/foo
|
|
22
23
|
* git branch -D dean/foo
|
|
23
24
|
* git checkout -b dean/foo archive/2026-07-30/dean/foo # exact objects restored
|
|
@@ -35,6 +36,8 @@ export declare class ArchiveResult {
|
|
|
35
36
|
constructor(tag: string, sha: string, ok: boolean, error: string);
|
|
36
37
|
}
|
|
37
38
|
export declare class BranchArchiver {
|
|
39
|
+
private readonly git;
|
|
40
|
+
constructor(git?: GitProcessApi);
|
|
38
41
|
/**
|
|
39
42
|
* `archive/<YYYY-MM-DD>/<branch>`. The date segment is what makes the namespace browsable
|
|
40
43
|
* chronologically (`git tag --list 'archive/2026-07-*'`) and is also what keeps two archives of the
|
|
@@ -57,14 +60,14 @@ export declare class BranchArchiver {
|
|
|
57
60
|
* archive namespace fills with `…` / `…-2` pairs at one sha, which reads like two distinct
|
|
58
61
|
* snapshots and is one more ref to explain.
|
|
59
62
|
*/
|
|
60
|
-
archive(repoRoot: string, branch: string, when?: Date): ArchiveResult
|
|
63
|
+
archive(repoRoot: string, branch: string, when?: Date): Promise<ArchiveResult>;
|
|
61
64
|
/** The literal command a human runs to bring an archived branch back, byte-identical to what was deleted. */
|
|
62
65
|
restoreCommand(branch: string, tag: string): string;
|
|
63
66
|
/** Every archive tag currently in the repo, newest namespace segment first (plain lexical on the date). */
|
|
64
|
-
listArchiveTags(repoRoot: string): string[]
|
|
67
|
+
listArchiveTags(repoRoot: string): Promise<string[]>;
|
|
65
68
|
private freeTagName;
|
|
66
69
|
private tagSha;
|
|
67
|
-
|
|
70
|
+
private tagExists;
|
|
68
71
|
private dateSegment;
|
|
69
72
|
private capture;
|
|
70
73
|
}
|
package/src/branch-archiver.js
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.BranchArchiver = exports.ArchiveResult = exports.ARCHIVE_TAG_PREFIX = exports.BRANCH_RETENTIONS = exports.BRANCH_RETENTION_KEEP = exports.BRANCH_RETENTION_ARCHIVE_TAG = exports.BRANCH_RETENTION_DELETE = void 0;
|
|
4
4
|
const tslib_1 = require("tslib");
|
|
5
|
-
const child_process_1 = require("child_process");
|
|
6
5
|
const inversify_1 = require("inversify");
|
|
6
|
+
const tooling_common_1 = require("@webpieces/tooling-common");
|
|
7
7
|
/**
|
|
8
8
|
* Archive a branch tip as a git TAG immediately before the branch is deleted.
|
|
9
9
|
*
|
|
@@ -23,7 +23,7 @@ const inversify_1 = require("inversify");
|
|
|
23
23
|
*
|
|
24
24
|
* Cost: one ref, zero new objects — the commits are already in the object store.
|
|
25
25
|
*
|
|
26
|
-
* Round trip (verified by hand
|
|
26
|
+
* Round trip (verified by hand; branch-archiver.spec.ts asserts the commands issued, against scripted git):
|
|
27
27
|
* git tag archive/2026-07-30/dean/foo dean/foo
|
|
28
28
|
* git branch -D dean/foo
|
|
29
29
|
* git checkout -b dean/foo archive/2026-07-30/dean/foo # exact objects restored
|
|
@@ -58,7 +58,24 @@ class ArchiveResult {
|
|
|
58
58
|
}
|
|
59
59
|
}
|
|
60
60
|
exports.ArchiveResult = ArchiveResult;
|
|
61
|
+
// Internal: one git call's verdict, trimmed stdout, and stderr so a refused tag can be reported verbatim. Data only.
|
|
62
|
+
class CapturedOutput {
|
|
63
|
+
ok;
|
|
64
|
+
out;
|
|
65
|
+
err;
|
|
66
|
+
constructor(ok, out, err) {
|
|
67
|
+
this.ok = ok;
|
|
68
|
+
this.out = out;
|
|
69
|
+
this.err = err;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
61
72
|
let BranchArchiver = class BranchArchiver {
|
|
73
|
+
git;
|
|
74
|
+
// Defaulted so the non-DI call sites can `new BranchArchiver()`, while inversify injects the container's
|
|
75
|
+
// git edge — which is what a feature test rebinds.
|
|
76
|
+
constructor(git = new tooling_common_1.GitProcessClient()) {
|
|
77
|
+
this.git = git;
|
|
78
|
+
}
|
|
62
79
|
/**
|
|
63
80
|
* `archive/<YYYY-MM-DD>/<branch>`. The date segment is what makes the namespace browsable
|
|
64
81
|
* chronologically (`git tag --list 'archive/2026-07-*'`) and is also what keeps two archives of the
|
|
@@ -83,17 +100,17 @@ let BranchArchiver = class BranchArchiver {
|
|
|
83
100
|
* archive namespace fills with `…` / `…-2` pairs at one sha, which reads like two distinct
|
|
84
101
|
* snapshots and is one more ref to explain.
|
|
85
102
|
*/
|
|
86
|
-
archive(repoRoot, branch, when = new Date()) {
|
|
87
|
-
const resolved = this.capture(repoRoot, ['rev-parse', branch]);
|
|
103
|
+
async archive(repoRoot, branch, when = new Date()) {
|
|
104
|
+
const resolved = await this.capture(repoRoot, ['rev-parse', branch]);
|
|
88
105
|
if (!resolved.ok)
|
|
89
106
|
return new ArchiveResult('', '', false, `cannot resolve ${branch}: ${resolved.err}`);
|
|
90
107
|
const sha = resolved.out;
|
|
91
108
|
const base = this.archiveTagName(branch, when);
|
|
92
|
-
if (this.tagExists(repoRoot, base) && this.tagSha(repoRoot, base) === sha) {
|
|
109
|
+
if (await this.tagExists(repoRoot, base) && await this.tagSha(repoRoot, base) === sha) {
|
|
93
110
|
return new ArchiveResult(base, sha, true, '');
|
|
94
111
|
}
|
|
95
|
-
const tag = this.freeTagName(repoRoot, base);
|
|
96
|
-
const tagged = this.capture(repoRoot, ['tag', tag, sha]);
|
|
112
|
+
const tag = await this.freeTagName(repoRoot, base);
|
|
113
|
+
const tagged = await this.capture(repoRoot, ['tag', tag, sha]);
|
|
97
114
|
if (!tagged.ok)
|
|
98
115
|
return new ArchiveResult('', sha, false, tagged.err);
|
|
99
116
|
return new ArchiveResult(tag, sha, true, '');
|
|
@@ -103,8 +120,8 @@ let BranchArchiver = class BranchArchiver {
|
|
|
103
120
|
return `git checkout -b ${branch} ${tag}`;
|
|
104
121
|
}
|
|
105
122
|
/** Every archive tag currently in the repo, newest namespace segment first (plain lexical on the date). */
|
|
106
|
-
listArchiveTags(repoRoot) {
|
|
107
|
-
const result = this.capture(repoRoot, ['tag', '--list', `${exports.ARCHIVE_TAG_PREFIX}/*`]);
|
|
123
|
+
async listArchiveTags(repoRoot) {
|
|
124
|
+
const result = await this.capture(repoRoot, ['tag', '--list', `${exports.ARCHIVE_TAG_PREFIX}/*`]);
|
|
108
125
|
if (!result.ok || result.out === '')
|
|
109
126
|
return [];
|
|
110
127
|
return result.out
|
|
@@ -116,11 +133,11 @@ let BranchArchiver = class BranchArchiver {
|
|
|
116
133
|
}
|
|
117
134
|
// First unused name in the `<base>`, `<base>-2`, `<base>-3` … series. Bounded so a pathological repo
|
|
118
135
|
// cannot spin: past the bound we hand back the last candidate and let `git tag` report the collision.
|
|
119
|
-
freeTagName(repoRoot, base) {
|
|
136
|
+
async freeTagName(repoRoot, base) {
|
|
120
137
|
const MAX_ATTEMPTS = 50;
|
|
121
138
|
let candidate = base;
|
|
122
139
|
for (let attempt = 2; attempt <= MAX_ATTEMPTS; attempt += 1) {
|
|
123
|
-
if (!this.tagExists(repoRoot, candidate))
|
|
140
|
+
if (!await this.tagExists(repoRoot, candidate))
|
|
124
141
|
return candidate;
|
|
125
142
|
candidate = `${base}-${String(attempt)}`;
|
|
126
143
|
}
|
|
@@ -128,30 +145,29 @@ let BranchArchiver = class BranchArchiver {
|
|
|
128
145
|
}
|
|
129
146
|
// The COMMIT an existing tag points at, '' when it cannot be resolved. `^{commit}` peels an
|
|
130
147
|
// annotated tag, so an archive written by hand with `git tag -a` compares equal to a lightweight one.
|
|
131
|
-
tagSha(repoRoot, tag) {
|
|
132
|
-
const result = this.capture(repoRoot, ['rev-parse', `refs/tags/${tag}^{commit}`]);
|
|
148
|
+
async tagSha(repoRoot, tag) {
|
|
149
|
+
const result = await this.capture(repoRoot, ['rev-parse', `refs/tags/${tag}^{commit}`]);
|
|
133
150
|
return result.ok ? result.out : '';
|
|
134
151
|
}
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
return this.capture(repoRoot, ['rev-parse', '--verify', '--quiet', `refs/tags/${tag}`]).ok;
|
|
152
|
+
async tagExists(repoRoot, tag) {
|
|
153
|
+
return (await this.capture(repoRoot, ['rev-parse', '--verify', '--quiet', `refs/tags/${tag}`])).ok;
|
|
138
154
|
}
|
|
139
155
|
// YYYY-MM-DD in LOCAL time: the archive is browsed by the human who created it, on their calendar.
|
|
140
156
|
dateSegment(when) {
|
|
141
157
|
const pad = (value) => String(value).padStart(2, '0');
|
|
142
158
|
return `${String(when.getFullYear())}-${pad(when.getMonth() + 1)}-${pad(when.getDate())}`;
|
|
143
159
|
}
|
|
144
|
-
capture(repoRoot, args) {
|
|
145
|
-
const result =
|
|
146
|
-
const err =
|
|
147
|
-
if (result.
|
|
148
|
-
return
|
|
149
|
-
|
|
150
|
-
return { ok: true, out: result.stdout.trim(), err };
|
|
160
|
+
async capture(repoRoot, args) {
|
|
161
|
+
const result = await this.git.run(args, repoRoot);
|
|
162
|
+
const err = result.stderr.trim();
|
|
163
|
+
if (!result.ok)
|
|
164
|
+
return new CapturedOutput(false, '', err !== '' ? err : 'git command failed');
|
|
165
|
+
return new CapturedOutput(true, result.stdout.trim(), err);
|
|
151
166
|
}
|
|
152
167
|
};
|
|
153
168
|
exports.BranchArchiver = BranchArchiver;
|
|
154
169
|
exports.BranchArchiver = BranchArchiver = tslib_1.__decorate([
|
|
155
|
-
(0, inversify_1.injectable)(inversify_1.bindingScopeValues.Singleton)
|
|
170
|
+
(0, inversify_1.injectable)(inversify_1.bindingScopeValues.Singleton),
|
|
171
|
+
tslib_1.__metadata("design:paramtypes", [tooling_common_1.GitProcessApi])
|
|
156
172
|
], BranchArchiver);
|
|
157
173
|
//# sourceMappingURL=branch-archiver.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"branch-archiver.js","sourceRoot":"","sources":["../../../../../packages/tooling/repo-workflow-core/src/branch-archiver.ts"],"names":[],"mappings":"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","sourcesContent":["import { spawnSync } from 'child_process';\nimport { injectable, bindingScopeValues } from 'inversify';\n\n/**\n * Archive a branch tip as a git TAG immediately before the branch is deleted.\n *\n * WHY this exists: `<feature>PreMerge<n>` snapshot BRANCHES exist for a real reason — after a squash\n * merge you sometimes need the original pre-merge history to debug — but they are branches, so they\n * accumulate forever, count toward `branch-creation-guard`'s maxLocalBranches, and can be committed\n * onto by accident. Observed in the wild: 6 parked branches against a cap of 5, which then REFUSED to\n * create the escape branch and pushed the agent into editing webpieces.config.json to get out.\n *\n * A tag is strictly better at the one job the branch was doing:\n * - vs KEEPING THE BRANCH — a tag is invisible to `git branch`, does not count toward the branch cap,\n * and cannot be accidentally committed onto.\n * - vs storing a PATCH in merge-info — a patch is lossy: it drops commit boundaries, messages,\n * authorship and parentage. A tag preserves the exact objects.\n * - vs relying on the REFLOG — the reflog expires (90 days by default) and is local to the one clone\n * that did the work. A tag survives `gc` and can be pushed if durability beyond one machine is wanted.\n *\n * Cost: one ref, zero new objects — the commits are already in the object store.\n *\n * Round trip (verified by hand, and asserted in branch-archiver.spec.ts against a real repo):\n * git tag archive/2026-07-30/dean/foo dean/foo\n * git branch -D dean/foo\n * git checkout -b dean/foo archive/2026-07-30/dean/foo # exact objects restored\n */\n\n// Retention policy for a branch whose PR has landed (or which wp-cleanup proved dead).\n// DELETE — delete outright, recoverable only via the reflog (the pre-0.4.492 behaviour).\n// ARCHIVE_TAG — tag the tip first, then delete. The default: same disk cost, permanently recoverable.\n// KEEP — do not delete at all (the branch keeps counting toward the branch cap).\nexport const BRANCH_RETENTION_DELETE = 'delete';\nexport const BRANCH_RETENTION_ARCHIVE_TAG = 'archive-tag';\nexport const BRANCH_RETENTION_KEEP = 'keep';\nexport const BRANCH_RETENTIONS: readonly string[] = [\n BRANCH_RETENTION_DELETE,\n BRANCH_RETENTION_ARCHIVE_TAG,\n BRANCH_RETENTION_KEEP,\n];\n\n// The one namespace every archive tag lives under, so `git tag --list 'archive/*'` is the whole\n// inventory and `git push origin 'refs/tags/archive/*'` is the whole backup.\nexport const ARCHIVE_TAG_PREFIX = 'archive';\n\n// Data-only (per CLAUDE.md, classes for data): what archiving one branch produced.\n// `tag` is '' when nothing was tagged — either the policy said not to, or git refused; `error` says which.\nexport class ArchiveResult {\n tag: string;\n sha: string;\n ok: boolean;\n error: string;\n\n constructor(tag: string, sha: string, ok: boolean, error: string) {\n this.tag = tag;\n this.sha = sha;\n this.ok = ok;\n this.error = error;\n }\n}\n\n// Result of a captured git invocation. `err` carries stderr so a refused tag can be reported verbatim.\ninterface CmdCapture {\n ok: boolean;\n out: string;\n err: string;\n}\n\n@injectable(bindingScopeValues.Singleton)\nexport class BranchArchiver {\n /**\n * `archive/<YYYY-MM-DD>/<branch>`. The date segment is what makes the namespace browsable\n * chronologically (`git tag --list 'archive/2026-07-*'`) and is also what keeps two archives of the\n * SAME branch name from colliding across days — the common case, since a branch name gets reused.\n */\n archiveTagName(branch: string, when: Date = new Date()): string {\n return `${ARCHIVE_TAG_PREFIX}/${this.dateSegment(when)}/${branch}`;\n }\n\n /**\n * Tag `branch`'s current tip, never overwriting an existing tag.\n *\n * Same branch archived twice on the SAME day gets `…-2`, `…-3`, … rather than `git tag -f`: force\n * would silently destroy the older archive, which is the exact failure the archive exists to prevent.\n * Returns ok=false (with git's stderr) rather than throwing — an archive that cannot be written must\n * turn into \"then do not delete either\", a decision the CALLER makes.\n *\n * IDEMPOTENT for the same tip: if the base name is already taken by a tag pointing at the SAME sha,\n * that tag is returned as-is rather than minting a `-2` beside it. Re-archiving an identical tip is\n * not a collision — it is the same archive — and `wp-land-pr` produces exactly that case, tagging\n * the branch in its own recap and then handing the worktree reap to a child that (correctly,\n * because it trusts nothing the parent told it) archives again a second later. Without this the\n * archive namespace fills with `…` / `…-2` pairs at one sha, which reads like two distinct\n * snapshots and is one more ref to explain.\n */\n archive(repoRoot: string, branch: string, when: Date = new Date()): ArchiveResult {\n const resolved = this.capture(repoRoot, ['rev-parse', branch]);\n if (!resolved.ok) return new ArchiveResult('', '', false, `cannot resolve ${branch}: ${resolved.err}`);\n const sha = resolved.out;\n\n const base = this.archiveTagName(branch, when);\n if (this.tagExists(repoRoot, base) && this.tagSha(repoRoot, base) === sha) {\n return new ArchiveResult(base, sha, true, '');\n }\n\n const tag = this.freeTagName(repoRoot, base);\n const tagged = this.capture(repoRoot, ['tag', tag, sha]);\n if (!tagged.ok) return new ArchiveResult('', sha, false, tagged.err);\n return new ArchiveResult(tag, sha, true, '');\n }\n\n /** The literal command a human runs to bring an archived branch back, byte-identical to what was deleted. */\n restoreCommand(branch: string, tag: string): string {\n return `git checkout -b ${branch} ${tag}`;\n }\n\n /** Every archive tag currently in the repo, newest namespace segment first (plain lexical on the date). */\n listArchiveTags(repoRoot: string): string[] {\n const result = this.capture(repoRoot, ['tag', '--list', `${ARCHIVE_TAG_PREFIX}/*`]);\n if (!result.ok || result.out === '') return [];\n return result.out\n .split('\\n')\n .map((line: string): string => line.trim())\n .filter((line: string): boolean => line !== '')\n .sort()\n .reverse();\n }\n\n // First unused name in the `<base>`, `<base>-2`, `<base>-3` … series. Bounded so a pathological repo\n // cannot spin: past the bound we hand back the last candidate and let `git tag` report the collision.\n private freeTagName(repoRoot: string, base: string): string {\n const MAX_ATTEMPTS = 50;\n let candidate = base;\n for (let attempt = 2; attempt <= MAX_ATTEMPTS; attempt += 1) {\n if (!this.tagExists(repoRoot, candidate)) return candidate;\n candidate = `${base}-${String(attempt)}`;\n }\n return candidate;\n }\n\n // The COMMIT an existing tag points at, '' when it cannot be resolved. `^{commit}` peels an\n // annotated tag, so an archive written by hand with `git tag -a` compares equal to a lightweight one.\n private tagSha(repoRoot: string, tag: string): string {\n const result = this.capture(repoRoot, ['rev-parse', `refs/tags/${tag}^{commit}`]);\n return result.ok ? result.out : '';\n }\n\n // Seam: overridden in the spec so name-collision logic is testable with no git and no repo.\n protected tagExists(repoRoot: string, tag: string): boolean {\n return this.capture(repoRoot, ['rev-parse', '--verify', '--quiet', `refs/tags/${tag}`]).ok;\n }\n\n // YYYY-MM-DD in LOCAL time: the archive is browsed by the human who created it, on their calendar.\n private dateSegment(when: Date): string {\n const pad = (value: number): string => String(value).padStart(2, '0');\n return `${String(when.getFullYear())}-${pad(when.getMonth() + 1)}-${pad(when.getDate())}`;\n }\n\n private capture(repoRoot: string, args: string[]): CmdCapture {\n const result = spawnSync('git', args, { cwd: repoRoot, encoding: 'utf8' });\n const err = typeof result.stderr === 'string' ? result.stderr.trim() : '';\n if (result.status !== 0 || typeof result.stdout !== 'string') {\n return { ok: false, out: '', err: err !== '' ? err : 'git command failed' };\n }\n return { ok: true, out: result.stdout.trim(), err };\n }\n}\n"]}
|
|
1
|
+
{"version":3,"file":"branch-archiver.js","sourceRoot":"","sources":["../../../../../packages/tooling/repo-workflow-core/src/branch-archiver.ts"],"names":[],"mappings":"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","sourcesContent":["import { injectable, bindingScopeValues } from 'inversify';\nimport { GitProcessApi, GitProcessClient } from '@webpieces/tooling-common';\n\n/**\n * Archive a branch tip as a git TAG immediately before the branch is deleted.\n *\n * WHY this exists: `<feature>PreMerge<n>` snapshot BRANCHES exist for a real reason — after a squash\n * merge you sometimes need the original pre-merge history to debug — but they are branches, so they\n * accumulate forever, count toward `branch-creation-guard`'s maxLocalBranches, and can be committed\n * onto by accident. Observed in the wild: 6 parked branches against a cap of 5, which then REFUSED to\n * create the escape branch and pushed the agent into editing webpieces.config.json to get out.\n *\n * A tag is strictly better at the one job the branch was doing:\n * - vs KEEPING THE BRANCH — a tag is invisible to `git branch`, does not count toward the branch cap,\n * and cannot be accidentally committed onto.\n * - vs storing a PATCH in merge-info — a patch is lossy: it drops commit boundaries, messages,\n * authorship and parentage. A tag preserves the exact objects.\n * - vs relying on the REFLOG — the reflog expires (90 days by default) and is local to the one clone\n * that did the work. A tag survives `gc` and can be pushed if durability beyond one machine is wanted.\n *\n * Cost: one ref, zero new objects — the commits are already in the object store.\n *\n * Round trip (verified by hand; branch-archiver.spec.ts asserts the commands issued, against scripted git):\n * git tag archive/2026-07-30/dean/foo dean/foo\n * git branch -D dean/foo\n * git checkout -b dean/foo archive/2026-07-30/dean/foo # exact objects restored\n */\n\n// Retention policy for a branch whose PR has landed (or which wp-cleanup proved dead).\n// DELETE — delete outright, recoverable only via the reflog (the pre-0.4.492 behaviour).\n// ARCHIVE_TAG — tag the tip first, then delete. The default: same disk cost, permanently recoverable.\n// KEEP — do not delete at all (the branch keeps counting toward the branch cap).\nexport const BRANCH_RETENTION_DELETE = 'delete';\nexport const BRANCH_RETENTION_ARCHIVE_TAG = 'archive-tag';\nexport const BRANCH_RETENTION_KEEP = 'keep';\nexport const BRANCH_RETENTIONS: readonly string[] = [\n BRANCH_RETENTION_DELETE,\n BRANCH_RETENTION_ARCHIVE_TAG,\n BRANCH_RETENTION_KEEP,\n];\n\n// The one namespace every archive tag lives under, so `git tag --list 'archive/*'` is the whole\n// inventory and `git push origin 'refs/tags/archive/*'` is the whole backup.\nexport const ARCHIVE_TAG_PREFIX = 'archive';\n\n// Data-only (per CLAUDE.md, classes for data): what archiving one branch produced.\n// `tag` is '' when nothing was tagged — either the policy said not to, or git refused; `error` says which.\nexport class ArchiveResult {\n tag: string;\n sha: string;\n ok: boolean;\n error: string;\n\n constructor(tag: string, sha: string, ok: boolean, error: string) {\n this.tag = tag;\n this.sha = sha;\n this.ok = ok;\n this.error = error;\n }\n}\n\n// Internal: one git call's verdict, trimmed stdout, and stderr so a refused tag can be reported verbatim. Data only.\nclass CapturedOutput {\n constructor(readonly ok: boolean, readonly out: string, readonly err: string) {}\n}\n\n@injectable(bindingScopeValues.Singleton)\nexport class BranchArchiver {\n // Defaulted so the non-DI call sites can `new BranchArchiver()`, while inversify injects the container's\n // git edge — which is what a feature test rebinds.\n constructor(private readonly git: GitProcessApi = new GitProcessClient()) {}\n\n /**\n * `archive/<YYYY-MM-DD>/<branch>`. The date segment is what makes the namespace browsable\n * chronologically (`git tag --list 'archive/2026-07-*'`) and is also what keeps two archives of the\n * SAME branch name from colliding across days — the common case, since a branch name gets reused.\n */\n archiveTagName(branch: string, when: Date = new Date()): string {\n return `${ARCHIVE_TAG_PREFIX}/${this.dateSegment(when)}/${branch}`;\n }\n\n /**\n * Tag `branch`'s current tip, never overwriting an existing tag.\n *\n * Same branch archived twice on the SAME day gets `…-2`, `…-3`, … rather than `git tag -f`: force\n * would silently destroy the older archive, which is the exact failure the archive exists to prevent.\n * Returns ok=false (with git's stderr) rather than throwing — an archive that cannot be written must\n * turn into \"then do not delete either\", a decision the CALLER makes.\n *\n * IDEMPOTENT for the same tip: if the base name is already taken by a tag pointing at the SAME sha,\n * that tag is returned as-is rather than minting a `-2` beside it. Re-archiving an identical tip is\n * not a collision — it is the same archive — and `wp-land-pr` produces exactly that case, tagging\n * the branch in its own recap and then handing the worktree reap to a child that (correctly,\n * because it trusts nothing the parent told it) archives again a second later. Without this the\n * archive namespace fills with `…` / `…-2` pairs at one sha, which reads like two distinct\n * snapshots and is one more ref to explain.\n */\n async archive(repoRoot: string, branch: string, when: Date = new Date()): Promise<ArchiveResult> {\n const resolved = await this.capture(repoRoot, ['rev-parse', branch]);\n if (!resolved.ok) return new ArchiveResult('', '', false, `cannot resolve ${branch}: ${resolved.err}`);\n const sha = resolved.out;\n\n const base = this.archiveTagName(branch, when);\n if (await this.tagExists(repoRoot, base) && await this.tagSha(repoRoot, base) === sha) {\n return new ArchiveResult(base, sha, true, '');\n }\n\n const tag = await this.freeTagName(repoRoot, base);\n const tagged = await this.capture(repoRoot, ['tag', tag, sha]);\n if (!tagged.ok) return new ArchiveResult('', sha, false, tagged.err);\n return new ArchiveResult(tag, sha, true, '');\n }\n\n /** The literal command a human runs to bring an archived branch back, byte-identical to what was deleted. */\n restoreCommand(branch: string, tag: string): string {\n return `git checkout -b ${branch} ${tag}`;\n }\n\n /** Every archive tag currently in the repo, newest namespace segment first (plain lexical on the date). */\n async listArchiveTags(repoRoot: string): Promise<string[]> {\n const result = await this.capture(repoRoot, ['tag', '--list', `${ARCHIVE_TAG_PREFIX}/*`]);\n if (!result.ok || result.out === '') return [];\n return result.out\n .split('\\n')\n .map((line: string): string => line.trim())\n .filter((line: string): boolean => line !== '')\n .sort()\n .reverse();\n }\n\n // First unused name in the `<base>`, `<base>-2`, `<base>-3` … series. Bounded so a pathological repo\n // cannot spin: past the bound we hand back the last candidate and let `git tag` report the collision.\n private async freeTagName(repoRoot: string, base: string): Promise<string> {\n const MAX_ATTEMPTS = 50;\n let candidate = base;\n for (let attempt = 2; attempt <= MAX_ATTEMPTS; attempt += 1) {\n if (!await this.tagExists(repoRoot, candidate)) return candidate;\n candidate = `${base}-${String(attempt)}`;\n }\n return candidate;\n }\n\n // The COMMIT an existing tag points at, '' when it cannot be resolved. `^{commit}` peels an\n // annotated tag, so an archive written by hand with `git tag -a` compares equal to a lightweight one.\n private async tagSha(repoRoot: string, tag: string): Promise<string> {\n const result = await this.capture(repoRoot, ['rev-parse', `refs/tags/${tag}^{commit}`]);\n return result.ok ? result.out : '';\n }\n\n private async tagExists(repoRoot: string, tag: string): Promise<boolean> {\n return (await this.capture(repoRoot, ['rev-parse', '--verify', '--quiet', `refs/tags/${tag}`])).ok;\n }\n\n // YYYY-MM-DD in LOCAL time: the archive is browsed by the human who created it, on their calendar.\n private dateSegment(when: Date): string {\n const pad = (value: number): string => String(value).padStart(2, '0');\n return `${String(when.getFullYear())}-${pad(when.getMonth() + 1)}-${pad(when.getDate())}`;\n }\n\n private async capture(repoRoot: string, args: string[]): Promise<CapturedOutput> {\n const result = await this.git.run(args, repoRoot);\n const err = result.stderr.trim();\n if (!result.ok) return new CapturedOutput(false, '', err !== '' ? err : 'git command failed');\n return new CapturedOutput(true, result.stdout.trim(), err);\n }\n}\n"]}
|
package/src/branch-reaper.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { GitProcessApi } from '@webpieces/tooling-common';
|
|
1
2
|
import { BranchArchiver } from './branch-archiver';
|
|
2
3
|
import { BranchMutationLog, MutationVerb } from './branch-mutation-log';
|
|
3
4
|
import { DeletableBranch, MergedBranchesCache } from './merged-branch-verdicts';
|
|
@@ -72,7 +73,8 @@ export declare class BranchReaper {
|
|
|
72
73
|
private readonly mergedBranches;
|
|
73
74
|
private readonly mutationLog;
|
|
74
75
|
private readonly archiver;
|
|
75
|
-
|
|
76
|
+
private readonly git;
|
|
77
|
+
constructor(mergedBranches?: MergedBranchesService, mutationLog?: BranchMutationLog, archiver?: BranchArchiver, git?: GitProcessApi);
|
|
76
78
|
/**
|
|
77
79
|
* Delete every branch the verdicts call dead, one command at a time.
|
|
78
80
|
*
|
|
@@ -82,14 +84,14 @@ export declare class BranchReaper {
|
|
|
82
84
|
* to go stale, which is fine for blocking a branch creation but is not fine for deleting, since a
|
|
83
85
|
* branch may have gained commits since it was written. Deleting never reads the stale file.
|
|
84
86
|
*/
|
|
85
|
-
reap(repoRoot: string, verb: MutationVerb, cache?: MergedBranchesCache | null, retention?: string): ReapResult
|
|
87
|
+
reap(repoRoot: string, verb: MutationVerb, cache?: MergedBranchesCache | null, retention?: string): Promise<ReapResult>;
|
|
86
88
|
/**
|
|
87
89
|
* Delete a CALLER-SUPPLIED list of branches — the branches a human just said yes to at wp-cleanup's
|
|
88
90
|
* classification prompt. Separate from `reap` because these are NOT provably dead: they earned their
|
|
89
91
|
* deletion from an explicit human answer, not from a verdict, so nothing here may ever run unattended.
|
|
90
92
|
* Archiving still happens first, which is precisely what makes that yes low-stakes.
|
|
91
93
|
*/
|
|
92
|
-
reapApproved(repoRoot: string, verb: MutationVerb, approved: DeletableBranch[], retention?: string): ReapResult
|
|
94
|
+
reapApproved(repoRoot: string, verb: MutationVerb, approved: DeletableBranch[], retention?: string): Promise<ReapResult>;
|
|
93
95
|
private reapBranches;
|
|
94
96
|
/**
|
|
95
97
|
* One branch: ARCHIVE the tip as a tag, then one `git branch -D`. Never the multi-name form the old
|
package/src/branch-reaper.js
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.BranchReaper = exports.ReapResult = exports.ReapedBranch = void 0;
|
|
4
4
|
const tslib_1 = require("tslib");
|
|
5
|
-
const child_process_1 = require("child_process");
|
|
6
5
|
const inversify_1 = require("inversify");
|
|
6
|
+
const tooling_common_1 = require("@webpieces/tooling-common");
|
|
7
7
|
const branch_archiver_1 = require("./branch-archiver");
|
|
8
8
|
const branch_mutation_log_1 = require("./branch-mutation-log");
|
|
9
9
|
const merged_branch_verdicts_1 = require("./merged-branch-verdicts");
|
|
@@ -93,17 +93,30 @@ class ReapResult {
|
|
|
93
93
|
}
|
|
94
94
|
}
|
|
95
95
|
exports.ReapResult = ReapResult;
|
|
96
|
+
// Internal: one git call's verdict, trimmed stdout, and stderr so a failed delete can be reported. Data only.
|
|
97
|
+
class CapturedOutput {
|
|
98
|
+
ok;
|
|
99
|
+
out;
|
|
100
|
+
err;
|
|
101
|
+
constructor(ok, out, err) {
|
|
102
|
+
this.ok = ok;
|
|
103
|
+
this.out = out;
|
|
104
|
+
this.err = err;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
96
107
|
let BranchReaper = class BranchReaper {
|
|
97
108
|
mergedBranches;
|
|
98
109
|
mutationLog;
|
|
99
110
|
archiver;
|
|
111
|
+
git;
|
|
100
112
|
// Defaulted so the non-DI call sites (the detached refresher in sync-main.ts) can just
|
|
101
113
|
// `new BranchReaper()`, while inversify still injects the singletons when resolved from a
|
|
102
114
|
// container. Mirrors how MergedBranchesService defaults its WorktreeService.
|
|
103
|
-
constructor(mergedBranches = new merged_branches_1.MergedBranchesService(), mutationLog = new branch_mutation_log_1.BranchMutationLog(), archiver = new branch_archiver_1.BranchArchiver()) {
|
|
115
|
+
constructor(mergedBranches = new merged_branches_1.MergedBranchesService(), mutationLog = new branch_mutation_log_1.BranchMutationLog(), archiver = new branch_archiver_1.BranchArchiver(), git = new tooling_common_1.GitProcessClient()) {
|
|
104
116
|
this.mergedBranches = mergedBranches;
|
|
105
117
|
this.mutationLog = mutationLog;
|
|
106
118
|
this.archiver = archiver;
|
|
119
|
+
this.git = git;
|
|
107
120
|
}
|
|
108
121
|
/**
|
|
109
122
|
* Delete every branch the verdicts call dead, one command at a time.
|
|
@@ -114,10 +127,10 @@ let BranchReaper = class BranchReaper {
|
|
|
114
127
|
* to go stale, which is fine for blocking a branch creation but is not fine for deleting, since a
|
|
115
128
|
* branch may have gained commits since it was written. Deleting never reads the stale file.
|
|
116
129
|
*/
|
|
117
|
-
reap(repoRoot, verb, cache = null, retention = branch_archiver_1.BRANCH_RETENTION_ARCHIVE_TAG) {
|
|
130
|
+
async reap(repoRoot, verb, cache = null, retention = branch_archiver_1.BRANCH_RETENTION_ARCHIVE_TAG) {
|
|
118
131
|
// `false`: this is the BRANCH reap, and worktree locks are none of its business. Ignoring one
|
|
119
132
|
// would only widen the WORKTREE verdicts it never reads.
|
|
120
|
-
const verdicts = cache ?? this.mergedBranches.computeMergedBranches(repoRoot, false);
|
|
133
|
+
const verdicts = cache ?? await this.mergedBranches.computeMergedBranches(repoRoot, false);
|
|
121
134
|
// 'keep' means "never delete anything" — the reap becomes a pure report. Everything still lands
|
|
122
135
|
// in `spared` so the human sees exactly what WOULD have been reaped under the other policies.
|
|
123
136
|
if (retention === branch_archiver_1.BRANCH_RETENTION_KEEP) {
|
|
@@ -131,14 +144,14 @@ let BranchReaper = class BranchReaper {
|
|
|
131
144
|
* deletion from an explicit human answer, not from a verdict, so nothing here may ever run unattended.
|
|
132
145
|
* Archiving still happens first, which is precisely what makes that yes low-stakes.
|
|
133
146
|
*/
|
|
134
|
-
reapApproved(repoRoot, verb, approved, retention = branch_archiver_1.BRANCH_RETENTION_ARCHIVE_TAG) {
|
|
147
|
+
async reapApproved(repoRoot, verb, approved, retention = branch_archiver_1.BRANCH_RETENTION_ARCHIVE_TAG) {
|
|
135
148
|
if (retention === branch_archiver_1.BRANCH_RETENTION_KEEP)
|
|
136
149
|
return new ReapResult([], [], approved);
|
|
137
150
|
const reaped = [];
|
|
138
151
|
const failed = [];
|
|
139
152
|
const alreadyGone = [];
|
|
140
153
|
for (const entry of approved) {
|
|
141
|
-
const outcome = this.deleteOne(repoRoot, verb, entry, retention);
|
|
154
|
+
const outcome = await this.deleteOne(repoRoot, verb, entry, retention);
|
|
142
155
|
if (outcome.ok)
|
|
143
156
|
reaped.push(outcome);
|
|
144
157
|
else if (outcome.alreadyGone)
|
|
@@ -148,12 +161,12 @@ let BranchReaper = class BranchReaper {
|
|
|
148
161
|
}
|
|
149
162
|
return new ReapResult(reaped, failed, [], alreadyGone);
|
|
150
163
|
}
|
|
151
|
-
reapBranches(repoRoot, verb, targets, retention, verdicts) {
|
|
164
|
+
async reapBranches(repoRoot, verb, targets, retention, verdicts) {
|
|
152
165
|
const reaped = [];
|
|
153
166
|
const failed = [];
|
|
154
167
|
const alreadyGone = [];
|
|
155
168
|
for (const entry of targets) {
|
|
156
|
-
const outcome = this.deleteOne(repoRoot, verb, entry, retention);
|
|
169
|
+
const outcome = await this.deleteOne(repoRoot, verb, entry, retention);
|
|
157
170
|
if (outcome.ok)
|
|
158
171
|
reaped.push(outcome);
|
|
159
172
|
else if (outcome.alreadyGone)
|
|
@@ -176,10 +189,10 @@ let BranchReaper = class BranchReaper {
|
|
|
176
189
|
* to stop relying on the reflog. Refusing to delete is the fail-safe direction: the worst case is a
|
|
177
190
|
* branch that survives one more cleanup cycle.
|
|
178
191
|
*/
|
|
179
|
-
deleteOne(repoRoot, verb, entry, retention) {
|
|
192
|
+
async deleteOne(repoRoot, verb, entry, retention) {
|
|
180
193
|
// SHA first — after the delete there is no branch left to resolve, and the whole point of the
|
|
181
194
|
// audit line is that it records what was destroyed while it still exists.
|
|
182
|
-
const resolved = this.capture(repoRoot, ['rev-parse', entry.branch]);
|
|
195
|
+
const resolved = await this.capture(repoRoot, ['rev-parse', entry.branch]);
|
|
183
196
|
const sha = resolved.ok ? resolved.out : '';
|
|
184
197
|
// It does not resolve ⇒ it is already gone, reaped by whoever won the race (see
|
|
185
198
|
// ReapedBranch.alreadyGone). Report that outcome honestly instead of letting it fall through
|
|
@@ -188,11 +201,11 @@ let BranchReaper = class BranchReaper {
|
|
|
188
201
|
if (!resolved.ok)
|
|
189
202
|
return this.alreadyGone(repoRoot, verb, entry);
|
|
190
203
|
const archive = retention === branch_archiver_1.BRANCH_RETENTION_ARCHIVE_TAG
|
|
191
|
-
? this.archiver.archive(repoRoot, entry.branch)
|
|
204
|
+
? await this.archiver.archive(repoRoot, entry.branch)
|
|
192
205
|
: new branch_archiver_1.ArchiveResult('', sha, true, '');
|
|
193
206
|
if (!archive.ok)
|
|
194
207
|
return this.archiveFailed(repoRoot, verb, entry, sha, archive);
|
|
195
|
-
const deleted = this.capture(repoRoot, ['branch', '-D', entry.branch]);
|
|
208
|
+
const deleted = await this.capture(repoRoot, ['branch', '-D', entry.branch]);
|
|
196
209
|
const result = new ReapedBranch(entry.branch, sha, entry.reason, entry.pr, deleted.ok, deleted.ok ? '' : deleted.err);
|
|
197
210
|
result.archiveTag = archive.tag;
|
|
198
211
|
const event = new branch_mutation_log_1.BranchMutationEvent(verb, 'REAP');
|
|
@@ -242,13 +255,12 @@ let BranchReaper = class BranchReaper {
|
|
|
242
255
|
this.mergedBranches.writeMergedBranches(repoRoot, new merged_branch_verdicts_1.MergedBranchesCache(verdicts.timestamp, remaining, verdicts.keep, verdicts.worktrees));
|
|
243
256
|
}
|
|
244
257
|
// Run a git command capturing trimmed stdout/stderr; ok=false on spawn failure or non-zero exit.
|
|
245
|
-
capture(repoRoot, args) {
|
|
246
|
-
const result =
|
|
247
|
-
const err =
|
|
248
|
-
if (result.
|
|
249
|
-
return
|
|
250
|
-
|
|
251
|
-
return { ok: true, out: result.stdout.trim(), err };
|
|
258
|
+
async capture(repoRoot, args) {
|
|
259
|
+
const result = await this.git.run(args, repoRoot);
|
|
260
|
+
const err = result.stderr.trim();
|
|
261
|
+
if (!result.ok)
|
|
262
|
+
return new CapturedOutput(false, '', err !== '' ? err : 'git command failed');
|
|
263
|
+
return new CapturedOutput(true, result.stdout.trim(), err);
|
|
252
264
|
}
|
|
253
265
|
};
|
|
254
266
|
exports.BranchReaper = BranchReaper;
|
|
@@ -256,6 +268,7 @@ exports.BranchReaper = BranchReaper = tslib_1.__decorate([
|
|
|
256
268
|
(0, inversify_1.injectable)(inversify_1.bindingScopeValues.Singleton),
|
|
257
269
|
tslib_1.__metadata("design:paramtypes", [merged_branches_1.MergedBranchesService,
|
|
258
270
|
branch_mutation_log_1.BranchMutationLog,
|
|
259
|
-
branch_archiver_1.BranchArchiver
|
|
271
|
+
branch_archiver_1.BranchArchiver,
|
|
272
|
+
tooling_common_1.GitProcessApi])
|
|
260
273
|
], BranchReaper);
|
|
261
274
|
//# sourceMappingURL=branch-reaper.js.map
|
package/src/branch-reaper.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"branch-reaper.js","sourceRoot":"","sources":["../../../../../packages/tooling/repo-workflow-core/src/branch-reaper.ts"],"names":[],"mappings":"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","sourcesContent":["import { spawnSync } from 'child_process';\nimport { injectable, bindingScopeValues } from 'inversify';\n\nimport {\n ArchiveResult,\n BranchArchiver,\n BRANCH_RETENTION_ARCHIVE_TAG,\n BRANCH_RETENTION_KEEP,\n} from './branch-archiver';\nimport { BranchMutationEvent, BranchMutationLog, MutationVerb } from './branch-mutation-log';\nimport { DeletableBranch, MergedBranchesCache } from './merged-branch-verdicts';\nimport { MergedBranchesService } from './merged-branches';\n\n/**\n * The EXECUTOR for the dead-branch verdicts that merged-branches.ts computes.\n *\n * WHY this exists: for a long time nothing in the tooling ever deleted a branch. The reap was only\n * ever a `git branch -D a b c` STRING embedded in a guard's fix hint, handed to an AI agent — which\n * reads a raw `-D` as destructive, asks \"may I clean this up?\", and stops. So the verdicts were\n * computed correctly on every hook call and then acted on by nobody, and local branches grew without\n * bound. The fix is not better wording; it is a thing that does the deleting.\n *\n * WHY deleting here is safe: every entry in `deletable` earned its place by one of exactly three\n * proofs — a MERGED PR (the work is in main), a squash-merge BACKUP of a merged branch, or ZERO\n * commits of its own (there is no work). merged-branches.ts also guarantees `main` is never in the\n * list and that a branch checked out in ANY worktree lands in `keep` instead, so we can never delete\n * the branch someone is standing on. On top of that, every delete is logged with the branch's\n * pre-delete SHA and a literal recover command, so no reap is unrecoverable.\n */\n\n// Data-only (per CLAUDE.md, classes for data). One branch and what happened to it.\nexport class ReapedBranch {\n branch: string;\n // The commit the branch pointed at BEFORE deletion — captured first, precisely so a delete is\n // always undoable via `git branch <branch> <sha>`. Empty only if rev-parse itself failed.\n sha: string;\n reason: string;\n pr: number;\n ok: boolean;\n // git's own stderr when ok=false. Kept verbatim: a failed delete is a thing a human must read.\n error: string;\n /**\n * The `archive/<date>/<branch>` tag written immediately BEFORE the delete, or '' when the retention\n * policy is 'delete'. Non-empty means the branch is restorable by NAME rather than by remembering a\n * sha — `git checkout -b <branch> <tag>` restores the exact objects, and the tag survives `gc` and\n * reflog expiry. Field-with-default so every existing `new ReapedBranch(...)` call site still builds.\n */\n archiveTag: string = '';\n /**\n * The branch was ALREADY GONE when we reached it — someone else reaped it first.\n *\n * Not a failure, and the distinction is not cosmetic. `wp-cleanup` and the detached refresher's\n * `auto-reap` both act on their own fresh verdicts, and the hooks kick off the refresher on the\n * same commands a human runs wp-cleanup from, so the two race by design. Observed 2026-08-04:\n * auto-reap deleted both dead branches at 14:30:58.207 and 14:30:59.035, wp-cleanup reached them\n * 300ms later and reported \"⚠️ 2 branch(es) could not be deleted — could not archive it first:\n * unknown revision\", i.e. it announced a failure for the exact outcome it wanted. Whoever wins,\n * the branch is gone, archived and logged by the winner.\n *\n * Field-with-default so every existing `new ReapedBranch(...)` call site still builds.\n */\n alreadyGone: boolean = false;\n\n // eslint-disable-next-line @typescript-eslint/max-params\n constructor(branch: string, sha: string, reason: string, pr: number, ok: boolean, error: string) {\n this.branch = branch;\n this.sha = sha;\n this.reason = reason;\n this.pr = pr;\n this.ok = ok;\n this.error = error;\n }\n}\n\n/**\n * The outcome of one reap. `spared` is carried alongside deliberately: a cleanup that silently says\n * nothing about the branches it did NOT touch reads as \"there was nothing else\", when in fact those\n * are exactly the branches only a human can rule on.\n */\nexport class ReapResult {\n reaped: ReapedBranch[];\n failed: ReapedBranch[];\n spared: DeletableBranch[];\n /**\n * Branches a CONCURRENT reaper got to first (see ReapedBranch.alreadyGone). A separate bucket\n * because they belong in neither of the other two: counting them as `reaped` would credit this\n * run with a delete and an archive tag it did not write, and counting them as `failed` is the bug\n * this fixes — a red warning about branches that are, in fact, gone.\n *\n * Optional so every existing `new ReapResult(...)` call site still builds.\n */\n alreadyGone: ReapedBranch[];\n\n constructor(\n reaped: ReapedBranch[],\n failed: ReapedBranch[],\n spared: DeletableBranch[],\n alreadyGone: ReapedBranch[] = [],\n ) {\n this.reaped = reaped;\n this.failed = failed;\n this.spared = spared;\n this.alreadyGone = alreadyGone;\n }\n}\n\n// Result of a captured git invocation. `err` carries stderr so a failed delete can be reported.\ninterface CmdCapture {\n ok: boolean;\n out: string;\n err: string;\n}\n\n@injectable(bindingScopeValues.Singleton)\nexport class BranchReaper {\n // Defaulted so the non-DI call sites (the detached refresher in sync-main.ts) can just\n // `new BranchReaper()`, while inversify still injects the singletons when resolved from a\n // container. Mirrors how MergedBranchesService defaults its WorktreeService.\n constructor(\n private readonly mergedBranches: MergedBranchesService = new MergedBranchesService(),\n private readonly mutationLog: BranchMutationLog = new BranchMutationLog(),\n private readonly archiver: BranchArchiver = new BranchArchiver(),\n ) {}\n\n /**\n * Delete every branch the verdicts call dead, one command at a time.\n *\n * `cache` is an ALREADY-FRESH set of verdicts (the refresher just computed them, so re-running\n * the `gh` lookup would be pure waste). Pass nothing — as `wp-cleanup` does — and we recompute\n * from scratch. That distinction is load-bearing: the cache file on disk is DELIBERATELY allowed\n * to go stale, which is fine for blocking a branch creation but is not fine for deleting, since a\n * branch may have gained commits since it was written. Deleting never reads the stale file.\n */\n reap(\n repoRoot: string,\n verb: MutationVerb,\n cache: MergedBranchesCache | null = null,\n retention: string = BRANCH_RETENTION_ARCHIVE_TAG,\n ): ReapResult {\n // `false`: this is the BRANCH reap, and worktree locks are none of its business. Ignoring one\n // would only widen the WORKTREE verdicts it never reads.\n const verdicts = cache ?? this.mergedBranches.computeMergedBranches(repoRoot, false);\n // 'keep' means \"never delete anything\" — the reap becomes a pure report. Everything still lands\n // in `spared` so the human sees exactly what WOULD have been reaped under the other policies.\n if (retention === BRANCH_RETENTION_KEEP) {\n return new ReapResult([], [], [...verdicts.deletable, ...verdicts.keep]);\n }\n return this.reapBranches(repoRoot, verb, verdicts.deletable, retention, verdicts);\n }\n\n /**\n * Delete a CALLER-SUPPLIED list of branches — the branches a human just said yes to at wp-cleanup's\n * classification prompt. Separate from `reap` because these are NOT provably dead: they earned their\n * deletion from an explicit human answer, not from a verdict, so nothing here may ever run unattended.\n * Archiving still happens first, which is precisely what makes that yes low-stakes.\n */\n reapApproved(\n repoRoot: string,\n verb: MutationVerb,\n approved: DeletableBranch[],\n retention: string = BRANCH_RETENTION_ARCHIVE_TAG,\n ): ReapResult {\n if (retention === BRANCH_RETENTION_KEEP) return new ReapResult([], [], approved);\n const reaped: ReapedBranch[] = [];\n const failed: ReapedBranch[] = [];\n const alreadyGone: ReapedBranch[] = [];\n for (const entry of approved) {\n const outcome = this.deleteOne(repoRoot, verb, entry, retention);\n if (outcome.ok) reaped.push(outcome);\n else if (outcome.alreadyGone) alreadyGone.push(outcome);\n else failed.push(outcome);\n }\n return new ReapResult(reaped, failed, [], alreadyGone);\n }\n\n private reapBranches(\n repoRoot: string,\n verb: MutationVerb,\n targets: DeletableBranch[],\n retention: string,\n verdicts: MergedBranchesCache,\n ): ReapResult {\n const reaped: ReapedBranch[] = [];\n const failed: ReapedBranch[] = [];\n const alreadyGone: ReapedBranch[] = [];\n for (const entry of targets) {\n const outcome = this.deleteOne(repoRoot, verb, entry, retention);\n if (outcome.ok) reaped.push(outcome);\n else if (outcome.alreadyGone) alreadyGone.push(outcome);\n else failed.push(outcome);\n }\n\n // Only genuine failures stay in the cache's `deletable`. An already-gone branch is not there\n // to delete on the next pass, and leaving it in would make the cap keep counting a ghost.\n this.rewriteCache(repoRoot, verdicts, failed);\n return new ReapResult(reaped, failed, verdicts.keep, alreadyGone);\n }\n\n /**\n * One branch: ARCHIVE the tip as a tag, then one `git branch -D`. Never the multi-name form the old\n * fix hint used: git aborts the whole command on the first branch it refuses, which would strand\n * every branch after it in the list. One invocation each means one failure costs exactly one branch.\n *\n * The archive comes FIRST and, when it fails, the delete does NOT happen. A branch we could not\n * archive is a branch whose only remaining copy is the reflog, and the entire point of this change is\n * to stop relying on the reflog. Refusing to delete is the fail-safe direction: the worst case is a\n * branch that survives one more cleanup cycle.\n */\n private deleteOne(\n repoRoot: string,\n verb: MutationVerb,\n entry: DeletableBranch,\n retention: string,\n ): ReapedBranch {\n // SHA first — after the delete there is no branch left to resolve, and the whole point of the\n // audit line is that it records what was destroyed while it still exists.\n const resolved = this.capture(repoRoot, ['rev-parse', entry.branch]);\n const sha = resolved.ok ? resolved.out : '';\n\n // It does not resolve ⇒ it is already gone, reaped by whoever won the race (see\n // ReapedBranch.alreadyGone). Report that outcome honestly instead of letting it fall through\n // to the archive, which fails on the same missing ref and calls a successful cleanup a\n // failure. Nothing to archive and nothing to delete: the winner did both, and logged them.\n if (!resolved.ok) return this.alreadyGone(repoRoot, verb, entry);\n\n const archive = retention === BRANCH_RETENTION_ARCHIVE_TAG\n ? this.archiver.archive(repoRoot, entry.branch)\n : new ArchiveResult('', sha, true, '');\n if (!archive.ok) return this.archiveFailed(repoRoot, verb, entry, sha, archive);\n\n const deleted = this.capture(repoRoot, ['branch', '-D', entry.branch]);\n const result = new ReapedBranch(\n entry.branch, sha, entry.reason, entry.pr, deleted.ok, deleted.ok ? '' : deleted.err);\n result.archiveTag = archive.tag;\n\n const event = new BranchMutationEvent(verb, 'REAP');\n event.fromBranch = entry.branch;\n event.sha = sha;\n event.archiveTag = archive.tag;\n event.outcome = deleted.ok ? `deleted (${entry.reason})` : `FAILED (${deleted.err})`;\n this.mutationLog.logBranchMutation(repoRoot, event);\n\n return result;\n }\n\n /**\n * The branch vanished between the verdict and the reap — a concurrent reaper got it.\n *\n * Logged like every other outcome (a mutation nobody logs is a mutation nobody can audit), but\n * as ALREADY_GONE rather than SKIPPED/FAILED, and carried in its own bucket so the report can\n * say \"already gone\" instead of warning about a branch that is exactly as dead as intended.\n */\n private alreadyGone(repoRoot: string, verb: MutationVerb, entry: DeletableBranch): ReapedBranch {\n const event = new BranchMutationEvent(verb, 'REAP');\n event.fromBranch = entry.branch;\n event.outcome = 'ALREADY GONE (a concurrent reap deleted and archived it first)';\n this.mutationLog.logBranchMutation(repoRoot, event);\n\n const result = new ReapedBranch(\n entry.branch, '', entry.reason, entry.pr, false,\n 'already gone — a concurrent reap deleted and archived it first');\n result.alreadyGone = true;\n return result;\n }\n\n // Archiving failed ⇒ the branch is NOT deleted. Reported as a failure with git's own words, so the\n // human sees a branch that survived and why, rather than a silent skip.\n private archiveFailed(\n repoRoot: string, verb: MutationVerb, entry: DeletableBranch, sha: string, archive: ArchiveResult,\n ): ReapedBranch {\n const error = `not deleted — could not archive it first: ${archive.error}`;\n const event = new BranchMutationEvent(verb, 'REAP');\n event.fromBranch = entry.branch;\n event.sha = sha;\n event.outcome = `SKIPPED (${error})`;\n this.mutationLog.logBranchMutation(repoRoot, event);\n return new ReapedBranch(entry.branch, sha, entry.reason, entry.pr, false, error);\n }\n\n /**\n * Write the verdicts back with the reaped branches removed, so the branch-creation-guard's cap\n * sees the post-reap truth on its very next call instead of continuing to block against branches\n * that no longer exist. Anything that FAILED to delete stays in `deletable` — it is still there,\n * and still dead.\n */\n private rewriteCache(repoRoot: string, verdicts: MergedBranchesCache, failed: ReapedBranch[]): void {\n const stillDead = new Set(failed.map((entry: ReapedBranch): string => entry.branch));\n const remaining = verdicts.deletable.filter(\n (entry: DeletableBranch): boolean => stillDead.has(entry.branch));\n this.mergedBranches.writeMergedBranches(\n repoRoot,\n new MergedBranchesCache(verdicts.timestamp, remaining, verdicts.keep, verdicts.worktrees),\n );\n }\n\n // Run a git command capturing trimmed stdout/stderr; ok=false on spawn failure or non-zero exit.\n private capture(repoRoot: string, args: string[]): CmdCapture {\n const result = spawnSync('git', args, { cwd: repoRoot, encoding: 'utf8' });\n const err = typeof result.stderr === 'string' ? result.stderr.trim() : '';\n if (result.status !== 0 || typeof result.stdout !== 'string') {\n return { ok: false, out: '', err: err !== '' ? err : 'git command failed' };\n }\n return { ok: true, out: result.stdout.trim(), err };\n }\n}\n"]}
|
|
1
|
+
{"version":3,"file":"branch-reaper.js","sourceRoot":"","sources":["../../../../../packages/tooling/repo-workflow-core/src/branch-reaper.ts"],"names":[],"mappings":"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","sourcesContent":["import { injectable, bindingScopeValues } from 'inversify';\nimport { GitProcessApi, GitProcessClient } from '@webpieces/tooling-common';\n\nimport {\n ArchiveResult,\n BranchArchiver,\n BRANCH_RETENTION_ARCHIVE_TAG,\n BRANCH_RETENTION_KEEP,\n} from './branch-archiver';\nimport { BranchMutationEvent, BranchMutationLog, MutationVerb } from './branch-mutation-log';\nimport { DeletableBranch, MergedBranchesCache } from './merged-branch-verdicts';\nimport { MergedBranchesService } from './merged-branches';\n\n/**\n * The EXECUTOR for the dead-branch verdicts that merged-branches.ts computes.\n *\n * WHY this exists: for a long time nothing in the tooling ever deleted a branch. The reap was only\n * ever a `git branch -D a b c` STRING embedded in a guard's fix hint, handed to an AI agent — which\n * reads a raw `-D` as destructive, asks \"may I clean this up?\", and stops. So the verdicts were\n * computed correctly on every hook call and then acted on by nobody, and local branches grew without\n * bound. The fix is not better wording; it is a thing that does the deleting.\n *\n * WHY deleting here is safe: every entry in `deletable` earned its place by one of exactly three\n * proofs — a MERGED PR (the work is in main), a squash-merge BACKUP of a merged branch, or ZERO\n * commits of its own (there is no work). merged-branches.ts also guarantees `main` is never in the\n * list and that a branch checked out in ANY worktree lands in `keep` instead, so we can never delete\n * the branch someone is standing on. On top of that, every delete is logged with the branch's\n * pre-delete SHA and a literal recover command, so no reap is unrecoverable.\n */\n\n// Data-only (per CLAUDE.md, classes for data). One branch and what happened to it.\nexport class ReapedBranch {\n branch: string;\n // The commit the branch pointed at BEFORE deletion — captured first, precisely so a delete is\n // always undoable via `git branch <branch> <sha>`. Empty only if rev-parse itself failed.\n sha: string;\n reason: string;\n pr: number;\n ok: boolean;\n // git's own stderr when ok=false. Kept verbatim: a failed delete is a thing a human must read.\n error: string;\n /**\n * The `archive/<date>/<branch>` tag written immediately BEFORE the delete, or '' when the retention\n * policy is 'delete'. Non-empty means the branch is restorable by NAME rather than by remembering a\n * sha — `git checkout -b <branch> <tag>` restores the exact objects, and the tag survives `gc` and\n * reflog expiry. Field-with-default so every existing `new ReapedBranch(...)` call site still builds.\n */\n archiveTag: string = '';\n /**\n * The branch was ALREADY GONE when we reached it — someone else reaped it first.\n *\n * Not a failure, and the distinction is not cosmetic. `wp-cleanup` and the detached refresher's\n * `auto-reap` both act on their own fresh verdicts, and the hooks kick off the refresher on the\n * same commands a human runs wp-cleanup from, so the two race by design. Observed 2026-08-04:\n * auto-reap deleted both dead branches at 14:30:58.207 and 14:30:59.035, wp-cleanup reached them\n * 300ms later and reported \"⚠️ 2 branch(es) could not be deleted — could not archive it first:\n * unknown revision\", i.e. it announced a failure for the exact outcome it wanted. Whoever wins,\n * the branch is gone, archived and logged by the winner.\n *\n * Field-with-default so every existing `new ReapedBranch(...)` call site still builds.\n */\n alreadyGone: boolean = false;\n\n // eslint-disable-next-line @typescript-eslint/max-params\n constructor(branch: string, sha: string, reason: string, pr: number, ok: boolean, error: string) {\n this.branch = branch;\n this.sha = sha;\n this.reason = reason;\n this.pr = pr;\n this.ok = ok;\n this.error = error;\n }\n}\n\n/**\n * The outcome of one reap. `spared` is carried alongside deliberately: a cleanup that silently says\n * nothing about the branches it did NOT touch reads as \"there was nothing else\", when in fact those\n * are exactly the branches only a human can rule on.\n */\nexport class ReapResult {\n reaped: ReapedBranch[];\n failed: ReapedBranch[];\n spared: DeletableBranch[];\n /**\n * Branches a CONCURRENT reaper got to first (see ReapedBranch.alreadyGone). A separate bucket\n * because they belong in neither of the other two: counting them as `reaped` would credit this\n * run with a delete and an archive tag it did not write, and counting them as `failed` is the bug\n * this fixes — a red warning about branches that are, in fact, gone.\n *\n * Optional so every existing `new ReapResult(...)` call site still builds.\n */\n alreadyGone: ReapedBranch[];\n\n constructor(\n reaped: ReapedBranch[],\n failed: ReapedBranch[],\n spared: DeletableBranch[],\n alreadyGone: ReapedBranch[] = [],\n ) {\n this.reaped = reaped;\n this.failed = failed;\n this.spared = spared;\n this.alreadyGone = alreadyGone;\n }\n}\n\n// Internal: one git call's verdict, trimmed stdout, and stderr so a failed delete can be reported. Data only.\nclass CapturedOutput {\n constructor(readonly ok: boolean, readonly out: string, readonly err: string) {}\n}\n\n@injectable(bindingScopeValues.Singleton)\nexport class BranchReaper {\n // Defaulted so the non-DI call sites (the detached refresher in sync-main.ts) can just\n // `new BranchReaper()`, while inversify still injects the singletons when resolved from a\n // container. Mirrors how MergedBranchesService defaults its WorktreeService.\n constructor(\n private readonly mergedBranches: MergedBranchesService = new MergedBranchesService(),\n private readonly mutationLog: BranchMutationLog = new BranchMutationLog(),\n private readonly archiver: BranchArchiver = new BranchArchiver(),\n private readonly git: GitProcessApi = new GitProcessClient(),\n ) {}\n\n /**\n * Delete every branch the verdicts call dead, one command at a time.\n *\n * `cache` is an ALREADY-FRESH set of verdicts (the refresher just computed them, so re-running\n * the `gh` lookup would be pure waste). Pass nothing — as `wp-cleanup` does — and we recompute\n * from scratch. That distinction is load-bearing: the cache file on disk is DELIBERATELY allowed\n * to go stale, which is fine for blocking a branch creation but is not fine for deleting, since a\n * branch may have gained commits since it was written. Deleting never reads the stale file.\n */\n async reap(\n repoRoot: string,\n verb: MutationVerb,\n cache: MergedBranchesCache | null = null,\n retention: string = BRANCH_RETENTION_ARCHIVE_TAG,\n ): Promise<ReapResult> {\n // `false`: this is the BRANCH reap, and worktree locks are none of its business. Ignoring one\n // would only widen the WORKTREE verdicts it never reads.\n const verdicts = cache ?? await this.mergedBranches.computeMergedBranches(repoRoot, false);\n // 'keep' means \"never delete anything\" — the reap becomes a pure report. Everything still lands\n // in `spared` so the human sees exactly what WOULD have been reaped under the other policies.\n if (retention === BRANCH_RETENTION_KEEP) {\n return new ReapResult([], [], [...verdicts.deletable, ...verdicts.keep]);\n }\n return this.reapBranches(repoRoot, verb, verdicts.deletable, retention, verdicts);\n }\n\n /**\n * Delete a CALLER-SUPPLIED list of branches — the branches a human just said yes to at wp-cleanup's\n * classification prompt. Separate from `reap` because these are NOT provably dead: they earned their\n * deletion from an explicit human answer, not from a verdict, so nothing here may ever run unattended.\n * Archiving still happens first, which is precisely what makes that yes low-stakes.\n */\n async reapApproved(\n repoRoot: string,\n verb: MutationVerb,\n approved: DeletableBranch[],\n retention: string = BRANCH_RETENTION_ARCHIVE_TAG,\n ): Promise<ReapResult> {\n if (retention === BRANCH_RETENTION_KEEP) return new ReapResult([], [], approved);\n const reaped: ReapedBranch[] = [];\n const failed: ReapedBranch[] = [];\n const alreadyGone: ReapedBranch[] = [];\n for (const entry of approved) {\n const outcome = await this.deleteOne(repoRoot, verb, entry, retention);\n if (outcome.ok) reaped.push(outcome);\n else if (outcome.alreadyGone) alreadyGone.push(outcome);\n else failed.push(outcome);\n }\n return new ReapResult(reaped, failed, [], alreadyGone);\n }\n\n private async reapBranches(\n repoRoot: string,\n verb: MutationVerb,\n targets: DeletableBranch[],\n retention: string,\n verdicts: MergedBranchesCache,\n ): Promise<ReapResult> {\n const reaped: ReapedBranch[] = [];\n const failed: ReapedBranch[] = [];\n const alreadyGone: ReapedBranch[] = [];\n for (const entry of targets) {\n const outcome = await this.deleteOne(repoRoot, verb, entry, retention);\n if (outcome.ok) reaped.push(outcome);\n else if (outcome.alreadyGone) alreadyGone.push(outcome);\n else failed.push(outcome);\n }\n\n // Only genuine failures stay in the cache's `deletable`. An already-gone branch is not there\n // to delete on the next pass, and leaving it in would make the cap keep counting a ghost.\n this.rewriteCache(repoRoot, verdicts, failed);\n return new ReapResult(reaped, failed, verdicts.keep, alreadyGone);\n }\n\n /**\n * One branch: ARCHIVE the tip as a tag, then one `git branch -D`. Never the multi-name form the old\n * fix hint used: git aborts the whole command on the first branch it refuses, which would strand\n * every branch after it in the list. One invocation each means one failure costs exactly one branch.\n *\n * The archive comes FIRST and, when it fails, the delete does NOT happen. A branch we could not\n * archive is a branch whose only remaining copy is the reflog, and the entire point of this change is\n * to stop relying on the reflog. Refusing to delete is the fail-safe direction: the worst case is a\n * branch that survives one more cleanup cycle.\n */\n private async deleteOne(\n repoRoot: string,\n verb: MutationVerb,\n entry: DeletableBranch,\n retention: string,\n ): Promise<ReapedBranch> {\n // SHA first — after the delete there is no branch left to resolve, and the whole point of the\n // audit line is that it records what was destroyed while it still exists.\n const resolved = await this.capture(repoRoot, ['rev-parse', entry.branch]);\n const sha = resolved.ok ? resolved.out : '';\n\n // It does not resolve ⇒ it is already gone, reaped by whoever won the race (see\n // ReapedBranch.alreadyGone). Report that outcome honestly instead of letting it fall through\n // to the archive, which fails on the same missing ref and calls a successful cleanup a\n // failure. Nothing to archive and nothing to delete: the winner did both, and logged them.\n if (!resolved.ok) return this.alreadyGone(repoRoot, verb, entry);\n\n const archive = retention === BRANCH_RETENTION_ARCHIVE_TAG\n ? await this.archiver.archive(repoRoot, entry.branch)\n : new ArchiveResult('', sha, true, '');\n if (!archive.ok) return this.archiveFailed(repoRoot, verb, entry, sha, archive);\n\n const deleted = await this.capture(repoRoot, ['branch', '-D', entry.branch]);\n const result = new ReapedBranch(\n entry.branch, sha, entry.reason, entry.pr, deleted.ok, deleted.ok ? '' : deleted.err);\n result.archiveTag = archive.tag;\n\n const event = new BranchMutationEvent(verb, 'REAP');\n event.fromBranch = entry.branch;\n event.sha = sha;\n event.archiveTag = archive.tag;\n event.outcome = deleted.ok ? `deleted (${entry.reason})` : `FAILED (${deleted.err})`;\n this.mutationLog.logBranchMutation(repoRoot, event);\n\n return result;\n }\n\n /**\n * The branch vanished between the verdict and the reap — a concurrent reaper got it.\n *\n * Logged like every other outcome (a mutation nobody logs is a mutation nobody can audit), but\n * as ALREADY_GONE rather than SKIPPED/FAILED, and carried in its own bucket so the report can\n * say \"already gone\" instead of warning about a branch that is exactly as dead as intended.\n */\n private alreadyGone(repoRoot: string, verb: MutationVerb, entry: DeletableBranch): ReapedBranch {\n const event = new BranchMutationEvent(verb, 'REAP');\n event.fromBranch = entry.branch;\n event.outcome = 'ALREADY GONE (a concurrent reap deleted and archived it first)';\n this.mutationLog.logBranchMutation(repoRoot, event);\n\n const result = new ReapedBranch(\n entry.branch, '', entry.reason, entry.pr, false,\n 'already gone — a concurrent reap deleted and archived it first');\n result.alreadyGone = true;\n return result;\n }\n\n // Archiving failed ⇒ the branch is NOT deleted. Reported as a failure with git's own words, so the\n // human sees a branch that survived and why, rather than a silent skip.\n private archiveFailed(\n repoRoot: string, verb: MutationVerb, entry: DeletableBranch, sha: string, archive: ArchiveResult,\n ): ReapedBranch {\n const error = `not deleted — could not archive it first: ${archive.error}`;\n const event = new BranchMutationEvent(verb, 'REAP');\n event.fromBranch = entry.branch;\n event.sha = sha;\n event.outcome = `SKIPPED (${error})`;\n this.mutationLog.logBranchMutation(repoRoot, event);\n return new ReapedBranch(entry.branch, sha, entry.reason, entry.pr, false, error);\n }\n\n /**\n * Write the verdicts back with the reaped branches removed, so the branch-creation-guard's cap\n * sees the post-reap truth on its very next call instead of continuing to block against branches\n * that no longer exist. Anything that FAILED to delete stays in `deletable` — it is still there,\n * and still dead.\n */\n private rewriteCache(repoRoot: string, verdicts: MergedBranchesCache, failed: ReapedBranch[]): void {\n const stillDead = new Set(failed.map((entry: ReapedBranch): string => entry.branch));\n const remaining = verdicts.deletable.filter(\n (entry: DeletableBranch): boolean => stillDead.has(entry.branch));\n this.mergedBranches.writeMergedBranches(\n repoRoot,\n new MergedBranchesCache(verdicts.timestamp, remaining, verdicts.keep, verdicts.worktrees),\n );\n }\n\n // Run a git command capturing trimmed stdout/stderr; ok=false on spawn failure or non-zero exit.\n private async capture(repoRoot: string, args: string[]): Promise<CapturedOutput> {\n const result = await this.git.run(args, repoRoot);\n const err = result.stderr.trim();\n if (!result.ok) return new CapturedOutput(false, '', err !== '' ? err : 'git command failed');\n return new CapturedOutput(true, result.stdout.trim(), err);\n }\n}\n"]}
|
|
@@ -1,23 +1,43 @@
|
|
|
1
|
+
import { GitProcessApi, GitResultDto, NativeProcessApi, ProcessResultDto, TimerApi } from '@webpieces/tooling-common';
|
|
1
2
|
/**
|
|
2
|
-
* The ceiling on every external command the detached main-sync refresher runs (#1342)
|
|
3
|
+
* The ceiling on every external command the detached main-sync refresher runs (#1342), and the ONE place
|
|
4
|
+
* those commands are bounded.
|
|
3
5
|
*
|
|
4
6
|
* WHY EVERY ONE NEEDS A CEILING. The refresher is single-flight behind `main-sync.lock.json`, and the
|
|
5
7
|
* lock is reclaimed once its holder is older than `hangTimeoutMinutes` (default 5). Reclaiming used to
|
|
6
|
-
* be the whole of hang recovery, and it was not enough: no
|
|
8
|
+
* be the whole of hang recovery, and it was not enough: no command here carried a deadline, so a
|
|
7
9
|
* `git fetch` stuck on an ssh handshake, or a `gh pr list` stuck on the network, lived forever — and
|
|
8
10
|
* after every reclaim the next refresher could stack another one beside it. A bounded command makes a
|
|
9
11
|
* hung run END on its own; the reclaimer's kill (MainSyncLockService.tryAcquireMainSyncLock) is the
|
|
10
|
-
* backstop for a process that is stuck somewhere no
|
|
12
|
+
* backstop for a process that is stuck somewhere no deadline reaches.
|
|
11
13
|
*
|
|
12
14
|
* Sized so ONE network command plus a handful of local ones stays well inside the default
|
|
13
|
-
* 5-minute hang window.
|
|
14
|
-
*
|
|
15
|
+
* 5-minute hang window. The kill is SIGTERM (node's default), which lets git clean up its own `.lock`
|
|
16
|
+
* files rather than leaving them behind as SIGKILL would.
|
|
17
|
+
*
|
|
18
|
+
* HOW (#1381): the command goes through the process edges, and its deadline is a task scheduled on the
|
|
19
|
+
* clock edge (`TimerApi`) that aborts the run. A feature test therefore SEES each requested deadline
|
|
20
|
+
* (`MockTimerApi.getScheduledTasks()`), and fires it itself to prove the timeout path — no real time
|
|
21
|
+
* passes. The one synchronous form ({@link localGitNow}) exists for the PreToolUse guard's blocking path,
|
|
22
|
+
* whose `check()` contract is synchronous; its kill-after is passed straight to the edge.
|
|
15
23
|
*/
|
|
16
24
|
export declare class CommandTimeouts {
|
|
25
|
+
private readonly git;
|
|
26
|
+
private readonly processes;
|
|
27
|
+
private readonly timer;
|
|
17
28
|
/** `git fetch` and `gh` — the two calls that cross the network. */
|
|
18
29
|
static readonly NETWORK_MS: number;
|
|
19
30
|
/** Local git (merge-base, rev-list, worktree list, status, …). Generous: a cold, large repo is slow, never hung. */
|
|
20
31
|
static readonly LOCAL_GIT_MS: number;
|
|
21
|
-
|
|
22
|
-
|
|
32
|
+
constructor(git?: GitProcessApi, processes?: NativeProcessApi, timer?: TimerApi);
|
|
33
|
+
/** Local git, killed after {@link LOCAL_GIT_MS}. */
|
|
34
|
+
localGit(args: string[], cwd: string): Promise<GitResultDto>;
|
|
35
|
+
/** Network git (`fetch`), killed after {@link NETWORK_MS}. */
|
|
36
|
+
networkGit(args: string[], cwd: string): Promise<GitResultDto>;
|
|
37
|
+
/** `gh`, killed after {@link NETWORK_MS}. */
|
|
38
|
+
gh(args: string[], cwd: string): Promise<ProcessResultDto>;
|
|
39
|
+
/** Local git, SYNCHRONOUSLY — the guard's blocking path only. Killed after {@link LOCAL_GIT_MS}. */
|
|
40
|
+
localGitNow(args: string[], cwd: string): GitResultDto;
|
|
41
|
+
private boundedGit;
|
|
42
|
+
private deadline;
|
|
23
43
|
}
|