mandrel 2.43.0 → 2.45.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/scripts/lib/ITicketingProvider.js +27 -0
- package/.agents/scripts/lib/observability/source-classifier.js +1 -0
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/branches-detect.js +187 -0
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/branches.js +37 -90
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes.js +129 -19
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +10 -2
- package/.agents/scripts/lib/orchestration/plan-run-labels/reap.js +327 -0
- package/.agents/scripts/lib/orchestration/review-providers/scoped-lint.js +117 -36
- package/.agents/scripts/lib/orchestration/single-story-close/phases/post-land.js +81 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/review-outcome.js +12 -7
- package/.agents/scripts/providers/github/labels.js +88 -0
- package/.agents/scripts/providers/github.js +2 -0
- package/.agents/scripts/prune-plan-run-labels.js +218 -0
- package/docs/CHANGELOG.md +20 -0
- package/package.json +1 -1
|
@@ -180,6 +180,33 @@ export class ITicketingProvider {
|
|
|
180
180
|
throw new Error('Not implemented: ensureLabels');
|
|
181
181
|
}
|
|
182
182
|
|
|
183
|
+
/**
|
|
184
|
+
* List the repository's whole label vocabulary.
|
|
185
|
+
*
|
|
186
|
+
* Implementations MUST paginate rather than take a fixed page cap: a cap is
|
|
187
|
+
* silent truncation, and a caller deciding what to delete from a truncated
|
|
188
|
+
* view is the exact failure Story #5189 exists to stop reproducing.
|
|
189
|
+
*
|
|
190
|
+
* @returns {Promise<Array<{ name: string, color: string|null, description: string|null }>>}
|
|
191
|
+
*/
|
|
192
|
+
async listLabels() {
|
|
193
|
+
throw new Error('Not implemented: listLabels');
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Delete one label by name.
|
|
198
|
+
*
|
|
199
|
+
* A label that is already gone MUST resolve as a successful no-op
|
|
200
|
+
* (`{ deleted: false, reason: 'not-found' }`) rather than throwing, so a
|
|
201
|
+
* re-run of any sweep built on this port is idempotent.
|
|
202
|
+
*
|
|
203
|
+
* @param {string} _name
|
|
204
|
+
* @returns {Promise<{ deleted: boolean, reason: string|null }>}
|
|
205
|
+
*/
|
|
206
|
+
async deleteLabel(_name) {
|
|
207
|
+
throw new Error('Not implemented: deleteLabel');
|
|
208
|
+
}
|
|
209
|
+
|
|
183
210
|
/**
|
|
184
211
|
* Idempotent custom field creation on the Project board.
|
|
185
212
|
* Only applicable when `projectNumber` is configured.
|
|
@@ -131,6 +131,7 @@ const FRAMEWORK_SCRIPT_BASENAMES = Object.freeze([
|
|
|
131
131
|
'pr-watch-with-update.js',
|
|
132
132
|
'provision-git-hooks.js',
|
|
133
133
|
'prune-baseline-orphans.js',
|
|
134
|
+
'prune-plan-run-labels.js',
|
|
134
135
|
'quality-preview.js',
|
|
135
136
|
'quality-watch.js',
|
|
136
137
|
'resolve-doc-tiers.js',
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* branches-detect.js — the branch-reap phase's detection signals beyond
|
|
3
|
+
* the PR probe, and the whole remote-only walk (Stories #4395, #5188).
|
|
4
|
+
*
|
|
5
|
+
* Split out of `branches.js` for the same reason `branches-reap.js` was:
|
|
6
|
+
* each phase file stays small enough to read in one pass. `branches.js`
|
|
7
|
+
* keeps the two walks' orchestration (`planCleanup`) and the reap
|
|
8
|
+
* (`executeCleanup`); this module owns what a branch with no reapable PR
|
|
9
|
+
* verdict is measured against.
|
|
10
|
+
*
|
|
11
|
+
* @module lib/orchestration/git-cleanup/phases/branches-detect
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { classifyLatestPr } from './git-probes.js';
|
|
15
|
+
|
|
16
|
+
/** Fields a planner verdict forwards onto its `skipped[]` entry. */
|
|
17
|
+
const SKIP_DETAIL_FIELDS = ['prNumber', 'tipSha', 'mergedSha', 'detail'];
|
|
18
|
+
|
|
19
|
+
export function skipEntryFromVerdict(branch, verdict) {
|
|
20
|
+
const entry = { branch, reason: verdict.reason };
|
|
21
|
+
for (const field of SKIP_DETAIL_FIELDS) {
|
|
22
|
+
if (verdict[field] != null) entry[field] = verdict[field];
|
|
23
|
+
}
|
|
24
|
+
return entry;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Story #4395 — the third detection signal, shared by the local and
|
|
29
|
+
* remote-only walks (Story #5188). Reached only when the branch has no
|
|
30
|
+
* reapable PR verdict and is not an ancestor of `<base>` (or
|
|
31
|
+
* `origin/<base>`). Probes content-equivalence via `git merge-tree
|
|
32
|
+
* --write-tree` and, when the probe is conclusive and the merge is a
|
|
33
|
+
* no-op, classifies the branch as `content-merged` instead of falling
|
|
34
|
+
* through to the `not-merged` skip.
|
|
35
|
+
*
|
|
36
|
+
* `rev` is the revision handed to git, `branch` the short name every
|
|
37
|
+
* output reports. They differ on the remote-only path, where names are
|
|
38
|
+
* enumerated with the `<remote>/` prefix stripped: handing `git
|
|
39
|
+
* merge-tree` the bare short name resolves to no ref, so the probe would
|
|
40
|
+
* report `{ supported: false }` for every candidate and degrade the whole
|
|
41
|
+
* signal to silence. Defaulting `rev` to `branch` keeps the local walk
|
|
42
|
+
* unchanged.
|
|
43
|
+
*/
|
|
44
|
+
export function evaluateContentEquivalence({
|
|
45
|
+
branch,
|
|
46
|
+
rev = branch,
|
|
47
|
+
baseBranch,
|
|
48
|
+
cwd,
|
|
49
|
+
contentEquivalentFn,
|
|
50
|
+
branchLastCommitFn,
|
|
51
|
+
lastCommitOpts,
|
|
52
|
+
skipExtra,
|
|
53
|
+
}) {
|
|
54
|
+
const verdict = contentEquivalentFn({ cwd, base: baseBranch, branch: rev });
|
|
55
|
+
if (verdict?.supported && verdict.equivalent) {
|
|
56
|
+
return { detectedBy: 'content-merged' };
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
skip: {
|
|
60
|
+
branch,
|
|
61
|
+
reason: 'not-merged',
|
|
62
|
+
lastCommitAt: branchLastCommitFn(cwd, branch, lastCommitOpts),
|
|
63
|
+
...skipExtra,
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Story #5188 — the remote-only walk's no-PR cascade, the mirror of the
|
|
70
|
+
* local walk's: ancestry against the base first, then content-equivalence,
|
|
71
|
+
* else a `not-merged` skip. Reaching this at all is the fix — the walk
|
|
72
|
+
* used to return early on a `no-pr` verdict, recording the branch in
|
|
73
|
+
* neither `candidates[]` nor `skipped[]`, so a provably-merged orphan ref
|
|
74
|
+
* was absent from the rendered log and the `--json` envelope alike.
|
|
75
|
+
*
|
|
76
|
+
* `mergedRemote` is the `-r` ancestry set ({@link listRemoteMergedBranches}):
|
|
77
|
+
* the local `git branch --merged` listing that feeds the local cascade
|
|
78
|
+
* enumerates local heads only and can never contain a remote branch name.
|
|
79
|
+
* Both signals read the local object database, so the sweep stays offline.
|
|
80
|
+
*
|
|
81
|
+
* A hit carries a `detectedBy` distinct from the PR-detected
|
|
82
|
+
* `'remote-only'` — `'remote-git-merged'` for ancestry, and the existing
|
|
83
|
+
* `'content-merged'` for the weaker content signal, so every consumer that
|
|
84
|
+
* already treats that value as report-only keeps doing so here.
|
|
85
|
+
*/
|
|
86
|
+
function evaluateRemoteOnlyNoPr({
|
|
87
|
+
branch,
|
|
88
|
+
rev,
|
|
89
|
+
baseBranch,
|
|
90
|
+
cwd,
|
|
91
|
+
mergedRemote,
|
|
92
|
+
contentEquivalentFn,
|
|
93
|
+
branchLastCommitFn,
|
|
94
|
+
remoteName,
|
|
95
|
+
}) {
|
|
96
|
+
if (mergedRemote.has(branch)) return { detectedBy: 'remote-git-merged' };
|
|
97
|
+
return evaluateContentEquivalence({
|
|
98
|
+
branch,
|
|
99
|
+
rev,
|
|
100
|
+
baseBranch,
|
|
101
|
+
cwd,
|
|
102
|
+
contentEquivalentFn,
|
|
103
|
+
branchLastCommitFn,
|
|
104
|
+
lastCommitOpts: { localExists: false, remoteName },
|
|
105
|
+
skipExtra: { localExists: false },
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Story #5188 — resolve one remote-only branch's PR verdict into either a
|
|
111
|
+
* detection provenance or a skip. Split out of the walk so the loop reads
|
|
112
|
+
* as enumerate → resolve → record and every arm of the cascade is visibly
|
|
113
|
+
* total: each branch that passes the protected and filter checks leaves
|
|
114
|
+
* this function as exactly one of `{ detectedBy, prInfo }` or `{ skip }`.
|
|
115
|
+
*/
|
|
116
|
+
function resolveRemoteOnlyBranch({ verdict, branch, rev, noPrArgs }) {
|
|
117
|
+
if (verdict.kind === 'skip') {
|
|
118
|
+
return { skip: skipEntryFromVerdict(branch, verdict) };
|
|
119
|
+
}
|
|
120
|
+
if (verdict.kind === 'candidate') {
|
|
121
|
+
return { detectedBy: 'remote-only', prInfo: verdict.prInfo };
|
|
122
|
+
}
|
|
123
|
+
const out = evaluateRemoteOnlyNoPr({ ...noPrArgs, branch, rev });
|
|
124
|
+
return out.skip ? out : { detectedBy: out.detectedBy, prInfo: null };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function collectRemoteOnlyCandidates({
|
|
128
|
+
remoteLister,
|
|
129
|
+
remoteName,
|
|
130
|
+
cwd,
|
|
131
|
+
baseBranch,
|
|
132
|
+
localSet,
|
|
133
|
+
classify,
|
|
134
|
+
filter,
|
|
135
|
+
prProbe,
|
|
136
|
+
branchTipShaFn,
|
|
137
|
+
ancestryFn,
|
|
138
|
+
mergedRemote,
|
|
139
|
+
contentEquivalentFn,
|
|
140
|
+
branchLastCommitFn,
|
|
141
|
+
skipped,
|
|
142
|
+
}) {
|
|
143
|
+
const noPrArgs = {
|
|
144
|
+
baseBranch,
|
|
145
|
+
cwd,
|
|
146
|
+
mergedRemote,
|
|
147
|
+
contentEquivalentFn,
|
|
148
|
+
branchLastCommitFn,
|
|
149
|
+
remoteName,
|
|
150
|
+
};
|
|
151
|
+
const out = [];
|
|
152
|
+
for (const branch of remoteLister(cwd, remoteName)) {
|
|
153
|
+
if (localSet.has(branch)) continue;
|
|
154
|
+
if (classify(branch)) continue;
|
|
155
|
+
if (!filter(branch)) continue;
|
|
156
|
+
const verdict = classifyLatestPr({
|
|
157
|
+
prInfo: prProbe(branch, cwd),
|
|
158
|
+
branch,
|
|
159
|
+
cwd,
|
|
160
|
+
remoteName,
|
|
161
|
+
localExists: false,
|
|
162
|
+
branchTipShaFn,
|
|
163
|
+
ancestryFn,
|
|
164
|
+
});
|
|
165
|
+
const resolved = resolveRemoteOnlyBranch({
|
|
166
|
+
verdict,
|
|
167
|
+
branch,
|
|
168
|
+
rev: `${remoteName}/${branch}`,
|
|
169
|
+
noPrArgs,
|
|
170
|
+
});
|
|
171
|
+
if (resolved.skip) {
|
|
172
|
+
skipped.push(resolved.skip);
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
out.push({
|
|
176
|
+
branch,
|
|
177
|
+
prNumber: resolved.prInfo?.number ?? null,
|
|
178
|
+
mergedAt: resolved.prInfo?.mergedAt ?? null,
|
|
179
|
+
hasWorktree: false,
|
|
180
|
+
worktreePath: null,
|
|
181
|
+
detectedBy: resolved.detectedBy,
|
|
182
|
+
localExists: false,
|
|
183
|
+
behindMerge: verdict.reason === 'tip-behind-merge',
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
return out;
|
|
187
|
+
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* branches.js — branch-reap phase of git-cleanup (Story #2466).
|
|
3
3
|
* Owns `planCleanup` + `executeCleanup`. Reap helpers live in
|
|
4
|
-
* `branches-reap.js
|
|
4
|
+
* `branches-reap.js`; the non-PR detection signals and the remote-only
|
|
5
|
+
* walk live in `branches-detect.js`.
|
|
5
6
|
* @module lib/orchestration/git-cleanup/phases/branches
|
|
6
7
|
*/
|
|
7
8
|
|
|
@@ -13,6 +14,11 @@ import {
|
|
|
13
14
|
} from '../../../git-branch-cleanup.js';
|
|
14
15
|
import { Logger } from '../../../Logger.js';
|
|
15
16
|
import { recordPendingCleanup } from '../../../worktree/lifecycle/pending-cleanup.js';
|
|
17
|
+
import {
|
|
18
|
+
collectRemoteOnlyCandidates,
|
|
19
|
+
evaluateContentEquivalence,
|
|
20
|
+
skipEntryFromVerdict,
|
|
21
|
+
} from './branches-detect.js';
|
|
16
22
|
import {
|
|
17
23
|
buildPruneSummary,
|
|
18
24
|
reapLocalRef,
|
|
@@ -25,9 +31,11 @@ import {
|
|
|
25
31
|
branchTipSha,
|
|
26
32
|
classifyLatestPr,
|
|
27
33
|
currentBranch as defaultCurrentBranch,
|
|
34
|
+
freshAnchoredMergedSet,
|
|
28
35
|
listLocalBranches,
|
|
29
36
|
listMergedBranches,
|
|
30
37
|
listRemoteBranches,
|
|
38
|
+
listRemoteMergedBranches,
|
|
31
39
|
probeAllPrs,
|
|
32
40
|
probeContentEquivalent,
|
|
33
41
|
probeLatestPr,
|
|
@@ -42,45 +50,6 @@ import { parsePrunedRefs } from './prune.js';
|
|
|
42
50
|
|
|
43
51
|
const TAG = '[git-cleanup]';
|
|
44
52
|
|
|
45
|
-
/** Fields a planner verdict forwards onto its `skipped[]` entry. */
|
|
46
|
-
const SKIP_DETAIL_FIELDS = ['prNumber', 'tipSha', 'mergedSha', 'detail'];
|
|
47
|
-
|
|
48
|
-
function skipEntryFromVerdict(branch, verdict) {
|
|
49
|
-
const entry = { branch, reason: verdict.reason };
|
|
50
|
-
for (const field of SKIP_DETAIL_FIELDS) {
|
|
51
|
-
if (verdict[field] != null) entry[field] = verdict[field];
|
|
52
|
-
}
|
|
53
|
-
return entry;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/**
|
|
57
|
-
* Story #4395 — the third detection signal. Reached only when the branch
|
|
58
|
-
* has no reapable PR verdict and is not an ancestor of `<base>` (or
|
|
59
|
-
* `origin/<base>`). Probes content-equivalence via `git merge-tree
|
|
60
|
-
* --write-tree` and, when the probe is conclusive and the merge is a
|
|
61
|
-
* no-op, classifies the branch as `content-merged` instead of falling
|
|
62
|
-
* through to the `not-merged` skip.
|
|
63
|
-
*/
|
|
64
|
-
function evaluateContentEquivalence({
|
|
65
|
-
branch,
|
|
66
|
-
baseBranch,
|
|
67
|
-
cwd,
|
|
68
|
-
contentEquivalentFn,
|
|
69
|
-
branchLastCommitFn,
|
|
70
|
-
}) {
|
|
71
|
-
const verdict = contentEquivalentFn({ cwd, base: baseBranch, branch });
|
|
72
|
-
if (verdict?.supported && verdict.equivalent) {
|
|
73
|
-
return { detectedBy: 'content-merged' };
|
|
74
|
-
}
|
|
75
|
-
return {
|
|
76
|
-
skip: {
|
|
77
|
-
branch,
|
|
78
|
-
reason: 'not-merged',
|
|
79
|
-
lastCommitAt: branchLastCommitFn(cwd, branch),
|
|
80
|
-
},
|
|
81
|
-
};
|
|
82
|
-
}
|
|
83
|
-
|
|
84
53
|
function evaluateLocalBranch({
|
|
85
54
|
branch,
|
|
86
55
|
baseBranch,
|
|
@@ -145,52 +114,6 @@ function evaluateLocalBranch({
|
|
|
145
114
|
};
|
|
146
115
|
}
|
|
147
116
|
|
|
148
|
-
function collectRemoteOnlyCandidates({
|
|
149
|
-
remoteLister,
|
|
150
|
-
remoteName,
|
|
151
|
-
cwd,
|
|
152
|
-
localSet,
|
|
153
|
-
classify,
|
|
154
|
-
filter,
|
|
155
|
-
prProbe,
|
|
156
|
-
branchTipShaFn,
|
|
157
|
-
ancestryFn,
|
|
158
|
-
skipped,
|
|
159
|
-
}) {
|
|
160
|
-
const out = [];
|
|
161
|
-
for (const branch of remoteLister(cwd, remoteName)) {
|
|
162
|
-
if (localSet.has(branch)) continue;
|
|
163
|
-
if (classify(branch)) continue;
|
|
164
|
-
if (!filter(branch)) continue;
|
|
165
|
-
const prInfo = prProbe(branch, cwd);
|
|
166
|
-
const verdict = classifyLatestPr({
|
|
167
|
-
prInfo,
|
|
168
|
-
branch,
|
|
169
|
-
cwd,
|
|
170
|
-
remoteName,
|
|
171
|
-
localExists: false,
|
|
172
|
-
branchTipShaFn,
|
|
173
|
-
ancestryFn,
|
|
174
|
-
});
|
|
175
|
-
if (verdict.kind === 'no-pr') continue;
|
|
176
|
-
if (verdict.kind === 'skip') {
|
|
177
|
-
skipped.push(skipEntryFromVerdict(branch, verdict));
|
|
178
|
-
continue;
|
|
179
|
-
}
|
|
180
|
-
out.push({
|
|
181
|
-
branch,
|
|
182
|
-
prNumber: verdict.prInfo.number ?? null,
|
|
183
|
-
mergedAt: verdict.prInfo.mergedAt ?? null,
|
|
184
|
-
hasWorktree: false,
|
|
185
|
-
worktreePath: null,
|
|
186
|
-
detectedBy: 'remote-only',
|
|
187
|
-
localExists: false,
|
|
188
|
-
behindMerge: verdict.reason === 'tip-behind-merge',
|
|
189
|
-
});
|
|
190
|
-
}
|
|
191
|
-
return out;
|
|
192
|
-
}
|
|
193
|
-
|
|
194
117
|
/**
|
|
195
118
|
* Pure-ish: enumerate merged-branch candidates.
|
|
196
119
|
*
|
|
@@ -229,6 +152,20 @@ function collectRemoteOnlyCandidates({
|
|
|
229
152
|
* git-only signals (ancestry + content-equivalence) rather than
|
|
230
153
|
* aborting the whole plan. The returned envelope's `ghDegraded` flag
|
|
231
154
|
* records whether this happened.
|
|
155
|
+
*
|
|
156
|
+
* Story #5188 makes the **remote-only** walk total (it now lives in
|
|
157
|
+
* `branches-detect.js`). It used to classify by
|
|
158
|
+
* the latest-PR verdict alone and return early on a `no-pr` verdict,
|
|
159
|
+
* recording neither a candidate nor a skip — so a remote ref no PR record
|
|
160
|
+
* covered was absent from `candidates[]`, `skipped[]`, the rendered log
|
|
161
|
+
* and the `--json` envelope alike. It now runs the same three-signal
|
|
162
|
+
* cascade the local walk does (PR verdict → ancestry → content
|
|
163
|
+
* -equivalence → `not-merged` skip), over its own remote-namespace
|
|
164
|
+
* ancestry source and with every git probe handed the qualified
|
|
165
|
+
* `<remote>/<branch>` rev. Every enumerated branch that passes the
|
|
166
|
+
* protected and filter checks therefore lands in exactly one of the two
|
|
167
|
+
* collections. Deletion is unchanged: a remote-only candidate still needs
|
|
168
|
+
* `--remote`, whichever signal detected it.
|
|
232
169
|
*/
|
|
233
170
|
function buildGuardedPrProbe({ cwd, prIndexFn, prFallback, onDegrade }) {
|
|
234
171
|
let prIndex;
|
|
@@ -269,6 +206,7 @@ export function planCleanup(ctx) {
|
|
|
269
206
|
filter = () => true,
|
|
270
207
|
includeRemoteOnly = false,
|
|
271
208
|
remoteLister = listRemoteBranches,
|
|
209
|
+
remoteMergedLister = listRemoteMergedBranches,
|
|
272
210
|
remoteName = 'origin',
|
|
273
211
|
logger = Logger,
|
|
274
212
|
} = ctx;
|
|
@@ -293,11 +231,12 @@ export function planCleanup(ctx) {
|
|
|
293
231
|
branch,
|
|
294
232
|
});
|
|
295
233
|
const wtMap = worktreesFn(cwd);
|
|
296
|
-
const mergedByGit = new Set(mergedLister(cwd, baseBranch));
|
|
297
234
|
const remoteBaseRef = `${remoteName}/${baseBranch}`;
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
235
|
+
const mergedSetArgs = { cwd, baseBranch, remoteBaseRef, refExistsFn };
|
|
236
|
+
const mergedByGit = freshAnchoredMergedSet({
|
|
237
|
+
...mergedSetArgs,
|
|
238
|
+
lister: mergedLister,
|
|
239
|
+
});
|
|
301
240
|
const localBranches = localLister(cwd);
|
|
302
241
|
const localSet = new Set(localBranches);
|
|
303
242
|
const candidates = [];
|
|
@@ -327,12 +266,20 @@ export function planCleanup(ctx) {
|
|
|
327
266
|
remoteLister,
|
|
328
267
|
remoteName,
|
|
329
268
|
cwd,
|
|
269
|
+
baseBranch,
|
|
330
270
|
localSet,
|
|
331
271
|
classify,
|
|
332
272
|
filter,
|
|
333
273
|
prProbe,
|
|
334
274
|
branchTipShaFn,
|
|
335
275
|
ancestryFn,
|
|
276
|
+
mergedRemote: freshAnchoredMergedSet({
|
|
277
|
+
...mergedSetArgs,
|
|
278
|
+
lister: remoteMergedLister,
|
|
279
|
+
remoteName,
|
|
280
|
+
}),
|
|
281
|
+
contentEquivalentFn,
|
|
282
|
+
branchLastCommitFn,
|
|
336
283
|
skipped,
|
|
337
284
|
}),
|
|
338
285
|
);
|
|
@@ -45,6 +45,43 @@ export function listLocalBranches(cwd) {
|
|
|
45
45
|
.filter(Boolean);
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
/**
|
|
49
|
+
* Pure: strip the `<remote>/` prefix off a remote-ref listing and drop the
|
|
50
|
+
* symbolic `HEAD` entry.
|
|
51
|
+
*
|
|
52
|
+
* `HEAD` arrives under two spellings. `%(refname:lstrip=3)` renders
|
|
53
|
+
* `refs/remotes/origin/HEAD` as the literal `HEAD`, but
|
|
54
|
+
* `%(refname:short)` — what both callers ask for — shortens it to the bare
|
|
55
|
+
* remote name `origin`, because git keeps the remote segment only when the
|
|
56
|
+
* result would otherwise be ambiguous. Missing that second spelling leaked
|
|
57
|
+
* a phantom `origin` candidate into the remote-only sweep, which the reap
|
|
58
|
+
* phase then reported as `already gone`.
|
|
59
|
+
*
|
|
60
|
+
* Both spellings are rejected **before** the prefix strip, because the
|
|
61
|
+
* strip is what makes them ambiguous: a real branch named `origin` lists
|
|
62
|
+
* as `origin/origin` and strips to `origin` too. Discriminating on the raw
|
|
63
|
+
* line keeps that branch reapable while still dropping the symbolic ref.
|
|
64
|
+
*
|
|
65
|
+
* Shared by {@link listRemoteBranches} and {@link listRemoteMergedBranches}
|
|
66
|
+
* so both enumerations speak the same short-name vocabulary the planner's
|
|
67
|
+
* remote-only walk keys on — a divergence there would make the ancestry
|
|
68
|
+
* signal miss every branch by name (Story #5188).
|
|
69
|
+
*
|
|
70
|
+
* @param {string} stdout
|
|
71
|
+
* @param {string} remoteName
|
|
72
|
+
* @returns {string[]}
|
|
73
|
+
*/
|
|
74
|
+
function shortRemoteNames(stdout, remoteName) {
|
|
75
|
+
const prefix = `${remoteName}/`;
|
|
76
|
+
return stdout
|
|
77
|
+
.split('\n')
|
|
78
|
+
.map((l) => l.trim())
|
|
79
|
+
.filter(Boolean)
|
|
80
|
+
.filter((l) => l !== 'HEAD' && l !== remoteName)
|
|
81
|
+
.map((l) => (l.startsWith(prefix) ? l.slice(prefix.length) : l))
|
|
82
|
+
.filter(Boolean);
|
|
83
|
+
}
|
|
84
|
+
|
|
48
85
|
/* node:coverage ignore next */
|
|
49
86
|
export function listRemoteBranches(cwd, remoteName = 'origin') {
|
|
50
87
|
const res = gitSpawn(
|
|
@@ -54,13 +91,41 @@ export function listRemoteBranches(cwd, remoteName = 'origin') {
|
|
|
54
91
|
`refs/remotes/${remoteName}/`,
|
|
55
92
|
);
|
|
56
93
|
if (res.status !== 0) return [];
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
94
|
+
return shortRemoteNames(res.stdout, remoteName);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The remote-only walk's ancestry source (Story #5188).
|
|
99
|
+
*
|
|
100
|
+
* {@link listMergedBranches} runs `git branch --merged` with no `-r`, so it
|
|
101
|
+
* enumerates **local** heads only and can never contain a remote branch
|
|
102
|
+
* name — feeding it the remote-only walk classified every orphaned remote
|
|
103
|
+
* ref as unmerged no matter how thoroughly it had landed. This is the `-r`
|
|
104
|
+
* twin: `git branch -r --merged <base>`, with the `<remote>/` prefix
|
|
105
|
+
* stripped so its names match the short names {@link listRemoteBranches}
|
|
106
|
+
* enumerates.
|
|
107
|
+
*
|
|
108
|
+
* Answered entirely from the local object database against the
|
|
109
|
+
* already-fetched remote-tracking refs — the sweep stays offline and adds
|
|
110
|
+
* no per-branch network call.
|
|
111
|
+
*
|
|
112
|
+
* @param {string} cwd
|
|
113
|
+
* @param {string} base Rev the ancestry is taken against (`main`, `origin/main`).
|
|
114
|
+
* @param {string} [remoteName]
|
|
115
|
+
* @returns {string[]} Short branch names, remote prefix stripped.
|
|
116
|
+
*/
|
|
117
|
+
/* node:coverage ignore next */
|
|
118
|
+
export function listRemoteMergedBranches(cwd, base, remoteName = 'origin') {
|
|
119
|
+
const res = gitSpawn(
|
|
120
|
+
cwd,
|
|
121
|
+
'branch',
|
|
122
|
+
'-r',
|
|
123
|
+
'--merged',
|
|
124
|
+
base,
|
|
125
|
+
'--format=%(refname:short)',
|
|
126
|
+
);
|
|
127
|
+
if (res.status !== 0) return [];
|
|
128
|
+
return shortRemoteNames(res.stdout, remoteName);
|
|
64
129
|
}
|
|
65
130
|
|
|
66
131
|
/* node:coverage ignore next */
|
|
@@ -79,6 +144,36 @@ export function listMergedBranches(cwd, base) {
|
|
|
79
144
|
.filter(Boolean);
|
|
80
145
|
}
|
|
81
146
|
|
|
147
|
+
/**
|
|
148
|
+
* Story #4395's fresh ancestry anchor, factored out here so the local and
|
|
149
|
+
* remote-only walks cannot drift apart on it (Story #5188): the merged
|
|
150
|
+
* listing taken against the local base is unioned with the one taken
|
|
151
|
+
* against `<remote>/<base>` whenever that ref exists, so a stale local
|
|
152
|
+
* base cannot hide a branch already merged on the remote.
|
|
153
|
+
*
|
|
154
|
+
* `lister` picks the namespace — {@link listMergedBranches} (local heads)
|
|
155
|
+
* for the local walk, {@link listRemoteMergedBranches} (remote-tracking
|
|
156
|
+
* refs) for the remote-only one. `remoteName` is forwarded as the listers'
|
|
157
|
+
* third argument, which the local lister ignores.
|
|
158
|
+
*
|
|
159
|
+
* @param {{ lister: Function, cwd: string, baseBranch: string, remoteBaseRef: string, refExistsFn: Function, remoteName?: string }} args
|
|
160
|
+
* @returns {Set<string>}
|
|
161
|
+
*/
|
|
162
|
+
export function freshAnchoredMergedSet({
|
|
163
|
+
lister,
|
|
164
|
+
cwd,
|
|
165
|
+
baseBranch,
|
|
166
|
+
remoteBaseRef,
|
|
167
|
+
refExistsFn,
|
|
168
|
+
remoteName,
|
|
169
|
+
}) {
|
|
170
|
+
const set = new Set(lister(cwd, baseBranch, remoteName));
|
|
171
|
+
if (refExistsFn(cwd, remoteBaseRef)) {
|
|
172
|
+
for (const b of lister(cwd, remoteBaseRef, remoteName)) set.add(b);
|
|
173
|
+
}
|
|
174
|
+
return set;
|
|
175
|
+
}
|
|
176
|
+
|
|
82
177
|
/* node:coverage ignore next */
|
|
83
178
|
export function currentBranch(cwd) {
|
|
84
179
|
const res = gitSpawn(cwd, 'symbolic-ref', '--quiet', '--short', 'HEAD');
|
|
@@ -356,18 +451,28 @@ export function refExists(cwd, ref) {
|
|
|
356
451
|
return res.status === 0;
|
|
357
452
|
}
|
|
358
453
|
|
|
454
|
+
/**
|
|
455
|
+
* Story #4395: last-commit timestamp for the dry-run `not-merged`
|
|
456
|
+
* skip-visibility line (branch name + last-commit age).
|
|
457
|
+
*
|
|
458
|
+
* Story #5188 adds the remote-only arm. Remote branches are enumerated
|
|
459
|
+
* with the `<remote>/` prefix stripped, so the short name resolves to no
|
|
460
|
+
* `refs/heads/` ref at all: passing `localExists: false` reads
|
|
461
|
+
* `refs/remotes/<remote>/<branch>` instead, so an orphaned remote ref's
|
|
462
|
+
* skip line carries a real age rather than a silent `unknown`.
|
|
463
|
+
*
|
|
464
|
+
* @param {string} cwd
|
|
465
|
+
* @param {string} branch Short branch name, remote prefix stripped.
|
|
466
|
+
* @param {{ localExists?: boolean, remoteName?: string }} [opts]
|
|
467
|
+
* @returns {string | null}
|
|
468
|
+
*/
|
|
359
469
|
/* node:coverage ignore next */
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
'-1',
|
|
367
|
-
'--format=%cI',
|
|
368
|
-
`refs/heads/${branch}`,
|
|
369
|
-
'--',
|
|
370
|
-
);
|
|
470
|
+
export function branchLastCommitAt(cwd, branch, opts = {}) {
|
|
471
|
+
const { localExists = true, remoteName = 'origin' } = opts;
|
|
472
|
+
const ref = localExists
|
|
473
|
+
? `refs/heads/${branch}`
|
|
474
|
+
: `refs/remotes/${remoteName}/${branch}`;
|
|
475
|
+
const res = gitSpawn(cwd, 'log', '-1', '--format=%cI', ref, '--');
|
|
371
476
|
if (res.status !== 0) return null;
|
|
372
477
|
return res.stdout.trim() || null;
|
|
373
478
|
}
|
|
@@ -429,7 +534,12 @@ export function probeContentEquivalent({
|
|
|
429
534
|
return { supported: true, equivalent: mergedTree === baseTree };
|
|
430
535
|
}
|
|
431
536
|
|
|
432
|
-
export const __testing = {
|
|
537
|
+
export const __testing = {
|
|
538
|
+
validSha,
|
|
539
|
+
firstLsRemoteSha,
|
|
540
|
+
firstStdoutLine,
|
|
541
|
+
shortRemoteNames,
|
|
542
|
+
};
|
|
433
543
|
|
|
434
544
|
/**
|
|
435
545
|
* Pure-ish: classify a latest-PR probe row into a planner verdict.
|
|
@@ -38,7 +38,14 @@ function formatCommitAge(iso, now) {
|
|
|
38
38
|
* surfaces each one with its last-commit age so the operator can see why
|
|
39
39
|
* a leftover branch isn't reaped instead of hunting for it by hand.
|
|
40
40
|
*
|
|
41
|
-
*
|
|
41
|
+
* Story #5188 routes the remote-only walk's genuinely-unmerged survivors
|
|
42
|
+
* through here too. A `localExists: false` skip is marked `(remote-only)`
|
|
43
|
+
* — the same marker {@link renderCandidateRow} puts on a remote-only
|
|
44
|
+
* candidate — because the short name it prints has no local ref behind
|
|
45
|
+
* it: without the marker the operator reads the line as a local branch and
|
|
46
|
+
* hunts for a ref that `git branch` will never list.
|
|
47
|
+
*
|
|
48
|
+
* @param {{ branch: string, reason: string, lastCommitAt?: string|null, localExists?: boolean }} skip
|
|
42
49
|
* @param {{ now?: number }} [opts]
|
|
43
50
|
* @returns {string | null}
|
|
44
51
|
*/
|
|
@@ -46,7 +53,8 @@ export function renderNotMergedSkipLine(skip, opts = {}) {
|
|
|
46
53
|
if (!skip || skip.reason !== 'not-merged') return null;
|
|
47
54
|
const now = opts.now ?? Date.now();
|
|
48
55
|
const age = formatCommitAge(skip.lastCommitAt, now);
|
|
49
|
-
|
|
56
|
+
const scope = skip.localExists === false ? ' (remote-only)' : '';
|
|
57
|
+
return `${TAG} ⏭️ ${skip.branch}${scope} skipped — not merged (last commit: ${age})`;
|
|
50
58
|
}
|
|
51
59
|
|
|
52
60
|
/**
|