mandrel 1.85.0 → 1.87.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/instructions.md +7 -0
- package/.agents/rules/git-conventions.md +45 -0
- package/.agents/scripts/boot-sweep.js +215 -0
- package/.agents/scripts/epic-deliver-prepare.js +55 -0
- package/.agents/scripts/git-cleanup.js +8 -0
- package/.agents/scripts/lib/checks/subagent-agent-tool-required.js +107 -30
- package/.agents/scripts/lib/epic-plan-ideation.js +24 -3
- package/.agents/scripts/lib/framework-version.js +210 -0
- package/.agents/scripts/lib/orchestration/context-hydration-engine.js +7 -22
- package/.agents/scripts/lib/orchestration/epic-cleanup.js +330 -6
- package/.agents/scripts/lib/orchestration/epic-spec-reconciler-diff.js +34 -3
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/branches.js +102 -7
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes-ff.js +83 -30
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes.js +85 -1
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/phase-drivers.js +34 -3
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +71 -4
- 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 +239 -57
- package/.agents/scripts/lib/story-body/story-body.js +81 -4
- package/.agents/scripts/providers/github/tickets.js +18 -1
- package/.agents/scripts/single-story-init.js +7 -51
- package/.agents/skills/core/epic-plan-consolidate/SKILL.md +7 -2
- package/.agents/skills/core/epic-plan-premortem/SKILL.md +8 -2
- package/.agents/skills/skills.index.json +3 -3
- package/.agents/skills/stack/architecture/subagent-orchestration/SKILL.md +36 -8
- package/.agents/workflows/git-cleanup.md +72 -18
- package/.agents/workflows/git-deliver.md +36 -0
- package/.agents/workflows/helpers/acceptance-self-eval.md +23 -1
- package/.agents/workflows/helpers/deliver-epic-reference.md +19 -13
- package/.agents/workflows/helpers/deliver-epic.md +47 -3
- package/.agents/workflows/helpers/deliver-stories.md +16 -3
- package/.agents/workflows/helpers/epic-audit.md +60 -2
- package/.agents/workflows/helpers/parallel-tooling.md +9 -2
- package/.agents/workflows/helpers/plan-epic.md +32 -14
- package/.agents/workflows/loops/nightly-audit.md +9 -1
- package/.agents/workflows/plan.md +32 -4
- package/docs/CHANGELOG.md +27 -0
- package/package.json +1 -1
|
@@ -81,6 +81,10 @@
|
|
|
81
81
|
*/
|
|
82
82
|
|
|
83
83
|
import { composeStoryBody } from '../../providers/github/tickets.js';
|
|
84
|
+
import {
|
|
85
|
+
extractFrameworkStamp,
|
|
86
|
+
stampFrameworkVersion,
|
|
87
|
+
} from '../framework-version.js';
|
|
84
88
|
import { assertPlanLabelAllowList } from './epic-spec-reconciler-discriminator.js';
|
|
85
89
|
import {
|
|
86
90
|
closeOp,
|
|
@@ -233,12 +237,21 @@ function stripFooter(body) {
|
|
|
233
237
|
* `composeStoryBody` directly makes that divergence structurally
|
|
234
238
|
* impossible going forward.
|
|
235
239
|
*
|
|
240
|
+
* Story #4382 — the create-time authoring stamp (`mandrel_version` /
|
|
241
|
+
* `authored_at` meta field + visible marker) is provenance, not spec-derived
|
|
242
|
+
* content, so this UPDATE path never mints or bumps it (which would also break
|
|
243
|
+
* this function's purity — the stamp uses a clock). Instead the stamp already
|
|
244
|
+
* present on the **live** GH body (`obsBody`) is preserved verbatim, and a
|
|
245
|
+
* legacy stamp-less body is left stamp-less (no backfill — see the Story's
|
|
246
|
+
* Out-of-Scope). Footer recomposition therefore runs in `stamp: false` mode.
|
|
247
|
+
*
|
|
236
248
|
* @param {{entity: string, parentSlug?: string|null, dependsOn?: string[]}} specEntity
|
|
237
249
|
* @param {string} specBody
|
|
238
250
|
* @param {{state?: StateInput}} ctx
|
|
251
|
+
* @param {string} [obsBody] - The live GH body, source of the preserved stamp.
|
|
239
252
|
* @returns {string}
|
|
240
253
|
*/
|
|
241
|
-
function composeBodyWithFooter(specEntity, specBody, ctx) {
|
|
254
|
+
function composeBodyWithFooter(specEntity, specBody, ctx, obsBody = '') {
|
|
242
255
|
const state = ctx?.state ?? {};
|
|
243
256
|
const mapping = state.mapping ?? {};
|
|
244
257
|
const parentSlug = specEntity.parentSlug ?? null;
|
|
@@ -264,7 +277,25 @@ function composeBodyWithFooter(specEntity, specBody, ctx) {
|
|
|
264
277
|
// included) or emits a canonical-form body. With the strip, the
|
|
265
278
|
// function is idempotent against its own output.
|
|
266
279
|
const head = stripFooter(specBody);
|
|
267
|
-
|
|
280
|
+
// Preserve the live body's create-time authoring stamp (Story #4382). When
|
|
281
|
+
// the spec head already carries it, `stampFrameworkVersion` is a no-op
|
|
282
|
+
// (immutable); when it doesn't, the stamp is restored from the live body so
|
|
283
|
+
// the recomposed form matches GH and no spurious body Update fires. A
|
|
284
|
+
// legacy body with no stamp stays stamp-less (no backfill).
|
|
285
|
+
const priorStamp = extractFrameworkStamp(obsBody);
|
|
286
|
+
const stampedHead = priorStamp
|
|
287
|
+
? stampFrameworkVersion(head, {
|
|
288
|
+
version: priorStamp.version,
|
|
289
|
+
authoredAt: priorStamp.authoredAt,
|
|
290
|
+
})
|
|
291
|
+
: head;
|
|
292
|
+
return composeStoryBody({
|
|
293
|
+
body: stampedHead,
|
|
294
|
+
parentId,
|
|
295
|
+
epicId,
|
|
296
|
+
dependencies,
|
|
297
|
+
stamp: false,
|
|
298
|
+
});
|
|
268
299
|
}
|
|
269
300
|
|
|
270
301
|
/**
|
|
@@ -329,7 +360,7 @@ function fieldChanges(specEntity, obs, mapping, ctx = {}) {
|
|
|
329
360
|
// Emit a body change only when the canonical form differs from
|
|
330
361
|
// what is on GH today — and write the canonical form back, so the
|
|
331
362
|
// footer cascade-readers depend on stays intact across resumes.
|
|
332
|
-
const after = composeBodyWithFooter(specEntity, specBody, ctx);
|
|
363
|
+
const after = composeBodyWithFooter(specEntity, specBody, ctx, obsBody);
|
|
333
364
|
if (after !== obsBody) {
|
|
334
365
|
changes.body = { before: obsBody, after };
|
|
335
366
|
}
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
} from './branches-reap.js';
|
|
22
22
|
import { computeProtectedReason } from './filters.js';
|
|
23
23
|
import {
|
|
24
|
+
branchLastCommitAt,
|
|
24
25
|
branchTipSha,
|
|
25
26
|
classifyLatestPr,
|
|
26
27
|
currentBranch as defaultCurrentBranch,
|
|
@@ -28,14 +29,18 @@ import {
|
|
|
28
29
|
listMergedBranches,
|
|
29
30
|
listRemoteBranches,
|
|
30
31
|
probeAllPrs,
|
|
32
|
+
probeContentEquivalent,
|
|
31
33
|
probeLatestPr,
|
|
32
34
|
pruneRemoteTracking,
|
|
33
35
|
readProtectedConfig,
|
|
36
|
+
refExists,
|
|
34
37
|
removeWorktree,
|
|
35
38
|
worktreesByBranch,
|
|
36
39
|
} from './git-probes.js';
|
|
37
40
|
import { parsePrunedRefs } from './prune.js';
|
|
38
41
|
|
|
42
|
+
const TAG = '[git-cleanup]';
|
|
43
|
+
|
|
39
44
|
function skipEntryFromVerdict(branch, verdict) {
|
|
40
45
|
const entry = { branch, reason: verdict.reason };
|
|
41
46
|
if (verdict.prNumber != null) entry.prNumber = verdict.prNumber;
|
|
@@ -44,8 +49,37 @@ function skipEntryFromVerdict(branch, verdict) {
|
|
|
44
49
|
return entry;
|
|
45
50
|
}
|
|
46
51
|
|
|
52
|
+
/**
|
|
53
|
+
* Story #4395 — the third detection signal. Reached only when the branch
|
|
54
|
+
* has no reapable PR verdict and is not an ancestor of `<base>` (or
|
|
55
|
+
* `origin/<base>`). Probes content-equivalence via `git merge-tree
|
|
56
|
+
* --write-tree` and, when the probe is conclusive and the merge is a
|
|
57
|
+
* no-op, classifies the branch as `content-merged` instead of falling
|
|
58
|
+
* through to the `not-merged` skip.
|
|
59
|
+
*/
|
|
60
|
+
function evaluateContentEquivalence({
|
|
61
|
+
branch,
|
|
62
|
+
baseBranch,
|
|
63
|
+
cwd,
|
|
64
|
+
contentEquivalentFn,
|
|
65
|
+
branchLastCommitFn,
|
|
66
|
+
}) {
|
|
67
|
+
const verdict = contentEquivalentFn({ cwd, base: baseBranch, branch });
|
|
68
|
+
if (verdict?.supported && verdict.equivalent) {
|
|
69
|
+
return { detectedBy: 'content-merged' };
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
skip: {
|
|
73
|
+
branch,
|
|
74
|
+
reason: 'not-merged',
|
|
75
|
+
lastCommitAt: branchLastCommitFn(cwd, branch),
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
47
80
|
function evaluateLocalBranch({
|
|
48
81
|
branch,
|
|
82
|
+
baseBranch,
|
|
49
83
|
classify,
|
|
50
84
|
filter,
|
|
51
85
|
mergedByGit,
|
|
@@ -54,6 +88,8 @@ function evaluateLocalBranch({
|
|
|
54
88
|
wtMap,
|
|
55
89
|
remoteName,
|
|
56
90
|
branchTipShaFn,
|
|
91
|
+
contentEquivalentFn,
|
|
92
|
+
branchLastCommitFn,
|
|
57
93
|
}) {
|
|
58
94
|
const protectedReason = classify(branch);
|
|
59
95
|
if (protectedReason) return { skip: { branch, reason: protectedReason } };
|
|
@@ -78,7 +114,15 @@ function evaluateLocalBranch({
|
|
|
78
114
|
} else if (mergedByGit.has(branch)) {
|
|
79
115
|
detectedBy = 'git-merged';
|
|
80
116
|
} else {
|
|
81
|
-
|
|
117
|
+
const out = evaluateContentEquivalence({
|
|
118
|
+
branch,
|
|
119
|
+
baseBranch,
|
|
120
|
+
cwd,
|
|
121
|
+
contentEquivalentFn,
|
|
122
|
+
branchLastCommitFn,
|
|
123
|
+
});
|
|
124
|
+
if (out.skip) return out;
|
|
125
|
+
detectedBy = out.detectedBy;
|
|
82
126
|
}
|
|
83
127
|
const wt = wtMap.get(branch);
|
|
84
128
|
return {
|
|
@@ -156,7 +200,43 @@ function collectRemoteOnlyCandidates({
|
|
|
156
200
|
* the per-branch fallback for head refs absent from the bulk page (a PR
|
|
157
201
|
* that fell outside the fetch window), so correctness is preserved for
|
|
158
202
|
* every branch. Injecting `prProbe` bypasses the bulk fetch entirely.
|
|
203
|
+
*
|
|
204
|
+
* Story #4395 adds three refinements:
|
|
205
|
+
* - **Content-equivalence signal.** A local branch with no reapable PR
|
|
206
|
+
* verdict and no ancestry match gets one more chance via
|
|
207
|
+
* {@link probeContentEquivalent} (`git merge-tree --write-tree`):
|
|
208
|
+
* when merging it into `baseBranch` would be a content no-op, it is
|
|
209
|
+
* classified `detectedBy: 'content-merged'` instead of skipped.
|
|
210
|
+
* - **Fresh ancestry anchor.** The ancestry signal (`git branch --merged`)
|
|
211
|
+
* is unioned against `origin/<base>` (via `refExistsFn` + `mergedLister`)
|
|
212
|
+
* whenever that remote-tracking ref exists, so a stale local `<base>`
|
|
213
|
+
* no longer hides a branch already merged on the remote.
|
|
214
|
+
* - **Graceful `gh` degradation.** A throwing `gh` runner (auth failure,
|
|
215
|
+
* rate limit, missing binary) inside the bulk index fetch or the
|
|
216
|
+
* per-branch fallback is caught, logged once, and degrades to
|
|
217
|
+
* git-only signals (ancestry + content-equivalence) rather than
|
|
218
|
+
* aborting the whole plan. The returned envelope's `ghDegraded` flag
|
|
219
|
+
* records whether this happened.
|
|
159
220
|
*/
|
|
221
|
+
function buildGuardedPrProbe({ cwd, prIndexFn, prFallback, onDegrade }) {
|
|
222
|
+
let prIndex;
|
|
223
|
+
try {
|
|
224
|
+
prIndex = prIndexFn(cwd);
|
|
225
|
+
} catch (err) {
|
|
226
|
+
onDegrade(err);
|
|
227
|
+
prIndex = new Map();
|
|
228
|
+
}
|
|
229
|
+
return (branch, c) => {
|
|
230
|
+
if (prIndex.has(branch)) return prIndex.get(branch);
|
|
231
|
+
try {
|
|
232
|
+
return prFallback(branch, c);
|
|
233
|
+
} catch (err) {
|
|
234
|
+
onDegrade(err);
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
160
240
|
export function planCleanup(ctx) {
|
|
161
241
|
const {
|
|
162
242
|
cwd,
|
|
@@ -170,18 +250,26 @@ export function planCleanup(ctx) {
|
|
|
170
250
|
prIndexFn = probeAllPrs,
|
|
171
251
|
prFallback = probeLatestPr,
|
|
172
252
|
branchTipShaFn = branchTipSha,
|
|
253
|
+
contentEquivalentFn = probeContentEquivalent,
|
|
254
|
+
branchLastCommitFn = branchLastCommitAt,
|
|
255
|
+
refExistsFn = refExists,
|
|
173
256
|
filter = () => true,
|
|
174
257
|
includeRemoteOnly = false,
|
|
175
258
|
remoteLister = listRemoteBranches,
|
|
176
259
|
remoteName = 'origin',
|
|
260
|
+
logger = Logger,
|
|
177
261
|
} = ctx;
|
|
262
|
+
let ghDegraded = false;
|
|
263
|
+
const onDegrade = (err) => {
|
|
264
|
+
if (ghDegraded) return;
|
|
265
|
+
ghDegraded = true;
|
|
266
|
+
logger.warn?.(
|
|
267
|
+
`${TAG} ⚠️ gh probe failed (${err?.message ?? err}); continuing with git-only signals`,
|
|
268
|
+
);
|
|
269
|
+
};
|
|
178
270
|
const prProbe =
|
|
179
271
|
injectedPrProbe ??
|
|
180
|
-
(
|
|
181
|
-
const prIndex = prIndexFn(cwd);
|
|
182
|
-
return (branch, c) =>
|
|
183
|
-
prIndex.has(branch) ? prIndex.get(branch) : prFallback(branch, c);
|
|
184
|
-
})();
|
|
272
|
+
buildGuardedPrProbe({ cwd, prIndexFn, prFallback, onDegrade });
|
|
185
273
|
const resolvedCurrent = currentBranchFn(cwd);
|
|
186
274
|
const resolvedConfigured = protectedConfigFn(cwd);
|
|
187
275
|
const classify = (branch) =>
|
|
@@ -193,6 +281,10 @@ export function planCleanup(ctx) {
|
|
|
193
281
|
});
|
|
194
282
|
const wtMap = worktreesFn(cwd);
|
|
195
283
|
const mergedByGit = new Set(mergedLister(cwd, baseBranch));
|
|
284
|
+
const remoteBaseRef = `${remoteName}/${baseBranch}`;
|
|
285
|
+
if (refExistsFn(cwd, remoteBaseRef)) {
|
|
286
|
+
for (const b of mergedLister(cwd, remoteBaseRef)) mergedByGit.add(b);
|
|
287
|
+
}
|
|
196
288
|
const localBranches = localLister(cwd);
|
|
197
289
|
const localSet = new Set(localBranches);
|
|
198
290
|
const candidates = [];
|
|
@@ -200,6 +292,7 @@ export function planCleanup(ctx) {
|
|
|
200
292
|
for (const branch of localBranches) {
|
|
201
293
|
const out = evaluateLocalBranch({
|
|
202
294
|
branch,
|
|
295
|
+
baseBranch,
|
|
203
296
|
classify,
|
|
204
297
|
filter,
|
|
205
298
|
mergedByGit,
|
|
@@ -208,6 +301,8 @@ export function planCleanup(ctx) {
|
|
|
208
301
|
wtMap,
|
|
209
302
|
remoteName,
|
|
210
303
|
branchTipShaFn,
|
|
304
|
+
contentEquivalentFn,
|
|
305
|
+
branchLastCommitFn,
|
|
211
306
|
});
|
|
212
307
|
if (out.skip) skipped.push(out.skip);
|
|
213
308
|
else candidates.push(out.candidate);
|
|
@@ -227,7 +322,7 @@ export function planCleanup(ctx) {
|
|
|
227
322
|
}),
|
|
228
323
|
);
|
|
229
324
|
}
|
|
230
|
-
return { candidates, skipped };
|
|
325
|
+
return { candidates, skipped, ghDegraded };
|
|
231
326
|
}
|
|
232
327
|
|
|
233
328
|
/**
|
|
@@ -34,54 +34,107 @@ export function isWorktreeLockFailure(stderr) {
|
|
|
34
34
|
|
|
35
35
|
/* node:coverage ignore next */
|
|
36
36
|
export function isWorkingTreeClean(cwd) {
|
|
37
|
-
|
|
38
|
-
if (res.status !== 0) return false;
|
|
39
|
-
return res.stdout.trim() === '';
|
|
37
|
+
return defaultFfProbes.isClean(cwd);
|
|
40
38
|
}
|
|
41
39
|
|
|
42
40
|
/* node:coverage ignore next */
|
|
43
41
|
export function fetchRef(cwd, remoteName, ref) {
|
|
44
|
-
|
|
45
|
-
if (res.status !== 0) return { ok: false, stderr: res.stderr };
|
|
46
|
-
return { ok: true };
|
|
42
|
+
return defaultFfProbes.fetch(cwd, remoteName, ref);
|
|
47
43
|
}
|
|
48
44
|
|
|
49
45
|
/* node:coverage ignore next */
|
|
50
46
|
export function canFastForward(cwd, baseBranch, remoteName) {
|
|
51
|
-
|
|
52
|
-
const ahead = gitSpawn(
|
|
53
|
-
cwd,
|
|
54
|
-
'rev-list',
|
|
55
|
-
'--left-right',
|
|
56
|
-
'--count',
|
|
57
|
-
`${baseBranch}...${ref}`,
|
|
58
|
-
);
|
|
59
|
-
if (ahead.status !== 0) {
|
|
60
|
-
return { ok: false, behind: 0, reason: 'rev-list-failed' };
|
|
61
|
-
}
|
|
62
|
-
const parts = ahead.stdout.trim().split(/\s+/);
|
|
63
|
-
const localAhead = Number(parts[0]) || 0;
|
|
64
|
-
const remoteAhead = Number(parts[1]) || 0;
|
|
65
|
-
if (localAhead > 0) {
|
|
66
|
-
return { ok: false, behind: remoteAhead, reason: 'not-fast-forward' };
|
|
67
|
-
}
|
|
68
|
-
return { ok: true, behind: remoteAhead };
|
|
47
|
+
return defaultFfProbes.canFastForward(cwd, baseBranch, remoteName);
|
|
69
48
|
}
|
|
70
49
|
|
|
71
50
|
/* node:coverage ignore next */
|
|
72
51
|
export function checkoutBranch(cwd, branch) {
|
|
73
|
-
|
|
74
|
-
if (res.status !== 0) return { ok: false, stderr: res.stderr };
|
|
75
|
-
return { ok: true };
|
|
52
|
+
return defaultFfProbes.checkout(cwd, branch);
|
|
76
53
|
}
|
|
77
54
|
|
|
78
55
|
/* node:coverage ignore next */
|
|
79
56
|
export function mergeFastForward(cwd, ref) {
|
|
80
|
-
|
|
81
|
-
if (res.status !== 0) return { ok: false, stderr: res.stderr };
|
|
82
|
-
return { ok: true };
|
|
57
|
+
return defaultFfProbes.merge(cwd, ref);
|
|
83
58
|
}
|
|
84
59
|
|
|
60
|
+
/**
|
|
61
|
+
* Build the fast-forward probe bundle bound to a `gitSpawn`.
|
|
62
|
+
*
|
|
63
|
+
* This is the **single implementation** of the FF/base-sync git wrappers.
|
|
64
|
+
* The standalone exports above delegate to a default instance bound to the
|
|
65
|
+
* shared `gitSpawn`; callers that need to inject their own spawn for testing
|
|
66
|
+
* (e.g. the epic-cleanup runner) call this factory directly instead of
|
|
67
|
+
* hand-rolling a parallel copy (framework-gap #4379). The bundle also carries
|
|
68
|
+
* `currentBranch` so an injecting caller gets the whole FF surface from one
|
|
69
|
+
* place.
|
|
70
|
+
*
|
|
71
|
+
* @param {(cwd: string, ...args: string[]) => { status: number, stdout: string, stderr: string }} [spawn]
|
|
72
|
+
* @returns {{
|
|
73
|
+
* isClean: (cwd: string) => boolean,
|
|
74
|
+
* currentBranch: (cwd: string) => string|null,
|
|
75
|
+
* fetch: (cwd: string, remoteName: string, ref: string) => { ok: boolean, stderr?: string },
|
|
76
|
+
* canFastForward: (cwd: string, baseBranch: string, remoteName: string) => { ok: boolean, behind: number, reason?: string },
|
|
77
|
+
* checkout: (cwd: string, branch: string) => { ok: boolean, stderr?: string },
|
|
78
|
+
* merge: (cwd: string, ref: string) => { ok: boolean, stderr?: string },
|
|
79
|
+
* }}
|
|
80
|
+
*/
|
|
81
|
+
export function makeFfProbes(spawn = gitSpawn) {
|
|
82
|
+
return {
|
|
83
|
+
isClean: (cwd) => {
|
|
84
|
+
const res = spawn(cwd, 'status', '--porcelain');
|
|
85
|
+
return res.status === 0 && String(res.stdout ?? '').trim() === '';
|
|
86
|
+
},
|
|
87
|
+
currentBranch: (cwd) => {
|
|
88
|
+
const res = spawn(cwd, 'symbolic-ref', '--quiet', '--short', 'HEAD');
|
|
89
|
+
return res.status !== 0 ? null : String(res.stdout ?? '').trim() || null;
|
|
90
|
+
},
|
|
91
|
+
fetch: (cwd, remoteName, ref) => {
|
|
92
|
+
const res = spawn(cwd, 'fetch', '--quiet', remoteName, ref);
|
|
93
|
+
return res.status === 0
|
|
94
|
+
? { ok: true }
|
|
95
|
+
: { ok: false, stderr: res.stderr };
|
|
96
|
+
},
|
|
97
|
+
canFastForward: (cwd, baseBranch, remoteName) => {
|
|
98
|
+
const ref = `${remoteName}/${baseBranch}`;
|
|
99
|
+
const ahead = spawn(
|
|
100
|
+
cwd,
|
|
101
|
+
'rev-list',
|
|
102
|
+
'--left-right',
|
|
103
|
+
'--count',
|
|
104
|
+
`${baseBranch}...${ref}`,
|
|
105
|
+
);
|
|
106
|
+
if (ahead.status !== 0) {
|
|
107
|
+
return { ok: false, behind: 0, reason: 'rev-list-failed' };
|
|
108
|
+
}
|
|
109
|
+
const parts = String(ahead.stdout ?? '')
|
|
110
|
+
.trim()
|
|
111
|
+
.split(/\s+/);
|
|
112
|
+
const localAhead = Number(parts[0]) || 0;
|
|
113
|
+
const remoteAhead = Number(parts[1]) || 0;
|
|
114
|
+
if (localAhead > 0) {
|
|
115
|
+
return { ok: false, behind: remoteAhead, reason: 'not-fast-forward' };
|
|
116
|
+
}
|
|
117
|
+
return { ok: true, behind: remoteAhead };
|
|
118
|
+
},
|
|
119
|
+
checkout: (cwd, branch) => {
|
|
120
|
+
const res = spawn(cwd, 'checkout', branch);
|
|
121
|
+
return res.status === 0
|
|
122
|
+
? { ok: true }
|
|
123
|
+
: { ok: false, stderr: res.stderr };
|
|
124
|
+
},
|
|
125
|
+
merge: (cwd, ref) => {
|
|
126
|
+
const res = spawn(cwd, 'merge', '--ff-only', ref);
|
|
127
|
+
return res.status === 0
|
|
128
|
+
? { ok: true }
|
|
129
|
+
: { ok: false, stderr: res.stderr };
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Default instance bound to the shared gitSpawn; the standalone wrappers
|
|
135
|
+
// above delegate to it so there is exactly one FF-probe implementation.
|
|
136
|
+
const defaultFfProbes = makeFfProbes(gitSpawn);
|
|
137
|
+
|
|
85
138
|
/* node:coverage ignore next */
|
|
86
139
|
export function removeWorktree(worktreePath, cwd) {
|
|
87
140
|
const plain = gitSpawn(cwd, 'worktree', 'remove', worktreePath);
|
|
@@ -344,7 +344,91 @@ export function branchTipSha({
|
|
|
344
344
|
return res.status !== 0 ? null : validSha(firstLsRemoteSha(res.stdout));
|
|
345
345
|
}
|
|
346
346
|
|
|
347
|
-
|
|
347
|
+
/* node:coverage ignore next */
|
|
348
|
+
// Story #4395: ancestry-anchor freshness check. `planCleanup` calls this
|
|
349
|
+
// before unioning `git branch --merged origin/<base>` into the ancestry
|
|
350
|
+
// signal so a stale local `<base>` (fast-forward phase skipped or
|
|
351
|
+
// `--branches` run alone) doesn't hide a branch that's already merged on
|
|
352
|
+
// the remote.
|
|
353
|
+
export function refExists(cwd, ref) {
|
|
354
|
+
const res = gitSpawn(cwd, 'rev-parse', '--verify', '--quiet', ref);
|
|
355
|
+
return res.status === 0;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/* node:coverage ignore next */
|
|
359
|
+
// Story #4395: last-commit timestamp for the dry-run `not-merged`
|
|
360
|
+
// skip-visibility line (branch name + last-commit age).
|
|
361
|
+
export function branchLastCommitAt(cwd, branch) {
|
|
362
|
+
const res = gitSpawn(
|
|
363
|
+
cwd,
|
|
364
|
+
'log',
|
|
365
|
+
'-1',
|
|
366
|
+
'--format=%cI',
|
|
367
|
+
`refs/heads/${branch}`,
|
|
368
|
+
'--',
|
|
369
|
+
);
|
|
370
|
+
if (res.status !== 0) return null;
|
|
371
|
+
return res.stdout.trim() || null;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* First non-empty trimmed stdout line — the resulting tree OID on a clean
|
|
376
|
+
* `git merge-tree --write-tree` run.
|
|
377
|
+
*
|
|
378
|
+
* @param {string} stdout
|
|
379
|
+
* @returns {string}
|
|
380
|
+
*/
|
|
381
|
+
function firstStdoutLine(stdout) {
|
|
382
|
+
const first = (stdout ?? '')
|
|
383
|
+
.split('\n')
|
|
384
|
+
.map((l) => l.trim())
|
|
385
|
+
.find(Boolean);
|
|
386
|
+
return first ?? '';
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Probe content-equivalence between `base` and `branch` via
|
|
391
|
+
* `git merge-tree --write-tree <base> <branch>` (git >= 2.38, Story #4395).
|
|
392
|
+
*
|
|
393
|
+
* A clean merge (exit 0) whose resulting tree OID equals `<base>`'s own
|
|
394
|
+
* tree OID means applying `branch`'s changes on top of `base` is a no-op —
|
|
395
|
+
* `branch`'s content already lives in `base` by another route (a
|
|
396
|
+
* squash-merged Epic PR, a cherry-pick, a manual `merge --squash`) that
|
|
397
|
+
* neither the PR probe nor the ancestry check can see.
|
|
398
|
+
*
|
|
399
|
+
* Both the "unsupported" case (git < 2.38 rejects `--write-tree`) and the
|
|
400
|
+
* "real conflict" case (branch and base diverge and cannot auto-merge)
|
|
401
|
+
* surface as a non-zero exit. This probe treats them identically — the
|
|
402
|
+
* signal is inconclusive, so the caller keeps the branch's current
|
|
403
|
+
* `not-merged` classification rather than guessing.
|
|
404
|
+
*
|
|
405
|
+
* @param {{ cwd: string, base: string, branch: string, spawn?: typeof gitSpawn }} args
|
|
406
|
+
* @returns {{ supported: false } | { supported: true, equivalent: boolean }}
|
|
407
|
+
*/
|
|
408
|
+
export function probeContentEquivalent({
|
|
409
|
+
cwd,
|
|
410
|
+
base,
|
|
411
|
+
branch,
|
|
412
|
+
spawn = gitSpawn,
|
|
413
|
+
}) {
|
|
414
|
+
const merged = spawn(cwd, 'merge-tree', '--write-tree', base, branch);
|
|
415
|
+
if (merged.status !== 0) return { supported: false };
|
|
416
|
+
const mergedTree = validSha(firstStdoutLine(merged.stdout));
|
|
417
|
+
if (!mergedTree) return { supported: false };
|
|
418
|
+
const baseTreeRes = spawn(
|
|
419
|
+
cwd,
|
|
420
|
+
'rev-parse',
|
|
421
|
+
'--verify',
|
|
422
|
+
'--quiet',
|
|
423
|
+
`${base}^{tree}`,
|
|
424
|
+
);
|
|
425
|
+
if (baseTreeRes.status !== 0) return { supported: false };
|
|
426
|
+
const baseTree = validSha(baseTreeRes.stdout);
|
|
427
|
+
if (!baseTree) return { supported: false };
|
|
428
|
+
return { supported: true, equivalent: mergedTree === baseTree };
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
export const __testing = { validSha, firstLsRemoteSha, firstStdoutLine };
|
|
348
432
|
|
|
349
433
|
/**
|
|
350
434
|
* Pure-ish: classify a latest-PR probe row into a planner verdict.
|
|
@@ -190,6 +190,22 @@ export async function runPrunePhase(opts, cwd) {
|
|
|
190
190
|
// Branch phase
|
|
191
191
|
// =====================================================================
|
|
192
192
|
|
|
193
|
+
/**
|
|
194
|
+
* Count of a plan's *actionable* candidates — the ones `executeCleanup`
|
|
195
|
+
* will actually delete given the current `--remote` setting. Story #4395
|
|
196
|
+
* always enumerates remote-only candidates in `plan.candidates` (so the
|
|
197
|
+
* operator sees them in the dry-run list), but their deletion still
|
|
198
|
+
* requires `--remote`; without it, `executeCleanup` no-ops on every
|
|
199
|
+
* `localExists: false` candidate. Counting only the actionable subset
|
|
200
|
+
* keeps the "no-candidates" short-circuit and the confirmation prompt's
|
|
201
|
+
* "Reap N" count honest about what will actually happen.
|
|
202
|
+
*/
|
|
203
|
+
function countActionableCandidates(candidates, remote) {
|
|
204
|
+
return remote
|
|
205
|
+
? candidates.length
|
|
206
|
+
: candidates.filter((c) => c.localExists !== false).length;
|
|
207
|
+
}
|
|
208
|
+
|
|
193
209
|
/**
|
|
194
210
|
* Pure: decide what the branch-reap phase should do given the plan.
|
|
195
211
|
*
|
|
@@ -209,7 +225,11 @@ export function decideBranchPhase(state) {
|
|
|
209
225
|
if (opts.dryRun) {
|
|
210
226
|
return { kind: 'dry-run', plan, result: { plan, result: null } };
|
|
211
227
|
}
|
|
212
|
-
|
|
228
|
+
const actionableCount = countActionableCandidates(
|
|
229
|
+
plan.candidates,
|
|
230
|
+
opts.remote,
|
|
231
|
+
);
|
|
232
|
+
if (actionableCount === 0) {
|
|
213
233
|
return { kind: 'no-candidates', plan, result: { plan, result: null } };
|
|
214
234
|
}
|
|
215
235
|
const executeArgs = {
|
|
@@ -218,10 +238,17 @@ export function decideBranchPhase(state) {
|
|
|
218
238
|
remote: opts.remote,
|
|
219
239
|
};
|
|
220
240
|
if (!opts.yes) {
|
|
241
|
+
const contentMergedCount = plan.candidates.filter(
|
|
242
|
+
(c) => c.detectedBy === 'content-merged',
|
|
243
|
+
).length;
|
|
244
|
+
const weakSignalNote =
|
|
245
|
+
contentMergedCount > 0
|
|
246
|
+
? ` (${contentMergedCount} content-merged — weaker signal, verify before confirming)`
|
|
247
|
+
: '';
|
|
221
248
|
return {
|
|
222
249
|
kind: 'prompt-then-execute',
|
|
223
250
|
plan,
|
|
224
|
-
promptMessage: `${TAG} Reap ${
|
|
251
|
+
promptMessage: `${TAG} Reap ${actionableCount} merged branch(es)${opts.remote ? ' (including origin)' : ''}${weakSignalNote}?`,
|
|
225
252
|
declinedResult: { plan, result: null, declined: true },
|
|
226
253
|
executeArgs,
|
|
227
254
|
};
|
|
@@ -254,11 +281,15 @@ export async function runBranchPhase(opts, cwd, baseBranch) {
|
|
|
254
281
|
include: opts.include,
|
|
255
282
|
exclude: opts.exclude,
|
|
256
283
|
});
|
|
284
|
+
// Story #4395: always enumerate remote-only merged branches so the
|
|
285
|
+
// dry-run / prompt shows them without requiring `--remote`. Deletion of
|
|
286
|
+
// a remote-only candidate still requires `--remote` — `executeCleanup`
|
|
287
|
+
// no-ops on `localExists: false` candidates otherwise (unchanged).
|
|
257
288
|
const plan = planCleanup({
|
|
258
289
|
cwd,
|
|
259
290
|
baseBranch,
|
|
260
291
|
filter,
|
|
261
|
-
includeRemoteOnly:
|
|
292
|
+
includeRemoteOnly: true,
|
|
262
293
|
});
|
|
263
294
|
emitDryRunHuman(plan, baseBranch);
|
|
264
295
|
const action = decideBranchPhase({ plan, opts, cwd });
|
|
@@ -9,10 +9,61 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
const TAG = '[git-cleanup]';
|
|
12
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Pure: format a last-commit ISO timestamp as a short relative-age string
|
|
16
|
+
* for the `not-merged` skip-visibility line (Story #4395). Returns
|
|
17
|
+
* `'unknown'` when `iso` is missing or unparseable — a branch whose commit
|
|
18
|
+
* date could not be resolved (e.g. `gh`-degraded run, deleted ref) still
|
|
19
|
+
* gets a line, just without an age.
|
|
20
|
+
*
|
|
21
|
+
* @param {string|null|undefined} iso
|
|
22
|
+
* @param {number} now Epoch-ms reference clock (injectable for tests).
|
|
23
|
+
* @returns {string}
|
|
24
|
+
*/
|
|
25
|
+
function formatCommitAge(iso, now) {
|
|
26
|
+
if (!iso) return 'unknown';
|
|
27
|
+
const then = Date.parse(iso);
|
|
28
|
+
if (!Number.isFinite(then)) return 'unknown';
|
|
29
|
+
const days = Math.max(0, Math.floor((now - then) / DAY_MS));
|
|
30
|
+
if (days === 0) return 'today';
|
|
31
|
+
if (days === 1) return '1 day ago';
|
|
32
|
+
return `${days} days ago`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Pure: render a single `not-merged` skip-visibility line (Story #4395).
|
|
37
|
+
* `renderDryRun` previously kept `not-merged` survivors silent; this
|
|
38
|
+
* surfaces each one with its last-commit age so the operator can see why
|
|
39
|
+
* a leftover branch isn't reaped instead of hunting for it by hand.
|
|
40
|
+
*
|
|
41
|
+
* @param {{ branch: string, reason: string, lastCommitAt?: string|null }} skip
|
|
42
|
+
* @param {{ now?: number }} [opts]
|
|
43
|
+
* @returns {string | null}
|
|
44
|
+
*/
|
|
45
|
+
export function renderNotMergedSkipLine(skip, opts = {}) {
|
|
46
|
+
if (!skip || skip.reason !== 'not-merged') return null;
|
|
47
|
+
const now = opts.now ?? Date.now();
|
|
48
|
+
const age = formatCommitAge(skip.lastCommitAt, now);
|
|
49
|
+
return `${TAG} ⏭️ ${skip.branch} skipped — not merged (last commit: ${age})`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Pure: render a single content-merged candidate annotation line
|
|
54
|
+
* (Story #4395). `content-merged` is a weaker signal than a merged PR or
|
|
55
|
+
* git ancestry — this note lets the operator tell it apart in both the
|
|
56
|
+
* dry-run list and the confirmation prompt.
|
|
57
|
+
*/
|
|
58
|
+
function contentMergedNote(candidate) {
|
|
59
|
+
return candidate.detectedBy === 'content-merged'
|
|
60
|
+
? ' (weaker signal — verify before deleting)'
|
|
61
|
+
: '';
|
|
62
|
+
}
|
|
12
63
|
|
|
13
64
|
/** Pure: render the dry-run plan as the operator-facing text block. */
|
|
14
65
|
export function renderDryRun(plan, opts = {}) {
|
|
15
|
-
const { baseBranch = null } = opts;
|
|
66
|
+
const { baseBranch = null, now } = opts;
|
|
16
67
|
const lines = [
|
|
17
68
|
`${TAG} DRY RUN (nothing deleted) — ${plan.candidates.length} candidate(s)`,
|
|
18
69
|
];
|
|
@@ -23,7 +74,9 @@ export function renderDryRun(plan, opts = {}) {
|
|
|
23
74
|
const pr = c.prNumber ? `PR #${c.prNumber}` : c.detectedBy;
|
|
24
75
|
const wt = c.hasWorktree ? ` (worktree: ${c.worktreePath})` : '';
|
|
25
76
|
const remoteOnly = c.localExists === false ? ' (remote-only)' : '';
|
|
26
|
-
lines.push(
|
|
77
|
+
lines.push(
|
|
78
|
+
` • ${c.branch} — ${pr}${wt}${remoteOnly}${contentMergedNote(c)}`,
|
|
79
|
+
);
|
|
27
80
|
}
|
|
28
81
|
}
|
|
29
82
|
const skipped = plan.skipped ?? [];
|
|
@@ -40,6 +93,15 @@ export function renderDryRun(plan, opts = {}) {
|
|
|
40
93
|
const line = renderLatestPrSkipLine(skip);
|
|
41
94
|
if (line) lines.push(line);
|
|
42
95
|
}
|
|
96
|
+
for (const skip of skipped) {
|
|
97
|
+
const line = renderNotMergedSkipLine(skip, { now });
|
|
98
|
+
if (line) lines.push(line);
|
|
99
|
+
}
|
|
100
|
+
if (plan.ghDegraded) {
|
|
101
|
+
lines.push(
|
|
102
|
+
`${TAG} ⚠️ gh probe degraded — candidates rely on git-only signals (ancestry + content-equivalence) for this run`,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
43
105
|
return lines;
|
|
44
106
|
}
|
|
45
107
|
|
|
@@ -47,7 +109,8 @@ export function renderDryRun(plan, opts = {}) {
|
|
|
47
109
|
* Pure: render a single latest-PR-state skip line. Returns null when the
|
|
48
110
|
* skip reason is not one of the latest-PR family — `renderDryRun` filters
|
|
49
111
|
* by truthy return value so unrelated skip reasons (`protected`,
|
|
50
|
-
* `current-head`, `filtered
|
|
112
|
+
* `current-head`, `filtered`) stay quiet here. `not-merged` gets its own
|
|
113
|
+
* renderer ({@link renderNotMergedSkipLine}).
|
|
51
114
|
*
|
|
52
115
|
* @param {{ branch: string, reason: string, prNumber?: number, tipSha?: string, mergedSha?: string }} skip
|
|
53
116
|
* @returns {string | null}
|
|
@@ -64,7 +127,10 @@ export function renderLatestPrSkipLine(skip) {
|
|
|
64
127
|
if (skip.reason === 'tip-diverged-from-merge') {
|
|
65
128
|
const tip = skip.tipSha ? skip.tipSha.slice(0, 7) : '<unknown>';
|
|
66
129
|
const merged = skip.mergedSha ? skip.mergedSha.slice(0, 7) : '<unknown>';
|
|
67
|
-
return
|
|
130
|
+
return (
|
|
131
|
+
`${TAG} ⏭️ ${skip.branch} skipped — tip ${tip} diverges from ${prRef}'s merged ${merged} (post-merge force-push); ` +
|
|
132
|
+
`resolve by deleting manually (\`git branch -D ${skip.branch}\`) or pushing the follow-up commit`
|
|
133
|
+
);
|
|
68
134
|
}
|
|
69
135
|
if (skip.reason === 'latest-pr-unknown-state') {
|
|
70
136
|
return `${TAG} ⏭️ ${skip.branch} skipped — ${prRef} has an unrecognized state`;
|
|
@@ -160,6 +226,7 @@ export function buildJsonEnvelope({
|
|
|
160
226
|
baseBranch,
|
|
161
227
|
candidates: plan.candidates,
|
|
162
228
|
skipped: plan.skipped,
|
|
229
|
+
ghDegraded: plan.ghDegraded ?? false,
|
|
163
230
|
worktrees: r.worktrees,
|
|
164
231
|
local: r.local,
|
|
165
232
|
remote: r.remote,
|