mandrel 2.43.0 → 2.44.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.
@@ -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
- if (refExistsFn(cwd, remoteBaseRef)) {
299
- for (const b of mergedLister(cwd, remoteBaseRef)) mergedByGit.add(b);
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,29 @@ 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
+ * Shared by {@link listRemoteBranches} and {@link listRemoteMergedBranches}
53
+ * so both enumerations speak the same short-name vocabulary the planner's
54
+ * remote-only walk keys on — a divergence there would make the ancestry
55
+ * signal miss every branch by name (Story #5188).
56
+ *
57
+ * @param {string} stdout
58
+ * @param {string} remoteName
59
+ * @returns {string[]}
60
+ */
61
+ function shortRemoteNames(stdout, remoteName) {
62
+ const prefix = `${remoteName}/`;
63
+ return stdout
64
+ .split('\n')
65
+ .map((l) => l.trim())
66
+ .filter(Boolean)
67
+ .map((l) => (l.startsWith(prefix) ? l.slice(prefix.length) : l))
68
+ .filter((b) => b && b !== 'HEAD');
69
+ }
70
+
48
71
  /* node:coverage ignore next */
49
72
  export function listRemoteBranches(cwd, remoteName = 'origin') {
50
73
  const res = gitSpawn(
@@ -54,13 +77,41 @@ export function listRemoteBranches(cwd, remoteName = 'origin') {
54
77
  `refs/remotes/${remoteName}/`,
55
78
  );
56
79
  if (res.status !== 0) return [];
57
- const prefix = `${remoteName}/`;
58
- return res.stdout
59
- .split('\n')
60
- .map((l) => l.trim())
61
- .filter(Boolean)
62
- .map((l) => (l.startsWith(prefix) ? l.slice(prefix.length) : l))
63
- .filter((b) => b && b !== 'HEAD');
80
+ return shortRemoteNames(res.stdout, remoteName);
81
+ }
82
+
83
+ /**
84
+ * The remote-only walk's ancestry source (Story #5188).
85
+ *
86
+ * {@link listMergedBranches} runs `git branch --merged` with no `-r`, so it
87
+ * enumerates **local** heads only and can never contain a remote branch
88
+ * name — feeding it the remote-only walk classified every orphaned remote
89
+ * ref as unmerged no matter how thoroughly it had landed. This is the `-r`
90
+ * twin: `git branch -r --merged <base>`, with the `<remote>/` prefix
91
+ * stripped so its names match the short names {@link listRemoteBranches}
92
+ * enumerates.
93
+ *
94
+ * Answered entirely from the local object database against the
95
+ * already-fetched remote-tracking refs — the sweep stays offline and adds
96
+ * no per-branch network call.
97
+ *
98
+ * @param {string} cwd
99
+ * @param {string} base Rev the ancestry is taken against (`main`, `origin/main`).
100
+ * @param {string} [remoteName]
101
+ * @returns {string[]} Short branch names, remote prefix stripped.
102
+ */
103
+ /* node:coverage ignore next */
104
+ export function listRemoteMergedBranches(cwd, base, remoteName = 'origin') {
105
+ const res = gitSpawn(
106
+ cwd,
107
+ 'branch',
108
+ '-r',
109
+ '--merged',
110
+ base,
111
+ '--format=%(refname:short)',
112
+ );
113
+ if (res.status !== 0) return [];
114
+ return shortRemoteNames(res.stdout, remoteName);
64
115
  }
65
116
 
66
117
  /* node:coverage ignore next */
@@ -79,6 +130,36 @@ export function listMergedBranches(cwd, base) {
79
130
  .filter(Boolean);
80
131
  }
81
132
 
133
+ /**
134
+ * Story #4395's fresh ancestry anchor, factored out here so the local and
135
+ * remote-only walks cannot drift apart on it (Story #5188): the merged
136
+ * listing taken against the local base is unioned with the one taken
137
+ * against `<remote>/<base>` whenever that ref exists, so a stale local
138
+ * base cannot hide a branch already merged on the remote.
139
+ *
140
+ * `lister` picks the namespace — {@link listMergedBranches} (local heads)
141
+ * for the local walk, {@link listRemoteMergedBranches} (remote-tracking
142
+ * refs) for the remote-only one. `remoteName` is forwarded as the listers'
143
+ * third argument, which the local lister ignores.
144
+ *
145
+ * @param {{ lister: Function, cwd: string, baseBranch: string, remoteBaseRef: string, refExistsFn: Function, remoteName?: string }} args
146
+ * @returns {Set<string>}
147
+ */
148
+ export function freshAnchoredMergedSet({
149
+ lister,
150
+ cwd,
151
+ baseBranch,
152
+ remoteBaseRef,
153
+ refExistsFn,
154
+ remoteName,
155
+ }) {
156
+ const set = new Set(lister(cwd, baseBranch, remoteName));
157
+ if (refExistsFn(cwd, remoteBaseRef)) {
158
+ for (const b of lister(cwd, remoteBaseRef, remoteName)) set.add(b);
159
+ }
160
+ return set;
161
+ }
162
+
82
163
  /* node:coverage ignore next */
83
164
  export function currentBranch(cwd) {
84
165
  const res = gitSpawn(cwd, 'symbolic-ref', '--quiet', '--short', 'HEAD');
@@ -356,18 +437,28 @@ export function refExists(cwd, ref) {
356
437
  return res.status === 0;
357
438
  }
358
439
 
440
+ /**
441
+ * Story #4395: last-commit timestamp for the dry-run `not-merged`
442
+ * skip-visibility line (branch name + last-commit age).
443
+ *
444
+ * Story #5188 adds the remote-only arm. Remote branches are enumerated
445
+ * with the `<remote>/` prefix stripped, so the short name resolves to no
446
+ * `refs/heads/` ref at all: passing `localExists: false` reads
447
+ * `refs/remotes/<remote>/<branch>` instead, so an orphaned remote ref's
448
+ * skip line carries a real age rather than a silent `unknown`.
449
+ *
450
+ * @param {string} cwd
451
+ * @param {string} branch Short branch name, remote prefix stripped.
452
+ * @param {{ localExists?: boolean, remoteName?: string }} [opts]
453
+ * @returns {string | null}
454
+ */
359
455
  /* node:coverage ignore next */
360
- // Story #4395: last-commit timestamp for the dry-run `not-merged`
361
- // skip-visibility line (branch name + last-commit age).
362
- export function branchLastCommitAt(cwd, branch) {
363
- const res = gitSpawn(
364
- cwd,
365
- 'log',
366
- '-1',
367
- '--format=%cI',
368
- `refs/heads/${branch}`,
369
- '--',
370
- );
456
+ export function branchLastCommitAt(cwd, branch, opts = {}) {
457
+ const { localExists = true, remoteName = 'origin' } = opts;
458
+ const ref = localExists
459
+ ? `refs/heads/${branch}`
460
+ : `refs/remotes/${remoteName}/${branch}`;
461
+ const res = gitSpawn(cwd, 'log', '-1', '--format=%cI', ref, '--');
371
462
  if (res.status !== 0) return null;
372
463
  return res.stdout.trim() || null;
373
464
  }
@@ -429,7 +520,12 @@ export function probeContentEquivalent({
429
520
  return { supported: true, equivalent: mergedTree === baseTree };
430
521
  }
431
522
 
432
- export const __testing = { validSha, firstLsRemoteSha, firstStdoutLine };
523
+ export const __testing = {
524
+ validSha,
525
+ firstLsRemoteSha,
526
+ firstStdoutLine,
527
+ shortRemoteNames,
528
+ };
433
529
 
434
530
  /**
435
531
  * 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
- * @param {{ branch: string, reason: string, lastCommitAt?: string|null }} skip
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
- return `${TAG} ⏭️ ${skip.branch} skipped — not merged (last commit: ${age})`;
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
  /**