@webpieces/repo-workflow-core 0.0.1
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 +31 -0
- package/src/agent-worktree-lock.d.ts +21 -0
- package/src/agent-worktree-lock.js +77 -0
- package/src/agent-worktree-lock.js.map +1 -0
- package/src/branch-archiver.d.ts +70 -0
- package/src/branch-archiver.js +157 -0
- package/src/branch-archiver.js.map +1 -0
- package/src/branch-identity.d.ts +6 -0
- package/src/branch-identity.js +59 -0
- package/src/branch-identity.js.map +1 -0
- package/src/branch-mutation-log.d.ts +77 -0
- package/src/branch-mutation-log.js +221 -0
- package/src/branch-mutation-log.js.map +1 -0
- package/src/branch-reaper.d.ts +122 -0
- package/src/branch-reaper.js +261 -0
- package/src/branch-reaper.js.map +1 -0
- package/src/harness-agent-activity.d.ts +154 -0
- package/src/harness-agent-activity.js +346 -0
- package/src/harness-agent-activity.js.map +1 -0
- package/src/index.d.ts +14 -0
- package/src/index.js +18 -0
- package/src/index.js.map +1 -0
- package/src/main-sync-file.d.ts +121 -0
- package/src/main-sync-file.js +281 -0
- package/src/main-sync-file.js.map +1 -0
- package/src/main-sync-status.d.ts +197 -0
- package/src/main-sync-status.js +555 -0
- package/src/main-sync-status.js.map +1 -0
- package/src/merged-branch-verdicts.d.ts +189 -0
- package/src/merged-branch-verdicts.js +251 -0
- package/src/merged-branch-verdicts.js.map +1 -0
- package/src/merged-branches.d.ts +140 -0
- package/src/merged-branches.js +521 -0
- package/src/merged-branches.js.map +1 -0
- package/src/review-identity-stamp.d.ts +82 -0
- package/src/review-identity-stamp.js +172 -0
- package/src/review-identity-stamp.js.map +1 -0
- package/src/worktree-lock-verdicts.d.ts +149 -0
- package/src/worktree-lock-verdicts.js +225 -0
- package/src/worktree-lock-verdicts.js.map +1 -0
- package/src/worktree-reaper.d.ts +116 -0
- package/src/worktree-reaper.js +318 -0
- package/src/worktree-reaper.js.map +1 -0
- package/src/worktrees.d.ts +113 -0
- package/src/worktrees.js +260 -0
- package/src/worktrees.js.map +1 -0
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.BranchMutationLog = exports.BranchMutationEvent = void 0;
|
|
4
|
+
exports.branchMutationLogPath = branchMutationLogPath;
|
|
5
|
+
exports.logBranchMutation = logBranchMutation;
|
|
6
|
+
const tslib_1 = require("tslib");
|
|
7
|
+
const fs = tslib_1.__importStar(require("fs"));
|
|
8
|
+
const path = tslib_1.__importStar(require("path"));
|
|
9
|
+
const inversify_1 = require("inversify");
|
|
10
|
+
const tooling_common_1 = require("@webpieces/tooling-common");
|
|
11
|
+
const to_error_1 = require("@webpieces/tooling-common/to-error");
|
|
12
|
+
// The BRANCH-MUTATION log — an audit trail for every workflow verb that RENAMES or MOVES branches.
|
|
13
|
+
// Records START / each phase boundary / END-with-outcome so the next agent (or a human) can
|
|
14
|
+
// reconstruct what the tooling did to the branches. Writes to `.webpieces/logs/branch-mutations.log`
|
|
15
|
+
// (in a linked worktree: `.webpieces/worktrees/<name>/logs/`) — see LOGS_STATE_DIR for why every
|
|
16
|
+
// webpieces log lives under `logs/` and no longer beside the non-log state in `hooks/`.
|
|
17
|
+
// Lives in repo-workflow-core (shared by guards and pr-gate) so the pr-gate scripts can call it directly.
|
|
18
|
+
const LOG_FILE = 'branch-mutations.log';
|
|
19
|
+
const LOG_FILE_PREV = 'branch-mutations.1.log';
|
|
20
|
+
const MAX_LOG_BYTES = 512 * 1024; // 512 KB — rotate when exceeded (mirrors the other webpieces logs)
|
|
21
|
+
const MAX_DETAIL_LEN = 400;
|
|
22
|
+
// Data-only record of one branch-mutation event (per CLAUDE.md: classes for data, explicit construction).
|
|
23
|
+
class BranchMutationEvent {
|
|
24
|
+
verb;
|
|
25
|
+
phase;
|
|
26
|
+
fromBranch = '';
|
|
27
|
+
toBranch = '';
|
|
28
|
+
oldMain = '';
|
|
29
|
+
newMain = '';
|
|
30
|
+
conflict = false;
|
|
31
|
+
conflictFiles = [];
|
|
32
|
+
outcome = '';
|
|
33
|
+
artifacts = [];
|
|
34
|
+
// The commit a DELETED branch pointed at, captured immediately before the delete. This is what
|
|
35
|
+
// makes a reap auditable AND reversible: the work is already in main, and the pre-delete tip is
|
|
36
|
+
// still addressable by hash (the reflog holds it ~90 days), so formatDetail renders a literal
|
|
37
|
+
// `recover=git branch <name> <sha>` next to it. Empty for mutations that delete nothing.
|
|
38
|
+
sha = '';
|
|
39
|
+
// The `archive/<date>/<branch>` tag written immediately BEFORE a REAP deleted the branch. When set,
|
|
40
|
+
// formatDetail renders `recover=` against the TAG instead of the sha: a tag is a permanent ref that
|
|
41
|
+
// survives `gc` and reflog expiry and can be pushed, whereas a bare sha is only recoverable while
|
|
42
|
+
// this clone's reflog still holds it. Empty when nothing was tagged (retention policy 'delete').
|
|
43
|
+
archiveTag = '';
|
|
44
|
+
// The directory a REAP_WORKTREE removed. When set, `recover=` becomes the WORKTREE form
|
|
45
|
+
// (`git worktree add -b <branch> <path> <ref>`) rather than the bare `git branch` form: putting the
|
|
46
|
+
// ref back does not put the directory back, and a recover line that restores half of what was
|
|
47
|
+
// destroyed is worse than none — it reads as done. Empty for every mutation that removes no directory.
|
|
48
|
+
worktreePath = '';
|
|
49
|
+
constructor(verb, phase) {
|
|
50
|
+
this.verb = verb;
|
|
51
|
+
this.phase = phase;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
exports.BranchMutationEvent = BranchMutationEvent;
|
|
55
|
+
/** Appends branch-mutation audit lines. `@injectable(bindingScopeValues.Singleton)` so it's injectable + drawn in the design. */
|
|
56
|
+
let BranchMutationLog = class BranchMutationLog {
|
|
57
|
+
dotDir;
|
|
58
|
+
constructor(dotDir = tooling_common_1.dotWebpieces) {
|
|
59
|
+
this.dotDir = dotDir;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* LOCAL scope, deliberately — one log per worktree, not one per repo.
|
|
63
|
+
*
|
|
64
|
+
* A SHARED append-only log would genuinely corrupt. `O_APPEND` makes a write indivisible only up to
|
|
65
|
+
* PIPE_BUF, which is 512 bytes on macOS, and a REAP_WORKTREE line carrying
|
|
66
|
+
* `recover=git worktree add -b <branch> <absolute-path> <tag>` exceeds that — concurrent appenders
|
|
67
|
+
* from seven worktrees would interleave into an unrecoverable audit trail, which is the one thing
|
|
68
|
+
* this file exists not to be. Per-worktree, THIS log has one writer and cannot tear.
|
|
69
|
+
*
|
|
70
|
+
* That last claim is narrower than it looks, so do not generalise it (2026-08-06). It holds here
|
|
71
|
+
* because the wp-* bins write this file one command at a time. It does NOT hold for the ai-hook
|
|
72
|
+
* logs in the same directory: Claude Code runs every matching PreToolUse hook IN PARALLEL, and
|
|
73
|
+
* several agents and sessions can share one tree, so a per-worktree name there had several
|
|
74
|
+
* concurrent writers. Those names now carry a `<sessionId>-<agentId|coordinator>-<hook>-` prefix
|
|
75
|
+
* (ai-hook-rules' LogStream), and the rejection DETAIL files sit in a directory of that same name.
|
|
76
|
+
*
|
|
77
|
+
* This file keeps a bare name deliberately, for TWO independent reasons, and neither has gone away
|
|
78
|
+
* (re-checked 2026-08-07). First, LogStream lives in ai-hook-rules, which DEPENDS on rules-config,
|
|
79
|
+
* so the import direction forbids reusing it here. Second — and this is the one that would still
|
|
80
|
+
* bite after moving the class down — the `wp-*` bins that write this file have NO session or agent
|
|
81
|
+
* identity to prefix with: those ids reach a PreToolUse hook on its JSON payload and a plain Bash
|
|
82
|
+
* tool call never sees them. Routing this through LogStream today would therefore produce
|
|
83
|
+
* `unknown-coordinator-hook-branch-mutations.log`: one shared file, exactly as now, under a longer
|
|
84
|
+
* name, plus a rename of a log people already grep. If this log ever gains a second concurrent
|
|
85
|
+
* writer, it needs a real stream identity FIRST — the prefix is only worth having when it
|
|
86
|
+
* discriminates.
|
|
87
|
+
*
|
|
88
|
+
* Nothing is lost by keeping it local: under the `worktrees/<name>/` layout the log lives in the
|
|
89
|
+
* PRIMARY clone, so it survives `git worktree remove`, and the whole history is one glob —
|
|
90
|
+
* `<primary>/.webpieces/worktrees/*/logs/branch-mutations.log`.
|
|
91
|
+
*/
|
|
92
|
+
branchMutationLogPath(root) {
|
|
93
|
+
return this.dotDir.logsFile(root, LOG_FILE);
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Append one tab-separated line per branch-mutation event to
|
|
97
|
+
* `.webpieces/logs/branch-mutations.log`. Swallows all errors — logging must NEVER block or fail
|
|
98
|
+
* the workflow it is observing.
|
|
99
|
+
*/
|
|
100
|
+
logBranchMutation(root, event) {
|
|
101
|
+
// eslint-disable-next-line @webpieces/no-unmanaged-exceptions
|
|
102
|
+
try {
|
|
103
|
+
const timestamp = new Date().toISOString();
|
|
104
|
+
const logsDir = this.dotDir.logs(root);
|
|
105
|
+
fs.mkdirSync(logsDir, { recursive: true });
|
|
106
|
+
const logPath = path.join(logsDir, LOG_FILE);
|
|
107
|
+
this.rotateLogFile(logPath, path.join(logsDir, LOG_FILE_PREV));
|
|
108
|
+
const line = [
|
|
109
|
+
`[${timestamp}]`,
|
|
110
|
+
event.verb,
|
|
111
|
+
event.phase,
|
|
112
|
+
this.oneLine(this.formatDetail(event)),
|
|
113
|
+
].join('\t') + '\n';
|
|
114
|
+
fs.appendFileSync(logPath, line);
|
|
115
|
+
}
|
|
116
|
+
catch (err) {
|
|
117
|
+
const error = (0, to_error_1.toError)(err);
|
|
118
|
+
void error;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
// Render only the fields this event actually set, as `key=value` tokens — greppable on one line.
|
|
122
|
+
formatDetail(event) {
|
|
123
|
+
const parts = [];
|
|
124
|
+
// A rename/move has both ends; a REAP has only the branch it destroyed. Printing `to=?` for
|
|
125
|
+
// the latter reads like a lost destination rather than "there was never one".
|
|
126
|
+
if (event.fromBranch !== '' && event.toBranch !== '')
|
|
127
|
+
parts.push(`from=${event.fromBranch} to=${event.toBranch}`);
|
|
128
|
+
else if (event.fromBranch !== '')
|
|
129
|
+
parts.push(`branch=${event.fromBranch}`);
|
|
130
|
+
else if (event.toBranch !== '')
|
|
131
|
+
parts.push(`from=? to=${event.toBranch}`);
|
|
132
|
+
if (event.oldMain !== '' || event.newMain !== '')
|
|
133
|
+
parts.push(`oldMain=${event.oldMain || '?'} newMain=${event.newMain || '?'}`);
|
|
134
|
+
if (event.conflict)
|
|
135
|
+
parts.push('conflict=true');
|
|
136
|
+
if (event.conflictFiles.length > 0)
|
|
137
|
+
parts.push(`conflictFiles=${event.conflictFiles.length}(${event.conflictFiles.join(',')})`);
|
|
138
|
+
// Emitted as one unit so the hash is never separated from the command that undoes the delete.
|
|
139
|
+
// Prefer the archive TAG as the recover ref when there is one — it does not expire.
|
|
140
|
+
//
|
|
141
|
+
// AHEAD OF `outcome=` DELIBERATELY. The line is capped at MAX_DETAIL_LEN, and the two absolute
|
|
142
|
+
// worktree paths a REAP_WORKTREE carries already put a real reap within a few characters of
|
|
143
|
+
// that cap — so whichever token comes last is the one that silently loses its tail. Prose
|
|
144
|
+
// describing what happened is the affordable loss; the command that undoes it is not.
|
|
145
|
+
if (event.worktreePath !== '') {
|
|
146
|
+
parts.push(this.worktreeDetail(event));
|
|
147
|
+
}
|
|
148
|
+
else if (event.sha !== '' && event.archiveTag !== '') {
|
|
149
|
+
parts.push(`sha=${event.sha} archiveTag=${event.archiveTag} ` +
|
|
150
|
+
`recover=git checkout -b ${event.fromBranch || '?'} ${event.archiveTag}`);
|
|
151
|
+
}
|
|
152
|
+
else if (event.sha !== '') {
|
|
153
|
+
parts.push(`sha=${event.sha} recover=git branch ${event.fromBranch || '?'} ${event.sha}`);
|
|
154
|
+
}
|
|
155
|
+
if (event.outcome !== '')
|
|
156
|
+
parts.push(`outcome=${event.outcome}`);
|
|
157
|
+
for (const artifact of event.artifacts)
|
|
158
|
+
parts.push(`artifact=${artifact}`);
|
|
159
|
+
return parts.join(' ');
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* The worktree flavour of the sha/recover token: path, tip, archive tag and the ONE command that
|
|
163
|
+
* puts the directory AND the branch back together.
|
|
164
|
+
*
|
|
165
|
+
* `git worktree add -b <branch> <path> <ref>` is verified by hand and in worktree-reaper.spec.ts —
|
|
166
|
+
* plain `git worktree add <path> <tag>` would restore the files at a DETACHED HEAD, silently losing
|
|
167
|
+
* the branch name the reap destroyed. Falls back to the sha when nothing was archived (retention
|
|
168
|
+
* 'delete'), and to a bare `git worktree add <path>` when there was no branch at all (detached).
|
|
169
|
+
*/
|
|
170
|
+
worktreeDetail(event) {
|
|
171
|
+
const ref = event.archiveTag !== '' ? event.archiveTag : event.sha;
|
|
172
|
+
const tokens = [`worktree=${event.worktreePath}`];
|
|
173
|
+
if (event.sha !== '')
|
|
174
|
+
tokens.push(`sha=${event.sha}`);
|
|
175
|
+
if (event.archiveTag !== '')
|
|
176
|
+
tokens.push(`archiveTag=${event.archiveTag}`);
|
|
177
|
+
if (ref === '')
|
|
178
|
+
return tokens.join(' ');
|
|
179
|
+
const recover = event.fromBranch !== ''
|
|
180
|
+
? `git worktree add -b ${event.fromBranch} ${event.worktreePath} ${ref}`
|
|
181
|
+
: `git worktree add ${event.worktreePath} ${ref}`;
|
|
182
|
+
tokens.push(`recover=${recover}`);
|
|
183
|
+
return tokens.join(' ');
|
|
184
|
+
}
|
|
185
|
+
// Collapse newlines/tabs and cap length so one event is always exactly one log line.
|
|
186
|
+
oneLine(value) {
|
|
187
|
+
const flat = value.replace(/[\t\r\n]+/g, ' ').trim();
|
|
188
|
+
return flat.length <= MAX_DETAIL_LEN ? flat : flat.slice(0, MAX_DETAIL_LEN) + '…';
|
|
189
|
+
}
|
|
190
|
+
rotateLogFile(logPath, prevPath) {
|
|
191
|
+
// eslint-disable-next-line @webpieces/no-unmanaged-exceptions
|
|
192
|
+
try {
|
|
193
|
+
const stat = fs.statSync(logPath);
|
|
194
|
+
if (stat.size > MAX_LOG_BYTES) {
|
|
195
|
+
if (fs.existsSync(prevPath))
|
|
196
|
+
fs.unlinkSync(prevPath);
|
|
197
|
+
fs.renameSync(logPath, prevPath);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
catch (err) {
|
|
201
|
+
const error = (0, to_error_1.toError)(err);
|
|
202
|
+
void error;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
exports.BranchMutationLog = BranchMutationLog;
|
|
207
|
+
exports.BranchMutationLog = BranchMutationLog = tslib_1.__decorate([
|
|
208
|
+
(0, inversify_1.injectable)(inversify_1.bindingScopeValues.Singleton),
|
|
209
|
+
tslib_1.__metadata("design:paramtypes", [tooling_common_1.DotWebpieces])
|
|
210
|
+
], BranchMutationLog);
|
|
211
|
+
// Temporary migration delegators to BranchMutationLog — removed once consumers inject it.
|
|
212
|
+
const branchMutationLogSvc = new BranchMutationLog();
|
|
213
|
+
// webpieces-disable no-function-outside-class -- temporary back-compat delegator to BranchMutationLog; removed once consumers inject it
|
|
214
|
+
function branchMutationLogPath(root) {
|
|
215
|
+
return branchMutationLogSvc.branchMutationLogPath(root);
|
|
216
|
+
}
|
|
217
|
+
// webpieces-disable no-function-outside-class -- temporary back-compat delegator to BranchMutationLog; removed once consumers inject it
|
|
218
|
+
function logBranchMutation(root, event) {
|
|
219
|
+
branchMutationLogSvc.logBranchMutation(root, event);
|
|
220
|
+
}
|
|
221
|
+
//# sourceMappingURL=branch-mutation-log.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"branch-mutation-log.js","sourceRoot":"","sources":["../../../../../packages/tooling/repo-workflow-core/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 { DotWebpieces, dotWebpieces } from '@webpieces/tooling-common';\nimport { toError } from '@webpieces/tooling-common/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/logs/branch-mutations.log`\n// (in a linked worktree: `.webpieces/worktrees/<name>/logs/`) — see LOGS_STATE_DIR for why every\n// webpieces log lives under `logs/` and no longer beside the non-log state in `hooks/`.\n// Lives in repo-workflow-core (shared by guards and pr-gate) so the pr-gate scripts can call it directly.\n\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-review-upsert-pr'\n | 'wp-finish-upsert-pr' | 'wp-cleanup' | 'wp-land-pr' | '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.\n// REAP_WORKTREE is its worktree twin: archive → `git worktree remove` → `git branch -D`, all three of\n// which succeed or fail as one act. It is a SEPARATE phase, not just a REAP with a path, so that\n// `grep REAP_WORKTREE` answers \"what directories did the tooling delete?\" — a strictly scarier\n// question than \"what refs did it delete?\", since a worktree removal takes real files with it.\nexport type MutationPhase =\n | 'START' | 'BACKUP' | 'CHECKOUT_MAIN' | 'PULL' | 'SQUASH' | 'RENAME'\n | 'FINALIZE' | 'CONFLICT' | 'INTERRUPTED' | 'END' | 'REAP' | 'REAP_WORKTREE';\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 // The directory a REAP_WORKTREE removed. When set, `recover=` becomes the WORKTREE form\n // (`git worktree add -b <branch> <path> <ref>`) rather than the bare `git branch` form: putting the\n // ref back does not put the directory back, and a recover line that restores half of what was\n // destroyed is worse than none — it reads as done. Empty for every mutation that removes no directory.\n worktreePath: 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 constructor(private readonly dotDir: DotWebpieces = dotWebpieces) {}\n\n /**\n * LOCAL scope, deliberately — one log per worktree, not one per repo.\n *\n * A SHARED append-only log would genuinely corrupt. `O_APPEND` makes a write indivisible only up to\n * PIPE_BUF, which is 512 bytes on macOS, and a REAP_WORKTREE line carrying\n * `recover=git worktree add -b <branch> <absolute-path> <tag>` exceeds that — concurrent appenders\n * from seven worktrees would interleave into an unrecoverable audit trail, which is the one thing\n * this file exists not to be. Per-worktree, THIS log has one writer and cannot tear.\n *\n * That last claim is narrower than it looks, so do not generalise it (2026-08-06). It holds here\n * because the wp-* bins write this file one command at a time. It does NOT hold for the ai-hook\n * logs in the same directory: Claude Code runs every matching PreToolUse hook IN PARALLEL, and\n * several agents and sessions can share one tree, so a per-worktree name there had several\n * concurrent writers. Those names now carry a `<sessionId>-<agentId|coordinator>-<hook>-` prefix\n * (ai-hook-rules' LogStream), and the rejection DETAIL files sit in a directory of that same name.\n *\n * This file keeps a bare name deliberately, for TWO independent reasons, and neither has gone away\n * (re-checked 2026-08-07). First, LogStream lives in ai-hook-rules, which DEPENDS on rules-config,\n * so the import direction forbids reusing it here. Second — and this is the one that would still\n * bite after moving the class down — the `wp-*` bins that write this file have NO session or agent\n * identity to prefix with: those ids reach a PreToolUse hook on its JSON payload and a plain Bash\n * tool call never sees them. Routing this through LogStream today would therefore produce\n * `unknown-coordinator-hook-branch-mutations.log`: one shared file, exactly as now, under a longer\n * name, plus a rename of a log people already grep. If this log ever gains a second concurrent\n * writer, it needs a real stream identity FIRST — the prefix is only worth having when it\n * discriminates.\n *\n * Nothing is lost by keeping it local: under the `worktrees/<name>/` layout the log lives in the\n * PRIMARY clone, so it survives `git worktree remove`, and the whole history is one glob —\n * `<primary>/.webpieces/worktrees/*/logs/branch-mutations.log`.\n */\n branchMutationLogPath(root: string): string {\n return this.dotDir.logsFile(root, LOG_FILE);\n }\n\n /**\n * Append one tab-separated line per branch-mutation event to\n * `.webpieces/logs/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 logsDir = this.dotDir.logs(root);\n fs.mkdirSync(logsDir, { recursive: true });\n\n const logPath = path.join(logsDir, LOG_FILE);\n this.rotateLogFile(logPath, path.join(logsDir, 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 // 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 //\n // AHEAD OF `outcome=` DELIBERATELY. The line is capped at MAX_DETAIL_LEN, and the two absolute\n // worktree paths a REAP_WORKTREE carries already put a real reap within a few characters of\n // that cap — so whichever token comes last is the one that silently loses its tail. Prose\n // describing what happened is the affordable loss; the command that undoes it is not.\n if (event.worktreePath !== '') {\n parts.push(this.worktreeDetail(event));\n } else 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 if (event.outcome !== '') parts.push(`outcome=${event.outcome}`);\n for (const artifact of event.artifacts) parts.push(`artifact=${artifact}`);\n return parts.join(' ');\n }\n\n /**\n * The worktree flavour of the sha/recover token: path, tip, archive tag and the ONE command that\n * puts the directory AND the branch back together.\n *\n * `git worktree add -b <branch> <path> <ref>` is verified by hand and in worktree-reaper.spec.ts —\n * plain `git worktree add <path> <tag>` would restore the files at a DETACHED HEAD, silently losing\n * the branch name the reap destroyed. Falls back to the sha when nothing was archived (retention\n * 'delete'), and to a bare `git worktree add <path>` when there was no branch at all (detached).\n */\n private worktreeDetail(event: BranchMutationEvent): string {\n const ref = event.archiveTag !== '' ? event.archiveTag : event.sha;\n const tokens = [`worktree=${event.worktreePath}`];\n if (event.sha !== '') tokens.push(`sha=${event.sha}`);\n if (event.archiveTag !== '') tokens.push(`archiveTag=${event.archiveTag}`);\n if (ref === '') return tokens.join(' ');\n const recover = event.fromBranch !== ''\n ? `git worktree add -b ${event.fromBranch} ${event.worktreePath} ${ref}`\n : `git worktree add ${event.worktreePath} ${ref}`;\n tokens.push(`recover=${recover}`);\n return tokens.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"]}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { BranchArchiver } from './branch-archiver';
|
|
2
|
+
import { BranchMutationLog, MutationVerb } from './branch-mutation-log';
|
|
3
|
+
import { DeletableBranch, MergedBranchesCache } from './merged-branch-verdicts';
|
|
4
|
+
import { MergedBranchesService } from './merged-branches';
|
|
5
|
+
/**
|
|
6
|
+
* The EXECUTOR for the dead-branch verdicts that merged-branches.ts computes.
|
|
7
|
+
*
|
|
8
|
+
* WHY this exists: for a long time nothing in the tooling ever deleted a branch. The reap was only
|
|
9
|
+
* ever a `git branch -D a b c` STRING embedded in a guard's fix hint, handed to an AI agent — which
|
|
10
|
+
* reads a raw `-D` as destructive, asks "may I clean this up?", and stops. So the verdicts were
|
|
11
|
+
* computed correctly on every hook call and then acted on by nobody, and local branches grew without
|
|
12
|
+
* bound. The fix is not better wording; it is a thing that does the deleting.
|
|
13
|
+
*
|
|
14
|
+
* WHY deleting here is safe: every entry in `deletable` earned its place by one of exactly three
|
|
15
|
+
* proofs — a MERGED PR (the work is in main), a squash-merge BACKUP of a merged branch, or ZERO
|
|
16
|
+
* commits of its own (there is no work). merged-branches.ts also guarantees `main` is never in the
|
|
17
|
+
* list and that a branch checked out in ANY worktree lands in `keep` instead, so we can never delete
|
|
18
|
+
* the branch someone is standing on. On top of that, every delete is logged with the branch's
|
|
19
|
+
* pre-delete SHA and a literal recover command, so no reap is unrecoverable.
|
|
20
|
+
*/
|
|
21
|
+
export declare class ReapedBranch {
|
|
22
|
+
branch: string;
|
|
23
|
+
sha: string;
|
|
24
|
+
reason: string;
|
|
25
|
+
pr: number;
|
|
26
|
+
ok: boolean;
|
|
27
|
+
error: string;
|
|
28
|
+
/**
|
|
29
|
+
* The `archive/<date>/<branch>` tag written immediately BEFORE the delete, or '' when the retention
|
|
30
|
+
* policy is 'delete'. Non-empty means the branch is restorable by NAME rather than by remembering a
|
|
31
|
+
* sha — `git checkout -b <branch> <tag>` restores the exact objects, and the tag survives `gc` and
|
|
32
|
+
* reflog expiry. Field-with-default so every existing `new ReapedBranch(...)` call site still builds.
|
|
33
|
+
*/
|
|
34
|
+
archiveTag: string;
|
|
35
|
+
/**
|
|
36
|
+
* The branch was ALREADY GONE when we reached it — someone else reaped it first.
|
|
37
|
+
*
|
|
38
|
+
* Not a failure, and the distinction is not cosmetic. `wp-cleanup` and the detached refresher's
|
|
39
|
+
* `auto-reap` both act on their own fresh verdicts, and the hooks kick off the refresher on the
|
|
40
|
+
* same commands a human runs wp-cleanup from, so the two race by design. Observed 2026-08-04:
|
|
41
|
+
* auto-reap deleted both dead branches at 14:30:58.207 and 14:30:59.035, wp-cleanup reached them
|
|
42
|
+
* 300ms later and reported "⚠️ 2 branch(es) could not be deleted — could not archive it first:
|
|
43
|
+
* unknown revision", i.e. it announced a failure for the exact outcome it wanted. Whoever wins,
|
|
44
|
+
* the branch is gone, archived and logged by the winner.
|
|
45
|
+
*
|
|
46
|
+
* Field-with-default so every existing `new ReapedBranch(...)` call site still builds.
|
|
47
|
+
*/
|
|
48
|
+
alreadyGone: boolean;
|
|
49
|
+
constructor(branch: string, sha: string, reason: string, pr: number, ok: boolean, error: string);
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The outcome of one reap. `spared` is carried alongside deliberately: a cleanup that silently says
|
|
53
|
+
* nothing about the branches it did NOT touch reads as "there was nothing else", when in fact those
|
|
54
|
+
* are exactly the branches only a human can rule on.
|
|
55
|
+
*/
|
|
56
|
+
export declare class ReapResult {
|
|
57
|
+
reaped: ReapedBranch[];
|
|
58
|
+
failed: ReapedBranch[];
|
|
59
|
+
spared: DeletableBranch[];
|
|
60
|
+
/**
|
|
61
|
+
* Branches a CONCURRENT reaper got to first (see ReapedBranch.alreadyGone). A separate bucket
|
|
62
|
+
* because they belong in neither of the other two: counting them as `reaped` would credit this
|
|
63
|
+
* run with a delete and an archive tag it did not write, and counting them as `failed` is the bug
|
|
64
|
+
* this fixes — a red warning about branches that are, in fact, gone.
|
|
65
|
+
*
|
|
66
|
+
* Optional so every existing `new ReapResult(...)` call site still builds.
|
|
67
|
+
*/
|
|
68
|
+
alreadyGone: ReapedBranch[];
|
|
69
|
+
constructor(reaped: ReapedBranch[], failed: ReapedBranch[], spared: DeletableBranch[], alreadyGone?: ReapedBranch[]);
|
|
70
|
+
}
|
|
71
|
+
export declare class BranchReaper {
|
|
72
|
+
private readonly mergedBranches;
|
|
73
|
+
private readonly mutationLog;
|
|
74
|
+
private readonly archiver;
|
|
75
|
+
constructor(mergedBranches?: MergedBranchesService, mutationLog?: BranchMutationLog, archiver?: BranchArchiver);
|
|
76
|
+
/**
|
|
77
|
+
* Delete every branch the verdicts call dead, one command at a time.
|
|
78
|
+
*
|
|
79
|
+
* `cache` is an ALREADY-FRESH set of verdicts (the refresher just computed them, so re-running
|
|
80
|
+
* the `gh` lookup would be pure waste). Pass nothing — as `wp-cleanup` does — and we recompute
|
|
81
|
+
* from scratch. That distinction is load-bearing: the cache file on disk is DELIBERATELY allowed
|
|
82
|
+
* to go stale, which is fine for blocking a branch creation but is not fine for deleting, since a
|
|
83
|
+
* branch may have gained commits since it was written. Deleting never reads the stale file.
|
|
84
|
+
*/
|
|
85
|
+
reap(repoRoot: string, verb: MutationVerb, cache?: MergedBranchesCache | null, retention?: string): ReapResult;
|
|
86
|
+
/**
|
|
87
|
+
* Delete a CALLER-SUPPLIED list of branches — the branches a human just said yes to at wp-cleanup's
|
|
88
|
+
* classification prompt. Separate from `reap` because these are NOT provably dead: they earned their
|
|
89
|
+
* deletion from an explicit human answer, not from a verdict, so nothing here may ever run unattended.
|
|
90
|
+
* Archiving still happens first, which is precisely what makes that yes low-stakes.
|
|
91
|
+
*/
|
|
92
|
+
reapApproved(repoRoot: string, verb: MutationVerb, approved: DeletableBranch[], retention?: string): ReapResult;
|
|
93
|
+
private reapBranches;
|
|
94
|
+
/**
|
|
95
|
+
* One branch: ARCHIVE the tip as a tag, then one `git branch -D`. Never the multi-name form the old
|
|
96
|
+
* fix hint used: git aborts the whole command on the first branch it refuses, which would strand
|
|
97
|
+
* every branch after it in the list. One invocation each means one failure costs exactly one branch.
|
|
98
|
+
*
|
|
99
|
+
* The archive comes FIRST and, when it fails, the delete does NOT happen. A branch we could not
|
|
100
|
+
* archive is a branch whose only remaining copy is the reflog, and the entire point of this change is
|
|
101
|
+
* to stop relying on the reflog. Refusing to delete is the fail-safe direction: the worst case is a
|
|
102
|
+
* branch that survives one more cleanup cycle.
|
|
103
|
+
*/
|
|
104
|
+
private deleteOne;
|
|
105
|
+
/**
|
|
106
|
+
* The branch vanished between the verdict and the reap — a concurrent reaper got it.
|
|
107
|
+
*
|
|
108
|
+
* Logged like every other outcome (a mutation nobody logs is a mutation nobody can audit), but
|
|
109
|
+
* as ALREADY_GONE rather than SKIPPED/FAILED, and carried in its own bucket so the report can
|
|
110
|
+
* say "already gone" instead of warning about a branch that is exactly as dead as intended.
|
|
111
|
+
*/
|
|
112
|
+
private alreadyGone;
|
|
113
|
+
private archiveFailed;
|
|
114
|
+
/**
|
|
115
|
+
* Write the verdicts back with the reaped branches removed, so the branch-creation-guard's cap
|
|
116
|
+
* sees the post-reap truth on its very next call instead of continuing to block against branches
|
|
117
|
+
* that no longer exist. Anything that FAILED to delete stays in `deletable` — it is still there,
|
|
118
|
+
* and still dead.
|
|
119
|
+
*/
|
|
120
|
+
private rewriteCache;
|
|
121
|
+
private capture;
|
|
122
|
+
}
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.BranchReaper = exports.ReapResult = exports.ReapedBranch = void 0;
|
|
4
|
+
const tslib_1 = require("tslib");
|
|
5
|
+
const child_process_1 = require("child_process");
|
|
6
|
+
const inversify_1 = require("inversify");
|
|
7
|
+
const branch_archiver_1 = require("./branch-archiver");
|
|
8
|
+
const branch_mutation_log_1 = require("./branch-mutation-log");
|
|
9
|
+
const merged_branch_verdicts_1 = require("./merged-branch-verdicts");
|
|
10
|
+
const merged_branches_1 = require("./merged-branches");
|
|
11
|
+
/**
|
|
12
|
+
* The EXECUTOR for the dead-branch verdicts that merged-branches.ts computes.
|
|
13
|
+
*
|
|
14
|
+
* WHY this exists: for a long time nothing in the tooling ever deleted a branch. The reap was only
|
|
15
|
+
* ever a `git branch -D a b c` STRING embedded in a guard's fix hint, handed to an AI agent — which
|
|
16
|
+
* reads a raw `-D` as destructive, asks "may I clean this up?", and stops. So the verdicts were
|
|
17
|
+
* computed correctly on every hook call and then acted on by nobody, and local branches grew without
|
|
18
|
+
* bound. The fix is not better wording; it is a thing that does the deleting.
|
|
19
|
+
*
|
|
20
|
+
* WHY deleting here is safe: every entry in `deletable` earned its place by one of exactly three
|
|
21
|
+
* proofs — a MERGED PR (the work is in main), a squash-merge BACKUP of a merged branch, or ZERO
|
|
22
|
+
* commits of its own (there is no work). merged-branches.ts also guarantees `main` is never in the
|
|
23
|
+
* list and that a branch checked out in ANY worktree lands in `keep` instead, so we can never delete
|
|
24
|
+
* the branch someone is standing on. On top of that, every delete is logged with the branch's
|
|
25
|
+
* pre-delete SHA and a literal recover command, so no reap is unrecoverable.
|
|
26
|
+
*/
|
|
27
|
+
// Data-only (per CLAUDE.md, classes for data). One branch and what happened to it.
|
|
28
|
+
class ReapedBranch {
|
|
29
|
+
branch;
|
|
30
|
+
// The commit the branch pointed at BEFORE deletion — captured first, precisely so a delete is
|
|
31
|
+
// always undoable via `git branch <branch> <sha>`. Empty only if rev-parse itself failed.
|
|
32
|
+
sha;
|
|
33
|
+
reason;
|
|
34
|
+
pr;
|
|
35
|
+
ok;
|
|
36
|
+
// git's own stderr when ok=false. Kept verbatim: a failed delete is a thing a human must read.
|
|
37
|
+
error;
|
|
38
|
+
/**
|
|
39
|
+
* The `archive/<date>/<branch>` tag written immediately BEFORE the delete, or '' when the retention
|
|
40
|
+
* policy is 'delete'. Non-empty means the branch is restorable by NAME rather than by remembering a
|
|
41
|
+
* sha — `git checkout -b <branch> <tag>` restores the exact objects, and the tag survives `gc` and
|
|
42
|
+
* reflog expiry. Field-with-default so every existing `new ReapedBranch(...)` call site still builds.
|
|
43
|
+
*/
|
|
44
|
+
archiveTag = '';
|
|
45
|
+
/**
|
|
46
|
+
* The branch was ALREADY GONE when we reached it — someone else reaped it first.
|
|
47
|
+
*
|
|
48
|
+
* Not a failure, and the distinction is not cosmetic. `wp-cleanup` and the detached refresher's
|
|
49
|
+
* `auto-reap` both act on their own fresh verdicts, and the hooks kick off the refresher on the
|
|
50
|
+
* same commands a human runs wp-cleanup from, so the two race by design. Observed 2026-08-04:
|
|
51
|
+
* auto-reap deleted both dead branches at 14:30:58.207 and 14:30:59.035, wp-cleanup reached them
|
|
52
|
+
* 300ms later and reported "⚠️ 2 branch(es) could not be deleted — could not archive it first:
|
|
53
|
+
* unknown revision", i.e. it announced a failure for the exact outcome it wanted. Whoever wins,
|
|
54
|
+
* the branch is gone, archived and logged by the winner.
|
|
55
|
+
*
|
|
56
|
+
* Field-with-default so every existing `new ReapedBranch(...)` call site still builds.
|
|
57
|
+
*/
|
|
58
|
+
alreadyGone = false;
|
|
59
|
+
// eslint-disable-next-line @typescript-eslint/max-params
|
|
60
|
+
constructor(branch, sha, reason, pr, ok, error) {
|
|
61
|
+
this.branch = branch;
|
|
62
|
+
this.sha = sha;
|
|
63
|
+
this.reason = reason;
|
|
64
|
+
this.pr = pr;
|
|
65
|
+
this.ok = ok;
|
|
66
|
+
this.error = error;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
exports.ReapedBranch = ReapedBranch;
|
|
70
|
+
/**
|
|
71
|
+
* The outcome of one reap. `spared` is carried alongside deliberately: a cleanup that silently says
|
|
72
|
+
* nothing about the branches it did NOT touch reads as "there was nothing else", when in fact those
|
|
73
|
+
* are exactly the branches only a human can rule on.
|
|
74
|
+
*/
|
|
75
|
+
class ReapResult {
|
|
76
|
+
reaped;
|
|
77
|
+
failed;
|
|
78
|
+
spared;
|
|
79
|
+
/**
|
|
80
|
+
* Branches a CONCURRENT reaper got to first (see ReapedBranch.alreadyGone). A separate bucket
|
|
81
|
+
* because they belong in neither of the other two: counting them as `reaped` would credit this
|
|
82
|
+
* run with a delete and an archive tag it did not write, and counting them as `failed` is the bug
|
|
83
|
+
* this fixes — a red warning about branches that are, in fact, gone.
|
|
84
|
+
*
|
|
85
|
+
* Optional so every existing `new ReapResult(...)` call site still builds.
|
|
86
|
+
*/
|
|
87
|
+
alreadyGone;
|
|
88
|
+
constructor(reaped, failed, spared, alreadyGone = []) {
|
|
89
|
+
this.reaped = reaped;
|
|
90
|
+
this.failed = failed;
|
|
91
|
+
this.spared = spared;
|
|
92
|
+
this.alreadyGone = alreadyGone;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
exports.ReapResult = ReapResult;
|
|
96
|
+
let BranchReaper = class BranchReaper {
|
|
97
|
+
mergedBranches;
|
|
98
|
+
mutationLog;
|
|
99
|
+
archiver;
|
|
100
|
+
// Defaulted so the non-DI call sites (the detached refresher in sync-main.ts) can just
|
|
101
|
+
// `new BranchReaper()`, while inversify still injects the singletons when resolved from a
|
|
102
|
+
// 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()) {
|
|
104
|
+
this.mergedBranches = mergedBranches;
|
|
105
|
+
this.mutationLog = mutationLog;
|
|
106
|
+
this.archiver = archiver;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Delete every branch the verdicts call dead, one command at a time.
|
|
110
|
+
*
|
|
111
|
+
* `cache` is an ALREADY-FRESH set of verdicts (the refresher just computed them, so re-running
|
|
112
|
+
* the `gh` lookup would be pure waste). Pass nothing — as `wp-cleanup` does — and we recompute
|
|
113
|
+
* from scratch. That distinction is load-bearing: the cache file on disk is DELIBERATELY allowed
|
|
114
|
+
* to go stale, which is fine for blocking a branch creation but is not fine for deleting, since a
|
|
115
|
+
* branch may have gained commits since it was written. Deleting never reads the stale file.
|
|
116
|
+
*/
|
|
117
|
+
reap(repoRoot, verb, cache = null, retention = branch_archiver_1.BRANCH_RETENTION_ARCHIVE_TAG) {
|
|
118
|
+
// `false`: this is the BRANCH reap, and worktree locks are none of its business. Ignoring one
|
|
119
|
+
// would only widen the WORKTREE verdicts it never reads.
|
|
120
|
+
const verdicts = cache ?? this.mergedBranches.computeMergedBranches(repoRoot, false);
|
|
121
|
+
// 'keep' means "never delete anything" — the reap becomes a pure report. Everything still lands
|
|
122
|
+
// in `spared` so the human sees exactly what WOULD have been reaped under the other policies.
|
|
123
|
+
if (retention === branch_archiver_1.BRANCH_RETENTION_KEEP) {
|
|
124
|
+
return new ReapResult([], [], [...verdicts.deletable, ...verdicts.keep]);
|
|
125
|
+
}
|
|
126
|
+
return this.reapBranches(repoRoot, verb, verdicts.deletable, retention, verdicts);
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Delete a CALLER-SUPPLIED list of branches — the branches a human just said yes to at wp-cleanup's
|
|
130
|
+
* classification prompt. Separate from `reap` because these are NOT provably dead: they earned their
|
|
131
|
+
* deletion from an explicit human answer, not from a verdict, so nothing here may ever run unattended.
|
|
132
|
+
* Archiving still happens first, which is precisely what makes that yes low-stakes.
|
|
133
|
+
*/
|
|
134
|
+
reapApproved(repoRoot, verb, approved, retention = branch_archiver_1.BRANCH_RETENTION_ARCHIVE_TAG) {
|
|
135
|
+
if (retention === branch_archiver_1.BRANCH_RETENTION_KEEP)
|
|
136
|
+
return new ReapResult([], [], approved);
|
|
137
|
+
const reaped = [];
|
|
138
|
+
const failed = [];
|
|
139
|
+
const alreadyGone = [];
|
|
140
|
+
for (const entry of approved) {
|
|
141
|
+
const outcome = this.deleteOne(repoRoot, verb, entry, retention);
|
|
142
|
+
if (outcome.ok)
|
|
143
|
+
reaped.push(outcome);
|
|
144
|
+
else if (outcome.alreadyGone)
|
|
145
|
+
alreadyGone.push(outcome);
|
|
146
|
+
else
|
|
147
|
+
failed.push(outcome);
|
|
148
|
+
}
|
|
149
|
+
return new ReapResult(reaped, failed, [], alreadyGone);
|
|
150
|
+
}
|
|
151
|
+
reapBranches(repoRoot, verb, targets, retention, verdicts) {
|
|
152
|
+
const reaped = [];
|
|
153
|
+
const failed = [];
|
|
154
|
+
const alreadyGone = [];
|
|
155
|
+
for (const entry of targets) {
|
|
156
|
+
const outcome = this.deleteOne(repoRoot, verb, entry, retention);
|
|
157
|
+
if (outcome.ok)
|
|
158
|
+
reaped.push(outcome);
|
|
159
|
+
else if (outcome.alreadyGone)
|
|
160
|
+
alreadyGone.push(outcome);
|
|
161
|
+
else
|
|
162
|
+
failed.push(outcome);
|
|
163
|
+
}
|
|
164
|
+
// Only genuine failures stay in the cache's `deletable`. An already-gone branch is not there
|
|
165
|
+
// to delete on the next pass, and leaving it in would make the cap keep counting a ghost.
|
|
166
|
+
this.rewriteCache(repoRoot, verdicts, failed);
|
|
167
|
+
return new ReapResult(reaped, failed, verdicts.keep, alreadyGone);
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* One branch: ARCHIVE the tip as a tag, then one `git branch -D`. Never the multi-name form the old
|
|
171
|
+
* fix hint used: git aborts the whole command on the first branch it refuses, which would strand
|
|
172
|
+
* every branch after it in the list. One invocation each means one failure costs exactly one branch.
|
|
173
|
+
*
|
|
174
|
+
* The archive comes FIRST and, when it fails, the delete does NOT happen. A branch we could not
|
|
175
|
+
* archive is a branch whose only remaining copy is the reflog, and the entire point of this change is
|
|
176
|
+
* to stop relying on the reflog. Refusing to delete is the fail-safe direction: the worst case is a
|
|
177
|
+
* branch that survives one more cleanup cycle.
|
|
178
|
+
*/
|
|
179
|
+
deleteOne(repoRoot, verb, entry, retention) {
|
|
180
|
+
// SHA first — after the delete there is no branch left to resolve, and the whole point of the
|
|
181
|
+
// audit line is that it records what was destroyed while it still exists.
|
|
182
|
+
const resolved = this.capture(repoRoot, ['rev-parse', entry.branch]);
|
|
183
|
+
const sha = resolved.ok ? resolved.out : '';
|
|
184
|
+
// It does not resolve ⇒ it is already gone, reaped by whoever won the race (see
|
|
185
|
+
// ReapedBranch.alreadyGone). Report that outcome honestly instead of letting it fall through
|
|
186
|
+
// to the archive, which fails on the same missing ref and calls a successful cleanup a
|
|
187
|
+
// failure. Nothing to archive and nothing to delete: the winner did both, and logged them.
|
|
188
|
+
if (!resolved.ok)
|
|
189
|
+
return this.alreadyGone(repoRoot, verb, entry);
|
|
190
|
+
const archive = retention === branch_archiver_1.BRANCH_RETENTION_ARCHIVE_TAG
|
|
191
|
+
? this.archiver.archive(repoRoot, entry.branch)
|
|
192
|
+
: new branch_archiver_1.ArchiveResult('', sha, true, '');
|
|
193
|
+
if (!archive.ok)
|
|
194
|
+
return this.archiveFailed(repoRoot, verb, entry, sha, archive);
|
|
195
|
+
const deleted = this.capture(repoRoot, ['branch', '-D', entry.branch]);
|
|
196
|
+
const result = new ReapedBranch(entry.branch, sha, entry.reason, entry.pr, deleted.ok, deleted.ok ? '' : deleted.err);
|
|
197
|
+
result.archiveTag = archive.tag;
|
|
198
|
+
const event = new branch_mutation_log_1.BranchMutationEvent(verb, 'REAP');
|
|
199
|
+
event.fromBranch = entry.branch;
|
|
200
|
+
event.sha = sha;
|
|
201
|
+
event.archiveTag = archive.tag;
|
|
202
|
+
event.outcome = deleted.ok ? `deleted (${entry.reason})` : `FAILED (${deleted.err})`;
|
|
203
|
+
this.mutationLog.logBranchMutation(repoRoot, event);
|
|
204
|
+
return result;
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* The branch vanished between the verdict and the reap — a concurrent reaper got it.
|
|
208
|
+
*
|
|
209
|
+
* Logged like every other outcome (a mutation nobody logs is a mutation nobody can audit), but
|
|
210
|
+
* as ALREADY_GONE rather than SKIPPED/FAILED, and carried in its own bucket so the report can
|
|
211
|
+
* say "already gone" instead of warning about a branch that is exactly as dead as intended.
|
|
212
|
+
*/
|
|
213
|
+
alreadyGone(repoRoot, verb, entry) {
|
|
214
|
+
const event = new branch_mutation_log_1.BranchMutationEvent(verb, 'REAP');
|
|
215
|
+
event.fromBranch = entry.branch;
|
|
216
|
+
event.outcome = 'ALREADY GONE (a concurrent reap deleted and archived it first)';
|
|
217
|
+
this.mutationLog.logBranchMutation(repoRoot, event);
|
|
218
|
+
const result = new ReapedBranch(entry.branch, '', entry.reason, entry.pr, false, 'already gone — a concurrent reap deleted and archived it first');
|
|
219
|
+
result.alreadyGone = true;
|
|
220
|
+
return result;
|
|
221
|
+
}
|
|
222
|
+
// Archiving failed ⇒ the branch is NOT deleted. Reported as a failure with git's own words, so the
|
|
223
|
+
// human sees a branch that survived and why, rather than a silent skip.
|
|
224
|
+
archiveFailed(repoRoot, verb, entry, sha, archive) {
|
|
225
|
+
const error = `not deleted — could not archive it first: ${archive.error}`;
|
|
226
|
+
const event = new branch_mutation_log_1.BranchMutationEvent(verb, 'REAP');
|
|
227
|
+
event.fromBranch = entry.branch;
|
|
228
|
+
event.sha = sha;
|
|
229
|
+
event.outcome = `SKIPPED (${error})`;
|
|
230
|
+
this.mutationLog.logBranchMutation(repoRoot, event);
|
|
231
|
+
return new ReapedBranch(entry.branch, sha, entry.reason, entry.pr, false, error);
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Write the verdicts back with the reaped branches removed, so the branch-creation-guard's cap
|
|
235
|
+
* sees the post-reap truth on its very next call instead of continuing to block against branches
|
|
236
|
+
* that no longer exist. Anything that FAILED to delete stays in `deletable` — it is still there,
|
|
237
|
+
* and still dead.
|
|
238
|
+
*/
|
|
239
|
+
rewriteCache(repoRoot, verdicts, failed) {
|
|
240
|
+
const stillDead = new Set(failed.map((entry) => entry.branch));
|
|
241
|
+
const remaining = verdicts.deletable.filter((entry) => stillDead.has(entry.branch));
|
|
242
|
+
this.mergedBranches.writeMergedBranches(repoRoot, new merged_branch_verdicts_1.MergedBranchesCache(verdicts.timestamp, remaining, verdicts.keep, verdicts.worktrees));
|
|
243
|
+
}
|
|
244
|
+
// Run a git command capturing trimmed stdout/stderr; ok=false on spawn failure or non-zero exit.
|
|
245
|
+
capture(repoRoot, args) {
|
|
246
|
+
const result = (0, child_process_1.spawnSync)('git', args, { cwd: repoRoot, encoding: 'utf8' });
|
|
247
|
+
const err = typeof result.stderr === 'string' ? result.stderr.trim() : '';
|
|
248
|
+
if (result.status !== 0 || typeof result.stdout !== 'string') {
|
|
249
|
+
return { ok: false, out: '', err: err !== '' ? err : 'git command failed' };
|
|
250
|
+
}
|
|
251
|
+
return { ok: true, out: result.stdout.trim(), err };
|
|
252
|
+
}
|
|
253
|
+
};
|
|
254
|
+
exports.BranchReaper = BranchReaper;
|
|
255
|
+
exports.BranchReaper = BranchReaper = tslib_1.__decorate([
|
|
256
|
+
(0, inversify_1.injectable)(inversify_1.bindingScopeValues.Singleton),
|
|
257
|
+
tslib_1.__metadata("design:paramtypes", [merged_branches_1.MergedBranchesService,
|
|
258
|
+
branch_mutation_log_1.BranchMutationLog,
|
|
259
|
+
branch_archiver_1.BranchArchiver])
|
|
260
|
+
], BranchReaper);
|
|
261
|
+
//# sourceMappingURL=branch-reaper.js.map
|