mandrel 1.85.0 → 1.86.0
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/.agents/rules/git-conventions.md +33 -0
- package/.agents/scripts/boot-sweep.js +183 -0
- package/.agents/scripts/epic-deliver-prepare.js +55 -0
- package/.agents/scripts/lib/orchestration/epic-cleanup.js +289 -1
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes-ff.js +83 -30
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/branch-cleaner.js +8 -3
- package/.agents/scripts/lib/orchestration/story-close/baseline-attribution/phases/gate-failure.js +54 -6
- package/.agents/scripts/lib/orchestration/story-close/baseline-attribution/phases/regression-projection.js +35 -4
- package/.agents/scripts/lib/single-story-sweep/protection-ctx.js +75 -0
- package/.agents/scripts/lib/single-story-sweep.js +181 -54
- package/.agents/scripts/single-story-init.js +7 -51
- package/.agents/workflows/git-deliver.md +36 -0
- package/.agents/workflows/helpers/deliver-epic-reference.md +19 -13
- package/.agents/workflows/helpers/deliver-epic.md +25 -0
- package/.agents/workflows/helpers/deliver-stories.md +16 -3
- package/.agents/workflows/plan.md +32 -4
- package/docs/CHANGELOG.md +12 -0
- package/package.json +1 -1
|
@@ -120,6 +120,39 @@ rejected by `pre-push` hooks):
|
|
|
120
120
|
(`--no-errors-on-unmatched` or equivalent) before escalating via
|
|
121
121
|
`agent::blocked`.
|
|
122
122
|
|
|
123
|
+
## Local checkout hygiene
|
|
124
|
+
|
|
125
|
+
**Invariant: the delivering flow owns tidying the local checkout — reaping its
|
|
126
|
+
own merged refs and fast-forwarding the base branch. `/git-cleanup` is a
|
|
127
|
+
recovery tool, not a routine chore.**
|
|
128
|
+
|
|
129
|
+
Every flow that lands work — `/deliver` (Epic and standalone-Story paths),
|
|
130
|
+
`/git-deliver` — is responsible for leaving the local checkout tidy without
|
|
131
|
+
operator intervention:
|
|
132
|
+
|
|
133
|
+
- **Fast-forwarding the base branch is owned by the flow.** The standalone
|
|
134
|
+
multi-Story path fast-forwards `main` itself in its summary phase (via
|
|
135
|
+
`git-cleanup.js --fast-forward-main --execute --yes`); the Epic path
|
|
136
|
+
fast-forwards `epic/<id>` / `main` on its merge-and-reap beat. No workflow
|
|
137
|
+
ends by telling the operator to "run `/git-cleanup` afterwards to catch up".
|
|
138
|
+
- **Reaping merged local refs is owned by the flow's next boot.** `/plan` and
|
|
139
|
+
`/git-deliver` open with a **protected boot sweep**
|
|
140
|
+
(`boot-sweep.js`) that fast-forwards `main`, prunes stale remote-tracking
|
|
141
|
+
refs, and reaps every local branch whose PR is already merged — skipping any
|
|
142
|
+
candidate with unpushed work, a dirty worktree, or a still-open parent
|
|
143
|
+
ticket. A branch a flow leaves behind (e.g. a `/git-deliver` feature branch
|
|
144
|
+
whose PR merges out of band) is therefore reaped automatically at the next
|
|
145
|
+
workflow boot, not left for the operator to sweep by hand.
|
|
146
|
+
- **`/git-cleanup` is recovery, not routine.** Run it by hand only to recover
|
|
147
|
+
an unusual state the automated hygiene does not cover — triaging stashes,
|
|
148
|
+
reaping across non-standard branch namespaces, or `--remote` pruning after a
|
|
149
|
+
force-push diverged a tip. It is **not** the expected way to keep `main`
|
|
150
|
+
current or to clear merged branches after a normal delivery; the delivering
|
|
151
|
+
flows already own that. If you find yourself reaching for `/git-cleanup`
|
|
152
|
+
after every routine `/deliver` or `/git-deliver` run, that is a signal the
|
|
153
|
+
owning flow's hygiene step regressed — fix the flow, do not codify the manual
|
|
154
|
+
sweep.
|
|
155
|
+
|
|
123
156
|
## Meta Labels (Retrospective Signal Routing)
|
|
124
157
|
|
|
125
158
|
Two `meta::*` labels route retrospective signals into durable substrates so
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/* node:coverage ignore file */
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* boot-sweep.js — protected boot-sweep CLI (Story #4373).
|
|
6
|
+
*
|
|
7
|
+
* A thin, non-interactive wrapper over the scope-agnostic
|
|
8
|
+
* [`sweepMergedBranches`](./lib/single-story-sweep.js) engine, exposed so
|
|
9
|
+
* workflow prose can invoke a *protected* boot sweep directly. Unlike the
|
|
10
|
+
* plain `git-cleanup.js --branches` phase (which reaps every merged
|
|
11
|
+
* candidate the planner surfaces), this surface always applies the
|
|
12
|
+
* `evaluateProtection` partition — a merged branch with unpushed work, a
|
|
13
|
+
* dirty worktree, or a still-open parent Story ticket is skipped, not
|
|
14
|
+
* reaped.
|
|
15
|
+
*
|
|
16
|
+
* The sweep is best-effort: any failure (lock contention, git/gh error)
|
|
17
|
+
* is swallowed and reported in the result envelope, never thrown, so a
|
|
18
|
+
* caller can wire it into a boot path without risking the host run.
|
|
19
|
+
*
|
|
20
|
+
* Usage:
|
|
21
|
+
* node .agents/scripts/boot-sweep.js [--include <glob>...] \
|
|
22
|
+
* [--exclude <glob>...] [--current <branch>] [--base <branch>] \
|
|
23
|
+
* [--no-fast-forward] [--json]
|
|
24
|
+
*
|
|
25
|
+
* Defaults: `--include story-*`, fast-forward the base branch on.
|
|
26
|
+
* Exit code is always 0 — a boot sweep never fails its host.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import path from 'node:path';
|
|
30
|
+
import { parseArgs } from 'node:util';
|
|
31
|
+
|
|
32
|
+
import { runAsCli } from './lib/cli-utils.js';
|
|
33
|
+
import { PROJECT_ROOT, resolveConfig } from './lib/config-resolver.js';
|
|
34
|
+
import { Logger } from './lib/Logger.js';
|
|
35
|
+
import { createProvider } from './lib/provider-factory.js';
|
|
36
|
+
import { buildProtectionCtx } from './lib/single-story-sweep/protection-ctx.js';
|
|
37
|
+
import { sweepMergedBranches } from './lib/single-story-sweep.js';
|
|
38
|
+
|
|
39
|
+
const HELP = `Usage: node .agents/scripts/boot-sweep.js [options]
|
|
40
|
+
|
|
41
|
+
Runs the protected merged-branch boot sweep non-interactively: reaps every
|
|
42
|
+
local branch whose PR is MERGED and whose HEAD matches the merged headRefOid,
|
|
43
|
+
skipping any candidate the protection partition flags (unpushed work, dirty
|
|
44
|
+
worktree, still-open parent Story), then fast-forwards the base branch.
|
|
45
|
+
|
|
46
|
+
Options:
|
|
47
|
+
--include <glob> Branch glob to sweep (repeatable). Default: story-*
|
|
48
|
+
--exclude <glob> Branch glob to exclude (repeatable).
|
|
49
|
+
--current <branch> A branch to always exclude (e.g. the active story).
|
|
50
|
+
--base <branch> Base branch to fast-forward. Default: project baseBranch.
|
|
51
|
+
--no-fast-forward Skip the base-branch fast-forward step.
|
|
52
|
+
--json Emit the result envelope as JSON.
|
|
53
|
+
`;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Run the protected boot sweep. Best-effort: swallows any error and
|
|
57
|
+
* returns the sweep envelope so no caller can be blocked by a failure.
|
|
58
|
+
*
|
|
59
|
+
* DI-friendly: `injectedConfig` / `injectedProvider` let a caller (e.g.
|
|
60
|
+
* `epic-deliver-prepare.js`) reuse an already-resolved config + provider,
|
|
61
|
+
* and `injectedSweep` swaps the engine for unit tests.
|
|
62
|
+
*
|
|
63
|
+
* @param {{
|
|
64
|
+
* cwd?: string,
|
|
65
|
+
* base?: string,
|
|
66
|
+
* include?: string[],
|
|
67
|
+
* exclude?: string[],
|
|
68
|
+
* current?: string,
|
|
69
|
+
* fastForward?: boolean,
|
|
70
|
+
* injectedConfig?: object,
|
|
71
|
+
* injectedProvider?: object,
|
|
72
|
+
* injectedSweep?: Function,
|
|
73
|
+
* logger?: { info?: Function, warn?: Function },
|
|
74
|
+
* }} [args]
|
|
75
|
+
* @returns {Promise<object>} the {@link sweepMergedBranches} envelope.
|
|
76
|
+
*/
|
|
77
|
+
export async function runBootSweep({
|
|
78
|
+
cwd,
|
|
79
|
+
base,
|
|
80
|
+
include,
|
|
81
|
+
exclude,
|
|
82
|
+
current,
|
|
83
|
+
fastForward = true,
|
|
84
|
+
injectedConfig,
|
|
85
|
+
injectedProvider,
|
|
86
|
+
injectedSweep,
|
|
87
|
+
logger = Logger,
|
|
88
|
+
} = {}) {
|
|
89
|
+
const root = path.resolve(cwd ?? PROJECT_ROOT);
|
|
90
|
+
try {
|
|
91
|
+
// Config/provider resolution is inside the try so a malformed
|
|
92
|
+
// `.agentrc.json` (or a provider-construction throw) degrades to the
|
|
93
|
+
// swallowed `ok:false` envelope below rather than propagating and
|
|
94
|
+
// exiting non-zero — the "host continues, exit 0" boot-sweep contract
|
|
95
|
+
// must hold even when config resolution is the thing that fails.
|
|
96
|
+
const config = injectedConfig ?? resolveConfig({ cwd: root });
|
|
97
|
+
const provider = injectedProvider ?? createProvider(config);
|
|
98
|
+
const baseBranch = base ?? config.project?.baseBranch ?? 'main';
|
|
99
|
+
|
|
100
|
+
const includeGlobs =
|
|
101
|
+
Array.isArray(include) && include.length > 0 ? include : ['story-*'];
|
|
102
|
+
const excludeGlobs = Array.isArray(exclude) ? [...exclude] : [];
|
|
103
|
+
if (typeof current === 'string' && current.length > 0) {
|
|
104
|
+
excludeGlobs.push(current);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const tempRoot = config?.project?.paths?.tempRoot ?? 'temp';
|
|
108
|
+
const lockPath = path.resolve(root, tempRoot, 'boot-sweep.lock');
|
|
109
|
+
const lockTimeoutMs =
|
|
110
|
+
config.delivery?.worktreeIsolation?.sweepLockMs ?? 60_000;
|
|
111
|
+
|
|
112
|
+
const sweepFn = injectedSweep ?? sweepMergedBranches;
|
|
113
|
+
return await sweepFn({
|
|
114
|
+
cwd: root,
|
|
115
|
+
baseBranch,
|
|
116
|
+
include: includeGlobs,
|
|
117
|
+
exclude: excludeGlobs,
|
|
118
|
+
fastForward,
|
|
119
|
+
logTag: '[boot-sweep]',
|
|
120
|
+
logger: {
|
|
121
|
+
info: (m) => logger.info?.(m),
|
|
122
|
+
warn: (m) => logger.warn?.(m),
|
|
123
|
+
},
|
|
124
|
+
protectionCtx: buildProtectionCtx({ cwd: root, provider }),
|
|
125
|
+
lockPath,
|
|
126
|
+
lockTimeoutMs,
|
|
127
|
+
});
|
|
128
|
+
} catch (err) {
|
|
129
|
+
const msg = err?.message ?? String(err);
|
|
130
|
+
logger.warn?.(`[boot-sweep] sweep threw (host continues): ${msg}`);
|
|
131
|
+
return {
|
|
132
|
+
ok: false,
|
|
133
|
+
skipped: true,
|
|
134
|
+
error: msg,
|
|
135
|
+
candidates: 0,
|
|
136
|
+
localDeleted: 0,
|
|
137
|
+
remoteDeleted: 0,
|
|
138
|
+
protected: [],
|
|
139
|
+
failures: [],
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function main() {
|
|
145
|
+
const { values } = parseArgs({
|
|
146
|
+
options: {
|
|
147
|
+
base: { type: 'string' },
|
|
148
|
+
cwd: { type: 'string' },
|
|
149
|
+
include: { type: 'string', multiple: true, default: [] },
|
|
150
|
+
exclude: { type: 'string', multiple: true, default: [] },
|
|
151
|
+
current: { type: 'string' },
|
|
152
|
+
'no-fast-forward': { type: 'boolean', default: false },
|
|
153
|
+
json: { type: 'boolean', default: false },
|
|
154
|
+
help: { type: 'boolean', short: 'h' },
|
|
155
|
+
},
|
|
156
|
+
strict: false,
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
if (values.help) {
|
|
160
|
+
Logger.info(HELP);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const result = await runBootSweep({
|
|
165
|
+
cwd: typeof values.cwd === 'string' ? values.cwd : undefined,
|
|
166
|
+
base: typeof values.base === 'string' ? values.base : undefined,
|
|
167
|
+
include: Array.isArray(values.include) ? values.include : [],
|
|
168
|
+
exclude: Array.isArray(values.exclude) ? values.exclude : [],
|
|
169
|
+
current: typeof values.current === 'string' ? values.current : undefined,
|
|
170
|
+
fastForward: values['no-fast-forward'] !== true,
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
if (values.json) {
|
|
174
|
+
Logger.info(JSON.stringify(result, null, 2));
|
|
175
|
+
} else {
|
|
176
|
+
const protectedCount = result.protected?.length ?? 0;
|
|
177
|
+
Logger.info(
|
|
178
|
+
`[boot-sweep] reaped ${result.localDeleted} local + ${result.remoteDeleted} remote; protected ${protectedCount}.`,
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
runAsCli(import.meta.url, main, { source: 'boot-sweep' });
|
|
@@ -36,6 +36,7 @@ import fs from 'node:fs';
|
|
|
36
36
|
import path from 'node:path';
|
|
37
37
|
import { parseArgs } from 'node:util';
|
|
38
38
|
|
|
39
|
+
import { runBootSweep } from './boot-sweep.js';
|
|
39
40
|
import { runAsCli } from './lib/cli-utils.js';
|
|
40
41
|
import { getPaths, getRunners, resolveConfig } from './lib/config-resolver.js';
|
|
41
42
|
import { currentBranch as gitCurrentBranch } from './lib/git-branch-lifecycle.js';
|
|
@@ -218,6 +219,49 @@ async function runPreflightGuardsForPrepare({
|
|
|
218
219
|
});
|
|
219
220
|
}
|
|
220
221
|
|
|
222
|
+
/**
|
|
223
|
+
* Route the Epic boot cleanup through the shared protected boot-sweep
|
|
224
|
+
* engine (Story #4373). Reaps merged, done `story-*` branches left over
|
|
225
|
+
* from prior runs — the protection partition skips any branch with
|
|
226
|
+
* unpushed work, a dirty worktree, or a still-open parent Story, so an
|
|
227
|
+
* in-flight Story is never touched. Fast-forward is off: the prepare may
|
|
228
|
+
* run on the Epic branch, and the fast-forward phase would otherwise
|
|
229
|
+
* check out the base branch.
|
|
230
|
+
*
|
|
231
|
+
* Best-effort — a sweep failure (lock contention, git/gh error) is
|
|
232
|
+
* swallowed and never blocks or fails the prepare. Skipped in the same
|
|
233
|
+
* injected-test shape the preflight guards use (a provider injected with
|
|
234
|
+
* no git seam) so unit tests never spawn real git/gh.
|
|
235
|
+
*/
|
|
236
|
+
async function runBootSweepForPrepare({
|
|
237
|
+
cwd,
|
|
238
|
+
config,
|
|
239
|
+
provider,
|
|
240
|
+
injectedProvider,
|
|
241
|
+
injectedGit,
|
|
242
|
+
injectedSweep,
|
|
243
|
+
skipPreflightGuards,
|
|
244
|
+
}) {
|
|
245
|
+
const suppressed =
|
|
246
|
+
skipPreflightGuards || (Boolean(injectedProvider) && !injectedGit);
|
|
247
|
+
if (suppressed) return;
|
|
248
|
+
try {
|
|
249
|
+
await runBootSweep({
|
|
250
|
+
cwd,
|
|
251
|
+
include: ['story-*'],
|
|
252
|
+
fastForward: false,
|
|
253
|
+
injectedConfig: config,
|
|
254
|
+
injectedProvider: provider,
|
|
255
|
+
injectedSweep,
|
|
256
|
+
logger: Logger,
|
|
257
|
+
});
|
|
258
|
+
} catch (err) {
|
|
259
|
+
Logger.warn(
|
|
260
|
+
`[epic-deliver-prepare] ⚠️ boot sweep threw (prepare continues): ${err?.message ?? err}`,
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
221
265
|
/**
|
|
222
266
|
* Resolve the Epic state, preferring the preflight cache (Story #3027) and
|
|
223
267
|
* falling back to a fresh snapshot + wave-DAG pass on miss or baseSha
|
|
@@ -333,6 +377,7 @@ export async function runEpicDeliverPrepare({
|
|
|
333
377
|
steal = false,
|
|
334
378
|
asOperator,
|
|
335
379
|
injectedGit,
|
|
380
|
+
injectedSweep,
|
|
336
381
|
leaseHeartbeatAt,
|
|
337
382
|
leaseNow,
|
|
338
383
|
skipPreflightGuards = false,
|
|
@@ -365,6 +410,16 @@ export async function runEpicDeliverPrepare({
|
|
|
365
410
|
skipPreflightGuards,
|
|
366
411
|
});
|
|
367
412
|
|
|
413
|
+
await runBootSweepForPrepare({
|
|
414
|
+
cwd,
|
|
415
|
+
config,
|
|
416
|
+
provider,
|
|
417
|
+
injectedProvider,
|
|
418
|
+
injectedGit,
|
|
419
|
+
injectedSweep,
|
|
420
|
+
skipPreflightGuards,
|
|
421
|
+
});
|
|
422
|
+
|
|
368
423
|
const { state, cacheStatus } = await resolvePrepareState({
|
|
369
424
|
epicId,
|
|
370
425
|
cwd,
|
|
@@ -29,8 +29,12 @@
|
|
|
29
29
|
*/
|
|
30
30
|
|
|
31
31
|
import { spawnSync } from 'node:child_process';
|
|
32
|
-
|
|
33
32
|
import { parseWorktreePorcelain } from '../worktree/inspector.js';
|
|
33
|
+
import {
|
|
34
|
+
executeFastForward,
|
|
35
|
+
planFastForward,
|
|
36
|
+
} from './git-cleanup/phases/fast-forward.js';
|
|
37
|
+
import { makeFfProbes } from './git-cleanup/phases/git-probes-ff.js';
|
|
34
38
|
|
|
35
39
|
const WT_SCRATCH_BRANCH = 'wt-branch';
|
|
36
40
|
|
|
@@ -328,6 +332,51 @@ export function deleteWtBranchIfPresent(opts) {
|
|
|
328
332
|
return { deleted: false, present: true, stderr };
|
|
329
333
|
}
|
|
330
334
|
|
|
335
|
+
/**
|
|
336
|
+
* Fast-forward the base branch to its remote after a confirmed merge, so a
|
|
337
|
+
* post-`/deliver` local checkout converges to `origin/<baseBranch>` without a
|
|
338
|
+
* manual `git pull`. Reuses `planFastForward` + `executeFastForward` from the
|
|
339
|
+
* git-cleanup phase library (the single source of the FF state machine),
|
|
340
|
+
* feeding them probes bound to the injected `gitSpawn`.
|
|
341
|
+
*
|
|
342
|
+
* @param {{
|
|
343
|
+
* cwd: string,
|
|
344
|
+
* baseBranch?: string,
|
|
345
|
+
* remoteName?: string,
|
|
346
|
+
* gitSpawn: (cwd: string, ...args: string[]) => { status: number, stdout: string, stderr: string },
|
|
347
|
+
* logger?: { info?: Function, warn?: Function },
|
|
348
|
+
* }} opts
|
|
349
|
+
* @returns {{ ok: boolean, applied: boolean, skipped: boolean, reason?: string, behind?: number, stderr?: string }}
|
|
350
|
+
*/
|
|
351
|
+
export function fastForwardBaseBranch(opts) {
|
|
352
|
+
const {
|
|
353
|
+
cwd,
|
|
354
|
+
baseBranch = 'main',
|
|
355
|
+
remoteName = 'origin',
|
|
356
|
+
gitSpawn,
|
|
357
|
+
logger,
|
|
358
|
+
} = opts;
|
|
359
|
+
const probe = makeFfProbes(gitSpawn);
|
|
360
|
+
const plan = planFastForward({
|
|
361
|
+
cwd,
|
|
362
|
+
baseBranch,
|
|
363
|
+
remoteName,
|
|
364
|
+
isCleanFn: probe.isClean,
|
|
365
|
+
currentBranchFn: probe.currentBranch,
|
|
366
|
+
fetchFn: probe.fetch,
|
|
367
|
+
canFastForwardFn: probe.canFastForward,
|
|
368
|
+
});
|
|
369
|
+
return executeFastForward({
|
|
370
|
+
cwd,
|
|
371
|
+
baseBranch,
|
|
372
|
+
remoteName,
|
|
373
|
+
plan,
|
|
374
|
+
checkoutFn: probe.checkout,
|
|
375
|
+
mergeFn: probe.merge,
|
|
376
|
+
...(logger ? { logger } : {}),
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
|
|
331
380
|
/**
|
|
332
381
|
* Reap every branch owned by the Epic. Best-effort — failures aggregate into
|
|
333
382
|
* the result rather than throwing.
|
|
@@ -350,6 +399,7 @@ export function deleteWtBranchIfPresent(opts) {
|
|
|
350
399
|
* switched: { switched: boolean, from: string|null, to: string|null, stderr?: string } | null,
|
|
351
400
|
* pruned: { pruned: string[], stderr?: string } | null,
|
|
352
401
|
* wtBranch: { deleted: boolean, present: boolean, reason?: string, stderr?: string } | null,
|
|
402
|
+
* fastForward: { ok: boolean, applied: boolean, skipped: boolean, reason?: string, behind?: number, stderr?: string } | null,
|
|
353
403
|
* epicBranchKept: boolean,
|
|
354
404
|
* ok: boolean,
|
|
355
405
|
* }}
|
|
@@ -375,6 +425,7 @@ export function reapEpicBranches(opts) {
|
|
|
375
425
|
switched: null,
|
|
376
426
|
pruned: null,
|
|
377
427
|
wtBranch: null,
|
|
428
|
+
fastForward: null,
|
|
378
429
|
epicBranchKept: false,
|
|
379
430
|
ok: true,
|
|
380
431
|
};
|
|
@@ -463,6 +514,26 @@ export function reapEpicBranches(opts) {
|
|
|
463
514
|
logger?.info?.(`[epic-cleanup] deleted stale ${WT_SCRATCH_BRANCH} ref`);
|
|
464
515
|
}
|
|
465
516
|
|
|
517
|
+
// After a confirmed merge (epic branch NOT kept), fast-forward the base
|
|
518
|
+
// branch so the local checkout converges to `origin/<baseBranch>` with no
|
|
519
|
+
// manual `git pull`. When the epic branch is kept (open PR), the merge is
|
|
520
|
+
// not confirmed, so the FF is skipped with an explicit reason rather than
|
|
521
|
+
// moving `main` under an in-flight PR.
|
|
522
|
+
const fastForward = epicHasOpenPr
|
|
523
|
+
? { ok: true, applied: false, skipped: true, reason: 'epic-branch-kept' }
|
|
524
|
+
: fastForwardBaseBranch({
|
|
525
|
+
cwd,
|
|
526
|
+
baseBranch,
|
|
527
|
+
remoteName: remote,
|
|
528
|
+
gitSpawn,
|
|
529
|
+
logger,
|
|
530
|
+
});
|
|
531
|
+
if (fastForward.applied) {
|
|
532
|
+
logger?.info?.(
|
|
533
|
+
`[epic-cleanup] fast-forwarded ${baseBranch} by ${fastForward.behind} commit(s) to ${remote}/${baseBranch}`,
|
|
534
|
+
);
|
|
535
|
+
}
|
|
536
|
+
|
|
466
537
|
const failures = reaped.filter((r) => !r.branchDeleted);
|
|
467
538
|
return {
|
|
468
539
|
epicId: state?.epicId ?? null,
|
|
@@ -471,7 +542,224 @@ export function reapEpicBranches(opts) {
|
|
|
471
542
|
switched,
|
|
472
543
|
pruned,
|
|
473
544
|
wtBranch,
|
|
545
|
+
fastForward,
|
|
474
546
|
epicBranchKept: epicHasOpenPr,
|
|
475
547
|
ok: failures.length === 0,
|
|
476
548
|
};
|
|
477
549
|
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* Does a local branch head ref exist? Thin `git rev-parse --verify` wrapper
|
|
553
|
+
* exported for the resume-detect path (and its tests).
|
|
554
|
+
*
|
|
555
|
+
* @param {{ cwd: string, gitSpawn: Function, branch: string }} opts
|
|
556
|
+
* @returns {boolean}
|
|
557
|
+
*/
|
|
558
|
+
export function localRefExists({ cwd, gitSpawn, branch }) {
|
|
559
|
+
const res = gitSpawn(
|
|
560
|
+
cwd,
|
|
561
|
+
'rev-parse',
|
|
562
|
+
'--verify',
|
|
563
|
+
'--quiet',
|
|
564
|
+
`refs/heads/${branch}`,
|
|
565
|
+
);
|
|
566
|
+
return res.status === 0;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* Probe whether the Epic branch's PR is MERGED and, if so, its URL (needed
|
|
571
|
+
* for the `epic.merge.armed` payload). Fails CLOSED to
|
|
572
|
+
* `{ merged: false, prUrl: null }` on any probe error — an indeterminate
|
|
573
|
+
* probe must never auto-arm a destructive reap.
|
|
574
|
+
*
|
|
575
|
+
* @param {{
|
|
576
|
+
* epicBranch: string,
|
|
577
|
+
* cwd: string,
|
|
578
|
+
* spawnFn?: typeof spawnSync,
|
|
579
|
+
* logger?: { warn?: Function },
|
|
580
|
+
* }} opts
|
|
581
|
+
* @returns {{ merged: boolean, prUrl: string|null }}
|
|
582
|
+
*/
|
|
583
|
+
export function epicPrMergeState(opts) {
|
|
584
|
+
const { epicBranch, cwd, spawnFn = spawnSync, logger } = opts;
|
|
585
|
+
if (typeof epicBranch !== 'string' || epicBranch.length === 0) {
|
|
586
|
+
return { merged: false, prUrl: null };
|
|
587
|
+
}
|
|
588
|
+
let result;
|
|
589
|
+
try {
|
|
590
|
+
result = spawnFn(
|
|
591
|
+
'gh',
|
|
592
|
+
[
|
|
593
|
+
'pr',
|
|
594
|
+
'list',
|
|
595
|
+
'--head',
|
|
596
|
+
epicBranch,
|
|
597
|
+
'--state',
|
|
598
|
+
'merged',
|
|
599
|
+
'--json',
|
|
600
|
+
'number,url,mergedAt',
|
|
601
|
+
'--limit',
|
|
602
|
+
'1',
|
|
603
|
+
],
|
|
604
|
+
{ cwd, encoding: 'utf-8', shell: false },
|
|
605
|
+
);
|
|
606
|
+
} catch (err) {
|
|
607
|
+
logger?.warn?.(
|
|
608
|
+
`[epic-cleanup] merged-PR probe threw for ${epicBranch} (treating as unmerged): ${err?.message ?? err}`,
|
|
609
|
+
);
|
|
610
|
+
return { merged: false, prUrl: null };
|
|
611
|
+
}
|
|
612
|
+
if (!result || result.status !== 0) {
|
|
613
|
+
logger?.warn?.(
|
|
614
|
+
`[epic-cleanup] merged-PR probe failed for ${epicBranch} (status=${result?.status}): ${(result?.stderr ?? '').trim()}`,
|
|
615
|
+
);
|
|
616
|
+
return { merged: false, prUrl: null };
|
|
617
|
+
}
|
|
618
|
+
let parsed;
|
|
619
|
+
try {
|
|
620
|
+
parsed = JSON.parse(String(result.stdout ?? '').trim() || '[]');
|
|
621
|
+
} catch {
|
|
622
|
+
return { merged: false, prUrl: null };
|
|
623
|
+
}
|
|
624
|
+
if (!Array.isArray(parsed) || parsed.length === 0) {
|
|
625
|
+
return { merged: false, prUrl: null };
|
|
626
|
+
}
|
|
627
|
+
const row = parsed[0];
|
|
628
|
+
const prUrl =
|
|
629
|
+
typeof row?.url === 'string' && row.url.length > 0 ? row.url : null;
|
|
630
|
+
// A merged-state row with no usable URL cannot arm (the schema requires a
|
|
631
|
+
// `prUrl`), so treat it as not-armable.
|
|
632
|
+
return { merged: prUrl !== null, prUrl };
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* Detect a merged-but-uncleaned Epic: the PR merged but one or more local
|
|
637
|
+
* `epic/<id>` / `story-<id>` refs still linger. This is the signal `/deliver`
|
|
638
|
+
* idempotent resume uses to auto-fire `epic.merge.armed` so Phase 9 reaps
|
|
639
|
+
* without a manual command. Pure given its injected ports.
|
|
640
|
+
*
|
|
641
|
+
* @param {{
|
|
642
|
+
* state: object|null,
|
|
643
|
+
* cwd: string,
|
|
644
|
+
* gitSpawn: Function,
|
|
645
|
+
* spawnFn?: Function,
|
|
646
|
+
* prMergeStateFn?: typeof epicPrMergeState,
|
|
647
|
+
* logger?: { warn?: Function, info?: Function },
|
|
648
|
+
* }} opts
|
|
649
|
+
* @returns {{
|
|
650
|
+
* epicId: number|null,
|
|
651
|
+
* epicBranch: string|null,
|
|
652
|
+
* presentRefs: string[],
|
|
653
|
+
* localRefsPresent: boolean,
|
|
654
|
+
* merged: boolean,
|
|
655
|
+
* prUrl: string|null,
|
|
656
|
+
* shouldArm: boolean,
|
|
657
|
+
* reason: string,
|
|
658
|
+
* }}
|
|
659
|
+
*/
|
|
660
|
+
export function detectMergedUncleanedEpic(opts) {
|
|
661
|
+
const {
|
|
662
|
+
state,
|
|
663
|
+
cwd,
|
|
664
|
+
gitSpawn,
|
|
665
|
+
spawnFn,
|
|
666
|
+
prMergeStateFn = epicPrMergeState,
|
|
667
|
+
logger,
|
|
668
|
+
} = opts;
|
|
669
|
+
const { epicBranch, storyBranches } = listEpicBranchesFromState(state);
|
|
670
|
+
if (!epicBranch) {
|
|
671
|
+
return {
|
|
672
|
+
epicId: null,
|
|
673
|
+
epicBranch: null,
|
|
674
|
+
presentRefs: [],
|
|
675
|
+
localRefsPresent: false,
|
|
676
|
+
merged: false,
|
|
677
|
+
prUrl: null,
|
|
678
|
+
shouldArm: false,
|
|
679
|
+
reason: 'no-state',
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
const presentRefs = [epicBranch, ...storyBranches].filter((branch) =>
|
|
683
|
+
localRefExists({ cwd, gitSpawn, branch }),
|
|
684
|
+
);
|
|
685
|
+
if (presentRefs.length === 0) {
|
|
686
|
+
// Already clean — nothing to arm. This is the idempotent no-op that
|
|
687
|
+
// makes re-running `/deliver` on an already-reaped Epic safe.
|
|
688
|
+
return {
|
|
689
|
+
epicId: state.epicId,
|
|
690
|
+
epicBranch,
|
|
691
|
+
presentRefs,
|
|
692
|
+
localRefsPresent: false,
|
|
693
|
+
merged: false,
|
|
694
|
+
prUrl: null,
|
|
695
|
+
shouldArm: false,
|
|
696
|
+
reason: 'no-local-refs',
|
|
697
|
+
};
|
|
698
|
+
}
|
|
699
|
+
const { merged, prUrl } = prMergeStateFn({
|
|
700
|
+
epicBranch,
|
|
701
|
+
cwd,
|
|
702
|
+
spawnFn,
|
|
703
|
+
logger,
|
|
704
|
+
});
|
|
705
|
+
const shouldArm = merged && prUrl !== null;
|
|
706
|
+
return {
|
|
707
|
+
epicId: state.epicId,
|
|
708
|
+
epicBranch,
|
|
709
|
+
presentRefs,
|
|
710
|
+
localRefsPresent: true,
|
|
711
|
+
merged,
|
|
712
|
+
prUrl,
|
|
713
|
+
shouldArm,
|
|
714
|
+
reason: shouldArm ? 'merged-uncleaned' : 'not-merged',
|
|
715
|
+
};
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
/**
|
|
719
|
+
* `/deliver` idempotent-resume auto-arm: detect a merged-but-uncleaned Epic
|
|
720
|
+
* and, when found, fire `epic.merge.armed` on the injected lifecycle `bus` so
|
|
721
|
+
* the Cleaner → BranchCleaner chain reaps Phase 9 without an operator command.
|
|
722
|
+
* A no-op (and never throws on a clean/unmerged Epic) so re-running resume is
|
|
723
|
+
* safe.
|
|
724
|
+
*
|
|
725
|
+
* @param {{
|
|
726
|
+
* state: object|null,
|
|
727
|
+
* cwd: string,
|
|
728
|
+
* gitSpawn: Function,
|
|
729
|
+
* spawnFn?: Function,
|
|
730
|
+
* bus: { emit: (event: string, payload: object) => Promise<unknown> },
|
|
731
|
+
* detectFn?: typeof detectMergedUncleanedEpic,
|
|
732
|
+
* logger?: { warn?: Function, info?: Function },
|
|
733
|
+
* }} opts
|
|
734
|
+
* @returns {Promise<{ armed: boolean, reason: string, prUrl: string|null, detection: object }>}
|
|
735
|
+
*/
|
|
736
|
+
export async function armCleanupIfMerged(opts) {
|
|
737
|
+
const { state, cwd, gitSpawn, spawnFn, bus, detectFn, logger } = opts;
|
|
738
|
+
if (!bus || typeof bus.emit !== 'function') {
|
|
739
|
+
throw new TypeError('armCleanupIfMerged requires a bus exposing emit()');
|
|
740
|
+
}
|
|
741
|
+
const detect = detectFn ?? detectMergedUncleanedEpic;
|
|
742
|
+
const detection = detect({ state, cwd, gitSpawn, spawnFn, logger });
|
|
743
|
+
if (!detection.shouldArm) {
|
|
744
|
+
return {
|
|
745
|
+
armed: false,
|
|
746
|
+
reason: detection.reason,
|
|
747
|
+
prUrl: detection.prUrl,
|
|
748
|
+
detection,
|
|
749
|
+
};
|
|
750
|
+
}
|
|
751
|
+
const payload = { prUrl: detection.prUrl };
|
|
752
|
+
if (Number.isInteger(detection.epicId) && detection.epicId > 0) {
|
|
753
|
+
payload.epicId = detection.epicId;
|
|
754
|
+
}
|
|
755
|
+
await bus.emit('epic.merge.armed', payload);
|
|
756
|
+
logger?.info?.(
|
|
757
|
+
`[epic-cleanup] resume auto-arm: fired epic.merge.armed for ${detection.epicBranch} (${detection.prUrl})`,
|
|
758
|
+
);
|
|
759
|
+
return {
|
|
760
|
+
armed: true,
|
|
761
|
+
reason: 'merged-uncleaned',
|
|
762
|
+
prUrl: detection.prUrl,
|
|
763
|
+
detection,
|
|
764
|
+
};
|
|
765
|
+
}
|