@webpieces/rules-config 0.4.495 → 0.4.497
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 +1 -1
- package/src/branch-archiver.d.ts +61 -0
- package/src/branch-archiver.js +139 -0
- package/src/branch-archiver.js.map +1 -0
- package/src/branch-mutation-log.d.ts +1 -0
- package/src/branch-mutation-log.js +12 -1
- package/src/branch-mutation-log.js.map +1 -1
- package/src/branch-reaper.d.ts +28 -5
- package/src/branch-reaper.js +72 -9
- package/src/branch-reaper.js.map +1 -1
- package/src/index.d.ts +3 -2
- package/src/index.js +22 -3
- package/src/index.js.map +1 -1
- package/src/merged-branches.d.ts +43 -1
- package/src/merged-branches.js +104 -12
- package/src/merged-branches.js.map +1 -1
- package/src/pr-gate-config.d.ts +29 -0
- package/src/pr-gate-config.js +49 -2
- package/src/pr-gate-config.js.map +1 -1
- package/src/pr-gate-section-validators.d.ts +1 -0
- package/src/pr-gate-section-validators.js +29 -0
- package/src/pr-gate-section-validators.js.map +1 -1
- package/src/validate-config.js +3 -0
- package/src/validate-config.js.map +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webpieces/rules-config",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.497",
|
|
4
4
|
"description": "Shared webpieces.config.json loader. Single source of truth for validation rule configuration consumed by @webpieces/ai-hook-rules, @webpieces/code-rules, and @webpieces/nx-webpieces-rules.",
|
|
5
5
|
"type": "commonjs",
|
|
6
6
|
"main": "./src/index.js",
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Archive a branch tip as a git TAG immediately before the branch is deleted.
|
|
3
|
+
*
|
|
4
|
+
* WHY this exists: `<feature>PreMerge<n>` snapshot BRANCHES exist for a real reason — after a squash
|
|
5
|
+
* merge you sometimes need the original pre-merge history to debug — but they are branches, so they
|
|
6
|
+
* accumulate forever, count toward `branch-creation-guard`'s maxLocalBranches, and can be committed
|
|
7
|
+
* onto by accident. Observed in the wild: 6 parked branches against a cap of 5, which then REFUSED to
|
|
8
|
+
* create the escape branch and pushed the agent into editing webpieces.config.json to get out.
|
|
9
|
+
*
|
|
10
|
+
* A tag is strictly better at the one job the branch was doing:
|
|
11
|
+
* - vs KEEPING THE BRANCH — a tag is invisible to `git branch`, does not count toward the branch cap,
|
|
12
|
+
* and cannot be accidentally committed onto.
|
|
13
|
+
* - vs storing a PATCH in merge-info — a patch is lossy: it drops commit boundaries, messages,
|
|
14
|
+
* authorship and parentage. A tag preserves the exact objects.
|
|
15
|
+
* - vs relying on the REFLOG — the reflog expires (90 days by default) and is local to the one clone
|
|
16
|
+
* that did the work. A tag survives `gc` and can be pushed if durability beyond one machine is wanted.
|
|
17
|
+
*
|
|
18
|
+
* Cost: one ref, zero new objects — the commits are already in the object store.
|
|
19
|
+
*
|
|
20
|
+
* Round trip (verified by hand, and asserted in branch-archiver.spec.ts against a real repo):
|
|
21
|
+
* git tag archive/2026-07-30/dean/foo dean/foo
|
|
22
|
+
* git branch -D dean/foo
|
|
23
|
+
* git checkout -b dean/foo archive/2026-07-30/dean/foo # exact objects restored
|
|
24
|
+
*/
|
|
25
|
+
export declare const BRANCH_RETENTION_DELETE = "delete";
|
|
26
|
+
export declare const BRANCH_RETENTION_ARCHIVE_TAG = "archive-tag";
|
|
27
|
+
export declare const BRANCH_RETENTION_KEEP = "keep";
|
|
28
|
+
export declare const BRANCH_RETENTIONS: readonly string[];
|
|
29
|
+
export declare const ARCHIVE_TAG_PREFIX = "archive";
|
|
30
|
+
export declare class ArchiveResult {
|
|
31
|
+
tag: string;
|
|
32
|
+
sha: string;
|
|
33
|
+
ok: boolean;
|
|
34
|
+
error: string;
|
|
35
|
+
constructor(tag: string, sha: string, ok: boolean, error: string);
|
|
36
|
+
}
|
|
37
|
+
export declare class BranchArchiver {
|
|
38
|
+
/**
|
|
39
|
+
* `archive/<YYYY-MM-DD>/<branch>`. The date segment is what makes the namespace browsable
|
|
40
|
+
* chronologically (`git tag --list 'archive/2026-07-*'`) and is also what keeps two archives of the
|
|
41
|
+
* SAME branch name from colliding across days — the common case, since a branch name gets reused.
|
|
42
|
+
*/
|
|
43
|
+
archiveTagName(branch: string, when?: Date): string;
|
|
44
|
+
/**
|
|
45
|
+
* Tag `branch`'s current tip, never overwriting an existing tag.
|
|
46
|
+
*
|
|
47
|
+
* Same branch archived twice on the SAME day gets `…-2`, `…-3`, … rather than `git tag -f`: force
|
|
48
|
+
* would silently destroy the older archive, which is the exact failure the archive exists to prevent.
|
|
49
|
+
* Returns ok=false (with git's stderr) rather than throwing — an archive that cannot be written must
|
|
50
|
+
* turn into "then do not delete either", a decision the CALLER makes.
|
|
51
|
+
*/
|
|
52
|
+
archive(repoRoot: string, branch: string, when?: Date): ArchiveResult;
|
|
53
|
+
/** The literal command a human runs to bring an archived branch back, byte-identical to what was deleted. */
|
|
54
|
+
restoreCommand(branch: string, tag: string): string;
|
|
55
|
+
/** Every archive tag currently in the repo, newest namespace segment first (plain lexical on the date). */
|
|
56
|
+
listArchiveTags(repoRoot: string): string[];
|
|
57
|
+
private freeTagName;
|
|
58
|
+
protected tagExists(repoRoot: string, tag: string): boolean;
|
|
59
|
+
private dateSegment;
|
|
60
|
+
private capture;
|
|
61
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
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
|
+
const tslib_1 = require("tslib");
|
|
5
|
+
const child_process_1 = require("child_process");
|
|
6
|
+
const inversify_1 = require("inversify");
|
|
7
|
+
/**
|
|
8
|
+
* Archive a branch tip as a git TAG immediately before the branch is deleted.
|
|
9
|
+
*
|
|
10
|
+
* WHY this exists: `<feature>PreMerge<n>` snapshot BRANCHES exist for a real reason — after a squash
|
|
11
|
+
* merge you sometimes need the original pre-merge history to debug — but they are branches, so they
|
|
12
|
+
* accumulate forever, count toward `branch-creation-guard`'s maxLocalBranches, and can be committed
|
|
13
|
+
* onto by accident. Observed in the wild: 6 parked branches against a cap of 5, which then REFUSED to
|
|
14
|
+
* create the escape branch and pushed the agent into editing webpieces.config.json to get out.
|
|
15
|
+
*
|
|
16
|
+
* A tag is strictly better at the one job the branch was doing:
|
|
17
|
+
* - vs KEEPING THE BRANCH — a tag is invisible to `git branch`, does not count toward the branch cap,
|
|
18
|
+
* and cannot be accidentally committed onto.
|
|
19
|
+
* - vs storing a PATCH in merge-info — a patch is lossy: it drops commit boundaries, messages,
|
|
20
|
+
* authorship and parentage. A tag preserves the exact objects.
|
|
21
|
+
* - vs relying on the REFLOG — the reflog expires (90 days by default) and is local to the one clone
|
|
22
|
+
* that did the work. A tag survives `gc` and can be pushed if durability beyond one machine is wanted.
|
|
23
|
+
*
|
|
24
|
+
* Cost: one ref, zero new objects — the commits are already in the object store.
|
|
25
|
+
*
|
|
26
|
+
* Round trip (verified by hand, and asserted in branch-archiver.spec.ts against a real repo):
|
|
27
|
+
* git tag archive/2026-07-30/dean/foo dean/foo
|
|
28
|
+
* git branch -D dean/foo
|
|
29
|
+
* git checkout -b dean/foo archive/2026-07-30/dean/foo # exact objects restored
|
|
30
|
+
*/
|
|
31
|
+
// Retention policy for a branch whose PR has landed (or which wp-cleanup proved dead).
|
|
32
|
+
// DELETE — delete outright, recoverable only via the reflog (the pre-0.4.492 behaviour).
|
|
33
|
+
// ARCHIVE_TAG — tag the tip first, then delete. The default: same disk cost, permanently recoverable.
|
|
34
|
+
// KEEP — do not delete at all (the branch keeps counting toward the branch cap).
|
|
35
|
+
exports.BRANCH_RETENTION_DELETE = 'delete';
|
|
36
|
+
exports.BRANCH_RETENTION_ARCHIVE_TAG = 'archive-tag';
|
|
37
|
+
exports.BRANCH_RETENTION_KEEP = 'keep';
|
|
38
|
+
exports.BRANCH_RETENTIONS = [
|
|
39
|
+
exports.BRANCH_RETENTION_DELETE,
|
|
40
|
+
exports.BRANCH_RETENTION_ARCHIVE_TAG,
|
|
41
|
+
exports.BRANCH_RETENTION_KEEP,
|
|
42
|
+
];
|
|
43
|
+
// The one namespace every archive tag lives under, so `git tag --list 'archive/*'` is the whole
|
|
44
|
+
// inventory and `git push origin 'refs/tags/archive/*'` is the whole backup.
|
|
45
|
+
exports.ARCHIVE_TAG_PREFIX = 'archive';
|
|
46
|
+
// Data-only (per CLAUDE.md, classes for data): what archiving one branch produced.
|
|
47
|
+
// `tag` is '' when nothing was tagged — either the policy said not to, or git refused; `error` says which.
|
|
48
|
+
class ArchiveResult {
|
|
49
|
+
tag;
|
|
50
|
+
sha;
|
|
51
|
+
ok;
|
|
52
|
+
error;
|
|
53
|
+
constructor(tag, sha, ok, error) {
|
|
54
|
+
this.tag = tag;
|
|
55
|
+
this.sha = sha;
|
|
56
|
+
this.ok = ok;
|
|
57
|
+
this.error = error;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
exports.ArchiveResult = ArchiveResult;
|
|
61
|
+
let BranchArchiver = class BranchArchiver {
|
|
62
|
+
/**
|
|
63
|
+
* `archive/<YYYY-MM-DD>/<branch>`. The date segment is what makes the namespace browsable
|
|
64
|
+
* chronologically (`git tag --list 'archive/2026-07-*'`) and is also what keeps two archives of the
|
|
65
|
+
* SAME branch name from colliding across days — the common case, since a branch name gets reused.
|
|
66
|
+
*/
|
|
67
|
+
archiveTagName(branch, when = new Date()) {
|
|
68
|
+
return `${exports.ARCHIVE_TAG_PREFIX}/${this.dateSegment(when)}/${branch}`;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Tag `branch`'s current tip, never overwriting an existing tag.
|
|
72
|
+
*
|
|
73
|
+
* Same branch archived twice on the SAME day gets `…-2`, `…-3`, … rather than `git tag -f`: force
|
|
74
|
+
* would silently destroy the older archive, which is the exact failure the archive exists to prevent.
|
|
75
|
+
* Returns ok=false (with git's stderr) rather than throwing — an archive that cannot be written must
|
|
76
|
+
* turn into "then do not delete either", a decision the CALLER makes.
|
|
77
|
+
*/
|
|
78
|
+
archive(repoRoot, branch, when = new Date()) {
|
|
79
|
+
const resolved = this.capture(repoRoot, ['rev-parse', branch]);
|
|
80
|
+
if (!resolved.ok)
|
|
81
|
+
return new ArchiveResult('', '', false, `cannot resolve ${branch}: ${resolved.err}`);
|
|
82
|
+
const sha = resolved.out;
|
|
83
|
+
const tag = this.freeTagName(repoRoot, this.archiveTagName(branch, when));
|
|
84
|
+
const tagged = this.capture(repoRoot, ['tag', tag, sha]);
|
|
85
|
+
if (!tagged.ok)
|
|
86
|
+
return new ArchiveResult('', sha, false, tagged.err);
|
|
87
|
+
return new ArchiveResult(tag, sha, true, '');
|
|
88
|
+
}
|
|
89
|
+
/** The literal command a human runs to bring an archived branch back, byte-identical to what was deleted. */
|
|
90
|
+
restoreCommand(branch, tag) {
|
|
91
|
+
return `git checkout -b ${branch} ${tag}`;
|
|
92
|
+
}
|
|
93
|
+
/** Every archive tag currently in the repo, newest namespace segment first (plain lexical on the date). */
|
|
94
|
+
listArchiveTags(repoRoot) {
|
|
95
|
+
const result = this.capture(repoRoot, ['tag', '--list', `${exports.ARCHIVE_TAG_PREFIX}/*`]);
|
|
96
|
+
if (!result.ok || result.out === '')
|
|
97
|
+
return [];
|
|
98
|
+
return result.out
|
|
99
|
+
.split('\n')
|
|
100
|
+
.map((line) => line.trim())
|
|
101
|
+
.filter((line) => line !== '')
|
|
102
|
+
.sort()
|
|
103
|
+
.reverse();
|
|
104
|
+
}
|
|
105
|
+
// First unused name in the `<base>`, `<base>-2`, `<base>-3` … series. Bounded so a pathological repo
|
|
106
|
+
// cannot spin: past the bound we hand back the last candidate and let `git tag` report the collision.
|
|
107
|
+
freeTagName(repoRoot, base) {
|
|
108
|
+
const MAX_ATTEMPTS = 50;
|
|
109
|
+
let candidate = base;
|
|
110
|
+
for (let attempt = 2; attempt <= MAX_ATTEMPTS; attempt += 1) {
|
|
111
|
+
if (!this.tagExists(repoRoot, candidate))
|
|
112
|
+
return candidate;
|
|
113
|
+
candidate = `${base}-${String(attempt)}`;
|
|
114
|
+
}
|
|
115
|
+
return candidate;
|
|
116
|
+
}
|
|
117
|
+
// Seam: overridden in the spec so name-collision logic is testable with no git and no repo.
|
|
118
|
+
tagExists(repoRoot, tag) {
|
|
119
|
+
return this.capture(repoRoot, ['rev-parse', '--verify', '--quiet', `refs/tags/${tag}`]).ok;
|
|
120
|
+
}
|
|
121
|
+
// YYYY-MM-DD in LOCAL time: the archive is browsed by the human who created it, on their calendar.
|
|
122
|
+
dateSegment(when) {
|
|
123
|
+
const pad = (value) => String(value).padStart(2, '0');
|
|
124
|
+
return `${String(when.getFullYear())}-${pad(when.getMonth() + 1)}-${pad(when.getDate())}`;
|
|
125
|
+
}
|
|
126
|
+
capture(repoRoot, args) {
|
|
127
|
+
const result = (0, child_process_1.spawnSync)('git', args, { cwd: repoRoot, encoding: 'utf8' });
|
|
128
|
+
const err = typeof result.stderr === 'string' ? result.stderr.trim() : '';
|
|
129
|
+
if (result.status !== 0 || typeof result.stdout !== 'string') {
|
|
130
|
+
return { ok: false, out: '', err: err !== '' ? err : 'git command failed' };
|
|
131
|
+
}
|
|
132
|
+
return { ok: true, out: result.stdout.trim(), err };
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
exports.BranchArchiver = BranchArchiver;
|
|
136
|
+
exports.BranchArchiver = BranchArchiver = tslib_1.__decorate([
|
|
137
|
+
(0, inversify_1.injectable)(inversify_1.bindingScopeValues.Singleton)
|
|
138
|
+
], BranchArchiver);
|
|
139
|
+
//# sourceMappingURL=branch-archiver.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"branch-archiver.js","sourceRoot":"","sources":["../../../../../packages/tooling/rules-config/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 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 tag = this.freeTagName(repoRoot, this.archiveTagName(branch, when));\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 // 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"]}
|
|
@@ -12,6 +12,7 @@ export declare class BranchMutationEvent {
|
|
|
12
12
|
outcome: string;
|
|
13
13
|
artifacts: string[];
|
|
14
14
|
sha: string;
|
|
15
|
+
archiveTag: string;
|
|
15
16
|
constructor(verb: MutationVerb, phase: MutationPhase);
|
|
16
17
|
}
|
|
17
18
|
/** Appends branch-mutation audit lines. `@injectable(bindingScopeValues.Singleton)` so it's injectable + drawn in the design. */
|
|
@@ -35,6 +35,11 @@ class BranchMutationEvent {
|
|
|
35
35
|
// still addressable by hash (the reflog holds it ~90 days), so formatDetail renders a literal
|
|
36
36
|
// `recover=git branch <name> <sha>` next to it. Empty for mutations that delete nothing.
|
|
37
37
|
sha = '';
|
|
38
|
+
// The `archive/<date>/<branch>` tag written immediately BEFORE a REAP deleted the branch. When set,
|
|
39
|
+
// formatDetail renders `recover=` against the TAG instead of the sha: a tag is a permanent ref that
|
|
40
|
+
// survives `gc` and reflog expiry and can be pushed, whereas a bare sha is only recoverable while
|
|
41
|
+
// this clone's reflog still holds it. Empty when nothing was tagged (retention policy 'delete').
|
|
42
|
+
archiveTag = '';
|
|
38
43
|
constructor(verb, phase) {
|
|
39
44
|
this.verb = verb;
|
|
40
45
|
this.phase = phase;
|
|
@@ -92,8 +97,14 @@ let BranchMutationLog = class BranchMutationLog {
|
|
|
92
97
|
if (event.outcome !== '')
|
|
93
98
|
parts.push(`outcome=${event.outcome}`);
|
|
94
99
|
// Emitted as one unit so the hash is never separated from the command that undoes the delete.
|
|
95
|
-
|
|
100
|
+
// Prefer the archive TAG as the recover ref when there is one — it does not expire.
|
|
101
|
+
if (event.sha !== '' && event.archiveTag !== '') {
|
|
102
|
+
parts.push(`sha=${event.sha} archiveTag=${event.archiveTag} ` +
|
|
103
|
+
`recover=git checkout -b ${event.fromBranch || '?'} ${event.archiveTag}`);
|
|
104
|
+
}
|
|
105
|
+
else if (event.sha !== '') {
|
|
96
106
|
parts.push(`sha=${event.sha} recover=git branch ${event.fromBranch || '?'} ${event.sha}`);
|
|
107
|
+
}
|
|
97
108
|
for (const artifact of event.artifacts)
|
|
98
109
|
parts.push(`artifact=${artifact}`);
|
|
99
110
|
return parts.join(' ');
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"branch-mutation-log.js","sourceRoot":"","sources":["../../../../../packages/tooling/rules-config/src/branch-mutation-log.ts"],"names":[],"mappings":"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","sourcesContent":["import * as fs from 'fs';\nimport * as path from 'path';\nimport { injectable, bindingScopeValues } from 'inversify';\n\nimport { WEBPIECES_TMP_DIR } from './constants';\nimport { toError } from './to-error';\n\n// The BRANCH-MUTATION log — an audit trail for every workflow verb that RENAMES or MOVES branches.\n// Records START / each phase boundary / END-with-outcome so the next agent (or a human) can\n// reconstruct what the tooling did to the branches. Writes to `.webpieces/hooks/branch-mutations.log`.\n// Lives in rules-config (the shared dep of pr-gate) so the pr-gate scripts can call it directly.\n\nconst HOOKS_DIR = 'hooks';\nconst LOG_FILE = 'branch-mutations.log';\nconst LOG_FILE_PREV = 'branch-mutations.1.log';\nconst MAX_LOG_BYTES = 512 * 1024; // 512 KB — rotate when exceeded (mirrors the other webpieces logs)\nconst MAX_DETAIL_LEN = 400;\n\n// The workflow verb whose branch mutation is being logged (the bin the AI/human invoked).\n// `auto-reap` is the odd one out: no human invoked it — it is the detached background refresher\n// (sync-main.ts) deleting dead branches on its own. It gets a verb precisely BECAUSE it is\n// unattended: a deletion nobody watched happen is the one that most needs an audit line.\nexport type MutationVerb =\n | 'wp-start-update' | 'wp-finish-update' | 'wp-start-upsert-pr' | 'wp-finish-upsert-pr'\n | 'wp-cleanup' | 'auto-reap';\n\n// A boundary within a verb's execution. START/END bracket the whole run; the middle phases mark each\n// irreversible git step so an interrupt leaves a breadcrumb at the last phase reached.\n// REAP is a whole mutation in one line (a branch delete has no phases) — see BranchReaper.\nexport type MutationPhase =\n | 'START' | 'BACKUP' | 'CHECKOUT_MAIN' | 'PULL' | 'SQUASH' | 'RENAME'\n | 'FINALIZE' | 'CONFLICT' | 'INTERRUPTED' | 'END' | 'REAP';\n\n// Data-only record of one branch-mutation event (per CLAUDE.md: classes for data, explicit construction).\nexport class BranchMutationEvent {\n verb: MutationVerb;\n phase: MutationPhase;\n fromBranch: string = '';\n toBranch: string = '';\n oldMain: string = '';\n newMain: string = '';\n conflict: boolean = false;\n conflictFiles: string[] = [];\n outcome: string = '';\n artifacts: string[] = [];\n // The commit a DELETED branch pointed at, captured immediately before the delete. This is what\n // makes a reap auditable AND reversible: the work is already in main, and the pre-delete tip is\n // still addressable by hash (the reflog holds it ~90 days), so formatDetail renders a literal\n // `recover=git branch <name> <sha>` next to it. Empty for mutations that delete nothing.\n sha: string = '';\n\n constructor(verb: MutationVerb, phase: MutationPhase) {\n this.verb = verb;\n this.phase = phase;\n }\n}\n\n/** Appends branch-mutation audit lines. `@injectable(bindingScopeValues.Singleton)` so it's injectable + drawn in the design. */\n@injectable(bindingScopeValues.Singleton)\nexport class BranchMutationLog {\n branchMutationLogPath(root: string): string {\n return path.join(root, WEBPIECES_TMP_DIR, HOOKS_DIR, LOG_FILE);\n }\n\n /**\n * Append one tab-separated line per branch-mutation event to\n * `.webpieces/hooks/branch-mutations.log`. Swallows all errors — logging must NEVER block or fail\n * the workflow it is observing.\n */\n logBranchMutation(root: string, event: BranchMutationEvent): void {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const timestamp = new Date().toISOString();\n const hooksDir = path.join(root, WEBPIECES_TMP_DIR, HOOKS_DIR);\n fs.mkdirSync(hooksDir, { recursive: true });\n\n const logPath = path.join(hooksDir, LOG_FILE);\n this.rotateLogFile(logPath, path.join(hooksDir, LOG_FILE_PREV));\n\n const line = [\n `[${timestamp}]`,\n event.verb,\n event.phase,\n this.oneLine(this.formatDetail(event)),\n ].join('\\t') + '\\n';\n fs.appendFileSync(logPath, line);\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n }\n }\n\n // Render only the fields this event actually set, as `key=value` tokens — greppable on one line.\n private formatDetail(event: BranchMutationEvent): string {\n const parts: string[] = [];\n // A rename/move has both ends; a REAP has only the branch it destroyed. Printing `to=?` for\n // the latter reads like a lost destination rather than \"there was never one\".\n if (event.fromBranch !== '' && event.toBranch !== '') parts.push(`from=${event.fromBranch} to=${event.toBranch}`);\n else if (event.fromBranch !== '') parts.push(`branch=${event.fromBranch}`);\n else if (event.toBranch !== '') parts.push(`from=? to=${event.toBranch}`);\n if (event.oldMain !== '' || event.newMain !== '') parts.push(`oldMain=${event.oldMain || '?'} newMain=${event.newMain || '?'}`);\n if (event.conflict) parts.push('conflict=true');\n if (event.conflictFiles.length > 0) parts.push(`conflictFiles=${event.conflictFiles.length}(${event.conflictFiles.join(',')})`);\n if (event.outcome !== '') parts.push(`outcome=${event.outcome}`);\n // Emitted as one unit so the hash is never separated from the command that undoes the delete.\n if (event.sha !== '') parts.push(`sha=${event.sha} recover=git branch ${event.fromBranch || '?'} ${event.sha}`);\n for (const artifact of event.artifacts) parts.push(`artifact=${artifact}`);\n return parts.join(' ');\n }\n\n // Collapse newlines/tabs and cap length so one event is always exactly one log line.\n private oneLine(value: string): string {\n const flat = value.replace(/[\\t\\r\\n]+/g, ' ').trim();\n return flat.length <= MAX_DETAIL_LEN ? flat : flat.slice(0, MAX_DETAIL_LEN) + '…';\n }\n\n private rotateLogFile(logPath: string, prevPath: string): void {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const stat = fs.statSync(logPath);\n if (stat.size > MAX_LOG_BYTES) {\n if (fs.existsSync(prevPath)) fs.unlinkSync(prevPath);\n fs.renameSync(logPath, prevPath);\n }\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n }\n }\n}\n\n// Temporary migration delegators to BranchMutationLog — removed once consumers inject it.\nconst branchMutationLogSvc = new BranchMutationLog();\n\n// webpieces-disable no-function-outside-class -- temporary back-compat delegator to BranchMutationLog; removed once consumers inject it\nexport function branchMutationLogPath(root: string): string {\n return branchMutationLogSvc.branchMutationLogPath(root);\n}\n\n// webpieces-disable no-function-outside-class -- temporary back-compat delegator to BranchMutationLog; removed once consumers inject it\nexport function logBranchMutation(root: string, event: BranchMutationEvent): void {\n branchMutationLogSvc.logBranchMutation(root, event);\n}\n"]}
|
|
1
|
+
{"version":3,"file":"branch-mutation-log.js","sourceRoot":"","sources":["../../../../../packages/tooling/rules-config/src/branch-mutation-log.ts"],"names":[],"mappings":"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","sourcesContent":["import * as fs from 'fs';\nimport * as path from 'path';\nimport { injectable, bindingScopeValues } from 'inversify';\n\nimport { WEBPIECES_TMP_DIR } from './constants';\nimport { toError } from './to-error';\n\n// The BRANCH-MUTATION log — an audit trail for every workflow verb that RENAMES or MOVES branches.\n// Records START / each phase boundary / END-with-outcome so the next agent (or a human) can\n// reconstruct what the tooling did to the branches. Writes to `.webpieces/hooks/branch-mutations.log`.\n// Lives in rules-config (the shared dep of pr-gate) so the pr-gate scripts can call it directly.\n\nconst HOOKS_DIR = 'hooks';\nconst LOG_FILE = 'branch-mutations.log';\nconst LOG_FILE_PREV = 'branch-mutations.1.log';\nconst MAX_LOG_BYTES = 512 * 1024; // 512 KB — rotate when exceeded (mirrors the other webpieces logs)\nconst MAX_DETAIL_LEN = 400;\n\n// The workflow verb whose branch mutation is being logged (the bin the AI/human invoked).\n// `auto-reap` is the odd one out: no human invoked it — it is the detached background refresher\n// (sync-main.ts) deleting dead branches on its own. It gets a verb precisely BECAUSE it is\n// unattended: a deletion nobody watched happen is the one that most needs an audit line.\nexport type MutationVerb =\n | 'wp-start-update' | 'wp-finish-update' | 'wp-start-upsert-pr' | 'wp-finish-upsert-pr'\n | 'wp-cleanup' | 'auto-reap';\n\n// A boundary within a verb's execution. START/END bracket the whole run; the middle phases mark each\n// irreversible git step so an interrupt leaves a breadcrumb at the last phase reached.\n// REAP is a whole mutation in one line (a branch delete has no phases) — see BranchReaper.\nexport type MutationPhase =\n | 'START' | 'BACKUP' | 'CHECKOUT_MAIN' | 'PULL' | 'SQUASH' | 'RENAME'\n | 'FINALIZE' | 'CONFLICT' | 'INTERRUPTED' | 'END' | 'REAP';\n\n// Data-only record of one branch-mutation event (per CLAUDE.md: classes for data, explicit construction).\nexport class BranchMutationEvent {\n verb: MutationVerb;\n phase: MutationPhase;\n fromBranch: string = '';\n toBranch: string = '';\n oldMain: string = '';\n newMain: string = '';\n conflict: boolean = false;\n conflictFiles: string[] = [];\n outcome: string = '';\n artifacts: string[] = [];\n // The commit a DELETED branch pointed at, captured immediately before the delete. This is what\n // makes a reap auditable AND reversible: the work is already in main, and the pre-delete tip is\n // still addressable by hash (the reflog holds it ~90 days), so formatDetail renders a literal\n // `recover=git branch <name> <sha>` next to it. Empty for mutations that delete nothing.\n sha: string = '';\n // The `archive/<date>/<branch>` tag written immediately BEFORE a REAP deleted the branch. When set,\n // formatDetail renders `recover=` against the TAG instead of the sha: a tag is a permanent ref that\n // survives `gc` and reflog expiry and can be pushed, whereas a bare sha is only recoverable while\n // this clone's reflog still holds it. Empty when nothing was tagged (retention policy 'delete').\n archiveTag: string = '';\n\n constructor(verb: MutationVerb, phase: MutationPhase) {\n this.verb = verb;\n this.phase = phase;\n }\n}\n\n/** Appends branch-mutation audit lines. `@injectable(bindingScopeValues.Singleton)` so it's injectable + drawn in the design. */\n@injectable(bindingScopeValues.Singleton)\nexport class BranchMutationLog {\n branchMutationLogPath(root: string): string {\n return path.join(root, WEBPIECES_TMP_DIR, HOOKS_DIR, LOG_FILE);\n }\n\n /**\n * Append one tab-separated line per branch-mutation event to\n * `.webpieces/hooks/branch-mutations.log`. Swallows all errors — logging must NEVER block or fail\n * the workflow it is observing.\n */\n logBranchMutation(root: string, event: BranchMutationEvent): void {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const timestamp = new Date().toISOString();\n const hooksDir = path.join(root, WEBPIECES_TMP_DIR, HOOKS_DIR);\n fs.mkdirSync(hooksDir, { recursive: true });\n\n const logPath = path.join(hooksDir, LOG_FILE);\n this.rotateLogFile(logPath, path.join(hooksDir, LOG_FILE_PREV));\n\n const line = [\n `[${timestamp}]`,\n event.verb,\n event.phase,\n this.oneLine(this.formatDetail(event)),\n ].join('\\t') + '\\n';\n fs.appendFileSync(logPath, line);\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n }\n }\n\n // Render only the fields this event actually set, as `key=value` tokens — greppable on one line.\n private formatDetail(event: BranchMutationEvent): string {\n const parts: string[] = [];\n // A rename/move has both ends; a REAP has only the branch it destroyed. Printing `to=?` for\n // the latter reads like a lost destination rather than \"there was never one\".\n if (event.fromBranch !== '' && event.toBranch !== '') parts.push(`from=${event.fromBranch} to=${event.toBranch}`);\n else if (event.fromBranch !== '') parts.push(`branch=${event.fromBranch}`);\n else if (event.toBranch !== '') parts.push(`from=? to=${event.toBranch}`);\n if (event.oldMain !== '' || event.newMain !== '') parts.push(`oldMain=${event.oldMain || '?'} newMain=${event.newMain || '?'}`);\n if (event.conflict) parts.push('conflict=true');\n if (event.conflictFiles.length > 0) parts.push(`conflictFiles=${event.conflictFiles.length}(${event.conflictFiles.join(',')})`);\n if (event.outcome !== '') parts.push(`outcome=${event.outcome}`);\n // Emitted as one unit so the hash is never separated from the command that undoes the delete.\n // Prefer the archive TAG as the recover ref when there is one — it does not expire.\n if (event.sha !== '' && event.archiveTag !== '') {\n parts.push(\n `sha=${event.sha} archiveTag=${event.archiveTag} ` +\n `recover=git checkout -b ${event.fromBranch || '?'} ${event.archiveTag}`,\n );\n } else if (event.sha !== '') {\n parts.push(`sha=${event.sha} recover=git branch ${event.fromBranch || '?'} ${event.sha}`);\n }\n for (const artifact of event.artifacts) parts.push(`artifact=${artifact}`);\n return parts.join(' ');\n }\n\n // Collapse newlines/tabs and cap length so one event is always exactly one log line.\n private oneLine(value: string): string {\n const flat = value.replace(/[\\t\\r\\n]+/g, ' ').trim();\n return flat.length <= MAX_DETAIL_LEN ? flat : flat.slice(0, MAX_DETAIL_LEN) + '…';\n }\n\n private rotateLogFile(logPath: string, prevPath: string): void {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const stat = fs.statSync(logPath);\n if (stat.size > MAX_LOG_BYTES) {\n if (fs.existsSync(prevPath)) fs.unlinkSync(prevPath);\n fs.renameSync(logPath, prevPath);\n }\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n }\n }\n}\n\n// Temporary migration delegators to BranchMutationLog — removed once consumers inject it.\nconst branchMutationLogSvc = new BranchMutationLog();\n\n// webpieces-disable no-function-outside-class -- temporary back-compat delegator to BranchMutationLog; removed once consumers inject it\nexport function branchMutationLogPath(root: string): string {\n return branchMutationLogSvc.branchMutationLogPath(root);\n}\n\n// webpieces-disable no-function-outside-class -- temporary back-compat delegator to BranchMutationLog; removed once consumers inject it\nexport function logBranchMutation(root: string, event: BranchMutationEvent): void {\n branchMutationLogSvc.logBranchMutation(root, event);\n}\n"]}
|
package/src/branch-reaper.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { BranchArchiver } from './branch-archiver';
|
|
1
2
|
import { BranchMutationLog, MutationVerb } from './branch-mutation-log';
|
|
2
3
|
import { DeletableBranch, MergedBranchesCache, MergedBranchesService } from './merged-branches';
|
|
3
4
|
/**
|
|
@@ -23,6 +24,13 @@ export declare class ReapedBranch {
|
|
|
23
24
|
pr: number;
|
|
24
25
|
ok: boolean;
|
|
25
26
|
error: string;
|
|
27
|
+
/**
|
|
28
|
+
* The `archive/<date>/<branch>` tag written immediately BEFORE the delete, or '' when the retention
|
|
29
|
+
* policy is 'delete'. Non-empty means the branch is restorable by NAME rather than by remembering a
|
|
30
|
+
* sha — `git checkout -b <branch> <tag>` restores the exact objects, and the tag survives `gc` and
|
|
31
|
+
* reflog expiry. Field-with-default so every existing `new ReapedBranch(...)` call site still builds.
|
|
32
|
+
*/
|
|
33
|
+
archiveTag: string;
|
|
26
34
|
constructor(branch: string, sha: string, reason: string, pr: number, ok: boolean, error: string);
|
|
27
35
|
}
|
|
28
36
|
/**
|
|
@@ -39,7 +47,8 @@ export declare class ReapResult {
|
|
|
39
47
|
export declare class BranchReaper {
|
|
40
48
|
private readonly mergedBranches;
|
|
41
49
|
private readonly mutationLog;
|
|
42
|
-
|
|
50
|
+
private readonly archiver;
|
|
51
|
+
constructor(mergedBranches?: MergedBranchesService, mutationLog?: BranchMutationLog, archiver?: BranchArchiver);
|
|
43
52
|
/**
|
|
44
53
|
* Delete every branch the verdicts call dead, one command at a time.
|
|
45
54
|
*
|
|
@@ -49,13 +58,27 @@ export declare class BranchReaper {
|
|
|
49
58
|
* to go stale, which is fine for blocking a branch creation but is not fine for deleting, since a
|
|
50
59
|
* branch may have gained commits since it was written. Deleting never reads the stale file.
|
|
51
60
|
*/
|
|
52
|
-
reap(repoRoot: string, verb: MutationVerb, cache?: MergedBranchesCache | null): ReapResult;
|
|
61
|
+
reap(repoRoot: string, verb: MutationVerb, cache?: MergedBranchesCache | null, retention?: string): ReapResult;
|
|
53
62
|
/**
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
63
|
+
* Delete a CALLER-SUPPLIED list of branches — the branches a human just said yes to at wp-cleanup's
|
|
64
|
+
* classification prompt. Separate from `reap` because these are NOT provably dead: they earned their
|
|
65
|
+
* deletion from an explicit human answer, not from a verdict, so nothing here may ever run unattended.
|
|
66
|
+
* Archiving still happens first, which is precisely what makes that yes low-stakes.
|
|
67
|
+
*/
|
|
68
|
+
reapApproved(repoRoot: string, verb: MutationVerb, approved: DeletableBranch[], retention?: string): ReapResult;
|
|
69
|
+
private reapBranches;
|
|
70
|
+
/**
|
|
71
|
+
* One branch: ARCHIVE the tip as a tag, then one `git branch -D`. Never the multi-name form the old
|
|
72
|
+
* fix hint used: git aborts the whole command on the first branch it refuses, which would strand
|
|
73
|
+
* every branch after it in the list. One invocation each means one failure costs exactly one branch.
|
|
74
|
+
*
|
|
75
|
+
* The archive comes FIRST and, when it fails, the delete does NOT happen. A branch we could not
|
|
76
|
+
* archive is a branch whose only remaining copy is the reflog, and the entire point of this change is
|
|
77
|
+
* to stop relying on the reflog. Refusing to delete is the fail-safe direction: the worst case is a
|
|
78
|
+
* branch that survives one more cleanup cycle.
|
|
57
79
|
*/
|
|
58
80
|
private deleteOne;
|
|
81
|
+
private archiveFailed;
|
|
59
82
|
/**
|
|
60
83
|
* Write the verdicts back with the reaped branches removed, so the branch-creation-guard's cap
|
|
61
84
|
* sees the post-reap truth on its very next call instead of continuing to block against branches
|
package/src/branch-reaper.js
CHANGED
|
@@ -4,6 +4,7 @@ exports.BranchReaper = exports.ReapResult = exports.ReapedBranch = void 0;
|
|
|
4
4
|
const tslib_1 = require("tslib");
|
|
5
5
|
const child_process_1 = require("child_process");
|
|
6
6
|
const inversify_1 = require("inversify");
|
|
7
|
+
const branch_archiver_1 = require("./branch-archiver");
|
|
7
8
|
const branch_mutation_log_1 = require("./branch-mutation-log");
|
|
8
9
|
const merged_branches_1 = require("./merged-branches");
|
|
9
10
|
/**
|
|
@@ -33,6 +34,14 @@ class ReapedBranch {
|
|
|
33
34
|
ok;
|
|
34
35
|
// git's own stderr when ok=false. Kept verbatim: a failed delete is a thing a human must read.
|
|
35
36
|
error;
|
|
37
|
+
/**
|
|
38
|
+
* The `archive/<date>/<branch>` tag written immediately BEFORE the delete, or '' when the retention
|
|
39
|
+
* policy is 'delete'. Non-empty means the branch is restorable by NAME rather than by remembering a
|
|
40
|
+
* sha — `git checkout -b <branch> <tag>` restores the exact objects, and the tag survives `gc` and
|
|
41
|
+
* reflog expiry. Field-with-default so every existing `new ReapedBranch(...)` call site still builds.
|
|
42
|
+
*/
|
|
43
|
+
archiveTag = '';
|
|
44
|
+
// eslint-disable-next-line @typescript-eslint/max-params
|
|
36
45
|
constructor(branch, sha, reason, pr, ok, error) {
|
|
37
46
|
this.branch = branch;
|
|
38
47
|
this.sha = sha;
|
|
@@ -62,12 +71,14 @@ exports.ReapResult = ReapResult;
|
|
|
62
71
|
let BranchReaper = class BranchReaper {
|
|
63
72
|
mergedBranches;
|
|
64
73
|
mutationLog;
|
|
74
|
+
archiver;
|
|
65
75
|
// Defaulted so the non-DI call sites (the detached refresher in sync-main.ts) can just
|
|
66
76
|
// `new BranchReaper()`, while inversify still injects the singletons when resolved from a
|
|
67
77
|
// container. Mirrors how MergedBranchesService defaults its WorktreeService.
|
|
68
|
-
constructor(mergedBranches = new merged_branches_1.MergedBranchesService(), mutationLog = new branch_mutation_log_1.BranchMutationLog()) {
|
|
78
|
+
constructor(mergedBranches = new merged_branches_1.MergedBranchesService(), mutationLog = new branch_mutation_log_1.BranchMutationLog(), archiver = new branch_archiver_1.BranchArchiver()) {
|
|
69
79
|
this.mergedBranches = mergedBranches;
|
|
70
80
|
this.mutationLog = mutationLog;
|
|
81
|
+
this.archiver = archiver;
|
|
71
82
|
}
|
|
72
83
|
/**
|
|
73
84
|
* Delete every branch the verdicts call dead, one command at a time.
|
|
@@ -78,12 +89,40 @@ let BranchReaper = class BranchReaper {
|
|
|
78
89
|
* to go stale, which is fine for blocking a branch creation but is not fine for deleting, since a
|
|
79
90
|
* branch may have gained commits since it was written. Deleting never reads the stale file.
|
|
80
91
|
*/
|
|
81
|
-
reap(repoRoot, verb, cache = null) {
|
|
92
|
+
reap(repoRoot, verb, cache = null, retention = branch_archiver_1.BRANCH_RETENTION_ARCHIVE_TAG) {
|
|
82
93
|
const verdicts = cache ?? this.mergedBranches.computeMergedBranches(repoRoot);
|
|
94
|
+
// 'keep' means "never delete anything" — the reap becomes a pure report. Everything still lands
|
|
95
|
+
// in `spared` so the human sees exactly what WOULD have been reaped under the other policies.
|
|
96
|
+
if (retention === branch_archiver_1.BRANCH_RETENTION_KEEP) {
|
|
97
|
+
return new ReapResult([], [], [...verdicts.deletable, ...verdicts.keep]);
|
|
98
|
+
}
|
|
99
|
+
return this.reapBranches(repoRoot, verb, verdicts.deletable, retention, verdicts);
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Delete a CALLER-SUPPLIED list of branches — the branches a human just said yes to at wp-cleanup's
|
|
103
|
+
* classification prompt. Separate from `reap` because these are NOT provably dead: they earned their
|
|
104
|
+
* deletion from an explicit human answer, not from a verdict, so nothing here may ever run unattended.
|
|
105
|
+
* Archiving still happens first, which is precisely what makes that yes low-stakes.
|
|
106
|
+
*/
|
|
107
|
+
reapApproved(repoRoot, verb, approved, retention = branch_archiver_1.BRANCH_RETENTION_ARCHIVE_TAG) {
|
|
108
|
+
if (retention === branch_archiver_1.BRANCH_RETENTION_KEEP)
|
|
109
|
+
return new ReapResult([], [], approved);
|
|
110
|
+
const reaped = [];
|
|
111
|
+
const failed = [];
|
|
112
|
+
for (const entry of approved) {
|
|
113
|
+
const outcome = this.deleteOne(repoRoot, verb, entry, retention);
|
|
114
|
+
if (outcome.ok)
|
|
115
|
+
reaped.push(outcome);
|
|
116
|
+
else
|
|
117
|
+
failed.push(outcome);
|
|
118
|
+
}
|
|
119
|
+
return new ReapResult(reaped, failed, []);
|
|
120
|
+
}
|
|
121
|
+
reapBranches(repoRoot, verb, targets, retention, verdicts) {
|
|
83
122
|
const reaped = [];
|
|
84
123
|
const failed = [];
|
|
85
|
-
for (const entry of
|
|
86
|
-
const outcome = this.deleteOne(repoRoot, verb, entry);
|
|
124
|
+
for (const entry of targets) {
|
|
125
|
+
const outcome = this.deleteOne(repoRoot, verb, entry, retention);
|
|
87
126
|
if (outcome.ok)
|
|
88
127
|
reaped.push(outcome);
|
|
89
128
|
else
|
|
@@ -93,24 +132,47 @@ let BranchReaper = class BranchReaper {
|
|
|
93
132
|
return new ReapResult(reaped, failed, verdicts.keep);
|
|
94
133
|
}
|
|
95
134
|
/**
|
|
96
|
-
* One branch, one `git branch -D`.
|
|
97
|
-
* the whole command on the first branch it refuses, which would strand
|
|
98
|
-
* the list. One invocation each means one failure costs exactly one branch.
|
|
135
|
+
* One branch: ARCHIVE the tip as a tag, then one `git branch -D`. Never the multi-name form the old
|
|
136
|
+
* fix hint used: git aborts the whole command on the first branch it refuses, which would strand
|
|
137
|
+
* every branch after it in the list. One invocation each means one failure costs exactly one branch.
|
|
138
|
+
*
|
|
139
|
+
* The archive comes FIRST and, when it fails, the delete does NOT happen. A branch we could not
|
|
140
|
+
* archive is a branch whose only remaining copy is the reflog, and the entire point of this change is
|
|
141
|
+
* to stop relying on the reflog. Refusing to delete is the fail-safe direction: the worst case is a
|
|
142
|
+
* branch that survives one more cleanup cycle.
|
|
99
143
|
*/
|
|
100
|
-
deleteOne(repoRoot, verb, entry) {
|
|
144
|
+
deleteOne(repoRoot, verb, entry, retention) {
|
|
101
145
|
// SHA first — after the delete there is no branch left to resolve, and the whole point of the
|
|
102
146
|
// audit line is that it records what was destroyed while it still exists.
|
|
103
147
|
const resolved = this.capture(repoRoot, ['rev-parse', entry.branch]);
|
|
104
148
|
const sha = resolved.ok ? resolved.out : '';
|
|
149
|
+
const archive = retention === branch_archiver_1.BRANCH_RETENTION_ARCHIVE_TAG
|
|
150
|
+
? this.archiver.archive(repoRoot, entry.branch)
|
|
151
|
+
: new branch_archiver_1.ArchiveResult('', sha, true, '');
|
|
152
|
+
if (!archive.ok)
|
|
153
|
+
return this.archiveFailed(repoRoot, verb, entry, sha, archive);
|
|
105
154
|
const deleted = this.capture(repoRoot, ['branch', '-D', entry.branch]);
|
|
106
155
|
const result = new ReapedBranch(entry.branch, sha, entry.reason, entry.pr, deleted.ok, deleted.ok ? '' : deleted.err);
|
|
156
|
+
result.archiveTag = archive.tag;
|
|
107
157
|
const event = new branch_mutation_log_1.BranchMutationEvent(verb, 'REAP');
|
|
108
158
|
event.fromBranch = entry.branch;
|
|
109
159
|
event.sha = sha;
|
|
160
|
+
event.archiveTag = archive.tag;
|
|
110
161
|
event.outcome = deleted.ok ? `deleted (${entry.reason})` : `FAILED (${deleted.err})`;
|
|
111
162
|
this.mutationLog.logBranchMutation(repoRoot, event);
|
|
112
163
|
return result;
|
|
113
164
|
}
|
|
165
|
+
// Archiving failed ⇒ the branch is NOT deleted. Reported as a failure with git's own words, so the
|
|
166
|
+
// human sees a branch that survived and why, rather than a silent skip.
|
|
167
|
+
archiveFailed(repoRoot, verb, entry, sha, archive) {
|
|
168
|
+
const error = `not deleted — could not archive it first: ${archive.error}`;
|
|
169
|
+
const event = new branch_mutation_log_1.BranchMutationEvent(verb, 'REAP');
|
|
170
|
+
event.fromBranch = entry.branch;
|
|
171
|
+
event.sha = sha;
|
|
172
|
+
event.outcome = `SKIPPED (${error})`;
|
|
173
|
+
this.mutationLog.logBranchMutation(repoRoot, event);
|
|
174
|
+
return new ReapedBranch(entry.branch, sha, entry.reason, entry.pr, false, error);
|
|
175
|
+
}
|
|
114
176
|
/**
|
|
115
177
|
* Write the verdicts back with the reaped branches removed, so the branch-creation-guard's cap
|
|
116
178
|
* sees the post-reap truth on its very next call instead of continuing to block against branches
|
|
@@ -136,6 +198,7 @@ exports.BranchReaper = BranchReaper;
|
|
|
136
198
|
exports.BranchReaper = BranchReaper = tslib_1.__decorate([
|
|
137
199
|
(0, inversify_1.injectable)(inversify_1.bindingScopeValues.Singleton),
|
|
138
200
|
tslib_1.__metadata("design:paramtypes", [merged_branches_1.MergedBranchesService,
|
|
139
|
-
branch_mutation_log_1.BranchMutationLog
|
|
201
|
+
branch_mutation_log_1.BranchMutationLog,
|
|
202
|
+
branch_archiver_1.BranchArchiver])
|
|
140
203
|
], BranchReaper);
|
|
141
204
|
//# 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/rules-config/src/branch-reaper.ts"],"names":[],"mappings":"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","sourcesContent":["import { spawnSync } from 'child_process';\nimport { injectable, bindingScopeValues } from 'inversify';\n\nimport { BranchMutationEvent, BranchMutationLog, MutationVerb } from './branch-mutation-log';\nimport { DeletableBranch, MergedBranchesCache, 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 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 constructor(reaped: ReapedBranch[], failed: ReapedBranch[], spared: DeletableBranch[]) {\n this.reaped = reaped;\n this.failed = failed;\n this.spared = spared;\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 ) {}\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(repoRoot: string, verb: MutationVerb, cache: MergedBranchesCache | null = null): ReapResult {\n const verdicts = cache ?? this.mergedBranches.computeMergedBranches(repoRoot);\n\n const reaped: ReapedBranch[] = [];\n const failed: ReapedBranch[] = [];\n for (const entry of verdicts.deletable) {\n const outcome = this.deleteOne(repoRoot, verb, entry);\n if (outcome.ok) reaped.push(outcome);\n else failed.push(outcome);\n }\n\n this.rewriteCache(repoRoot, verdicts, failed);\n return new ReapResult(reaped, failed, verdicts.keep);\n }\n\n /**\n * One branch, one `git branch -D`. NEVER the multi-name form the old fix hint used: git aborts\n * the whole command on the first branch it refuses, which would strand every branch after it in\n * the list. One invocation each means one failure costs exactly one branch.\n */\n private deleteOne(repoRoot: string, verb: MutationVerb, entry: DeletableBranch): 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 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\n const event = new BranchMutationEvent(verb, 'REAP');\n event.fromBranch = entry.branch;\n event.sha = sha;\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 * 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/rules-config/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, 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 // 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 constructor(reaped: ReapedBranch[], failed: ReapedBranch[], spared: DeletableBranch[]) {\n this.reaped = reaped;\n this.failed = failed;\n this.spared = spared;\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 const verdicts = cache ?? this.mergedBranches.computeMergedBranches(repoRoot);\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 for (const entry of approved) {\n const outcome = this.deleteOne(repoRoot, verb, entry, retention);\n if (outcome.ok) reaped.push(outcome);\n else failed.push(outcome);\n }\n return new ReapResult(reaped, failed, []);\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 for (const entry of targets) {\n const outcome = this.deleteOne(repoRoot, verb, entry, retention);\n if (outcome.ok) reaped.push(outcome);\n else failed.push(outcome);\n }\n\n this.rewriteCache(repoRoot, verdicts, failed);\n return new ReapResult(reaped, failed, verdicts.keep);\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 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 // 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"]}
|