mandrel 2.53.0 → 2.55.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/agents/story-worker.md +24 -23
- package/.agents/audit-checklists/accessibility.md +0 -3
- package/.agents/audit-checklists/mobile.md +0 -4
- package/.agents/docs/agentrc-reference.json +4 -2
- package/.agents/docs/configuration.md +2 -0
- package/.agents/schemas/agentrc.schema.json +15 -1
- package/.agents/schemas/lifecycle/merge.unlanded.schema.json +2 -1
- package/.agents/schemas/story-deliver-terminal.schema.json +1 -0
- package/.agents/scripts/audit-to-stories.js +158 -7
- package/.agents/scripts/check-audit-attribution.js +119 -62
- package/.agents/scripts/check-test-portability.js +512 -0
- package/.agents/scripts/coverage-capture.js +17 -10
- package/.agents/scripts/evidence-gate.js +31 -4
- package/.agents/scripts/generate-workflows-doc.js +65 -14
- package/.agents/scripts/git-cleanup.js +4 -0
- package/.agents/scripts/lib/ITicketingProvider.js +78 -0
- package/.agents/scripts/lib/audit-advisories.js +195 -0
- package/.agents/scripts/lib/audit-attribution.js +22 -0
- package/.agents/scripts/lib/audit-to-stories/dedupe-against-github.js +68 -5
- package/.agents/scripts/lib/audit-to-stories/issue-index.js +83 -0
- package/.agents/scripts/lib/audit-to-stories/ledger-commit.js +60 -114
- package/.agents/scripts/lib/audit-to-stories/ledger-pr.js +347 -0
- package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +169 -44
- package/.agents/scripts/lib/baselines/merge-envelopes.js +298 -32
- package/.agents/scripts/lib/bootstrap/baseline-merge-driver.js +180 -14
- package/.agents/scripts/lib/cli-args.js +26 -0
- package/.agents/scripts/lib/close-validation/gates.js +113 -7
- package/.agents/scripts/lib/close-validation/process.js +7 -3
- package/.agents/scripts/lib/close-validation/runner.js +62 -11
- package/.agents/scripts/lib/config/ci.js +28 -9
- package/.agents/scripts/lib/config-settings-schema-delivery.js +7 -0
- package/.agents/scripts/lib/config-settings-schema.js +19 -1
- package/.agents/scripts/lib/coverage-capture-fullscope.js +23 -11
- package/.agents/scripts/lib/coverage-capture-incremental.js +22 -16
- package/.agents/scripts/lib/coverage-capture-usage.js +5 -1
- package/.agents/scripts/lib/coverage-capture.js +77 -3
- package/.agents/scripts/lib/findings/route-finding.js +4 -2
- package/.agents/scripts/lib/full-suite-lock.js +232 -6
- package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
- package/.agents/scripts/lib/git/sync-from-base.js +130 -13
- package/.agents/scripts/lib/observability/source-classifier.js +1 -0
- package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +10 -2
- package/.agents/scripts/lib/orchestration/check-baselines/phases/refresh-ack.js +75 -15
- package/.agents/scripts/lib/orchestration/deliver-recover.js +82 -43
- package/.agents/scripts/lib/orchestration/dependency-candidates.js +8 -4
- package/.agents/scripts/lib/orchestration/epic-candidates.js +9 -4
- package/.agents/scripts/lib/orchestration/epic-container.js +66 -4
- package/.agents/scripts/lib/orchestration/epic-rollup.js +241 -84
- package/.agents/scripts/lib/orchestration/file-assumptions.js +218 -16
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/branches.js +93 -7
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes.js +22 -6
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/parse-args.js +26 -5
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/phase-drivers.js +13 -2
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +35 -5
- package/.agents/scripts/lib/orchestration/merge-block-class.js +18 -3
- package/.agents/scripts/lib/orchestration/merge-poll.js +284 -40
- package/.agents/scripts/lib/orchestration/plan-persist/epic-adoption.js +49 -2
- package/.agents/scripts/lib/orchestration/plan-persist/epic-ops.js +43 -7
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +24 -1
- package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +5 -0
- package/.agents/scripts/lib/orchestration/plan-persist/summary.js +3 -0
- package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +119 -6
- package/.agents/scripts/lib/orchestration/plan-persist/wave-serialisation.js +110 -0
- package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +130 -40
- package/.agents/scripts/lib/orchestration/resolve-stories.js +44 -1
- package/.agents/scripts/lib/orchestration/review-providers/native.js +31 -11
- package/.agents/scripts/lib/orchestration/review-providers/scoped-lint.js +27 -24
- package/.agents/scripts/lib/orchestration/run-epilogue.js +59 -38
- package/.agents/scripts/lib/orchestration/single-story-close/close-note.js +81 -0
- package/.agents/scripts/lib/orchestration/single-story-close/failed-terminal.js +40 -51
- package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +10 -2
- package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +101 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +351 -28
- package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +27 -6
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +117 -22
- package/.agents/scripts/lib/orchestration/story-close/baseline-upward-writeback.js +94 -12
- package/.agents/scripts/lib/orchestration/story-close/format-autofix.js +6 -1
- package/.agents/scripts/lib/orchestration/ticket-validator.js +25 -14
- package/.agents/scripts/lib/orchestration/ticketing/bulk.js +70 -6
- package/.agents/scripts/lib/orchestration/verify-credit.js +37 -0
- package/.agents/scripts/lib/pinned-override-notes.js +41 -53
- package/.agents/scripts/lib/pinned-override-resolve.js +212 -0
- package/.agents/scripts/lib/qa/resolve-qa-contract.js +18 -0
- package/.agents/scripts/lib/single-story-sweep/sweep-lock.js +173 -9
- package/.agents/scripts/lib/skills/walk-skill-files.js +24 -7
- package/.agents/scripts/lib/test-temp.js +167 -30
- package/.agents/scripts/lib/validation-evidence.js +37 -0
- package/.agents/scripts/lib/wave-runner/footprint.js +167 -14
- package/.agents/scripts/lib/wave-runner/live-probe.js +7 -1
- package/.agents/scripts/lib/wave-runner/ready-set.js +1 -1
- package/.agents/scripts/merge-baseline.js +175 -21
- package/.agents/scripts/providers/github/errors.js +22 -1
- package/.agents/scripts/providers/github/issues.js +106 -1
- package/.agents/scripts/providers/github/sub-issue-add.js +18 -1
- package/.agents/scripts/providers/github.js +6 -0
- package/.agents/scripts/resolve-stories.js +44 -34
- package/.agents/scripts/single-story-close.js +5 -0
- package/.agents/scripts/stories-wave-tick.js +37 -13
- package/.agents/templates/docs/audit-sweep-runbook.md +41 -7
- package/.agents/workflows/audit-accessibility.md +16 -31
- package/.agents/workflows/audit-mobile.md +20 -37
- package/.agents/workflows/git-cleanup.md +17 -3
- package/.agents/workflows/helpers/audit-lens-core.md +45 -0
- package/.agents/workflows/helpers/deliver-digest.md +7 -6
- package/.agents/workflows/helpers/deliver-reference.md +40 -16
- package/.agents/workflows/helpers/deliver-story-reference.md +7 -4
- package/.agents/workflows/helpers/deliver-story.md +15 -12
- package/.agents/workflows/helpers/plan-reference.md +8 -1
- package/.agents/workflows/mandrel-plan.md +4 -7
- package/.agents/workflows/memory-consolidate.md +14 -9
- package/docs/CHANGELOG.md +34 -0
- package/lib/cli/registry.js +64 -21
- package/lib/cli/sync.js +27 -2
- package/package.json +7 -4
|
@@ -15,13 +15,19 @@
|
|
|
15
15
|
*
|
|
16
16
|
* Rules (one error per mismatched path):
|
|
17
17
|
* - `creates` + path **exists** → error (Story would clobber).
|
|
18
|
-
* - `refactors-existing` (via `changes`) + path **absent
|
|
19
|
-
* auto-normalized to `creates` with a
|
|
20
|
-
* a refactor declaration against a
|
|
21
|
-
* deterministically a create, so rejecting it
|
|
22
|
-
* reject→amend→re-persist cycle for a mechanical rewrite.
|
|
23
|
-
*
|
|
24
|
-
*
|
|
18
|
+
* - `refactors-existing` (via `changes`) + path **absent and never
|
|
19
|
+
* tracked** at `baseBranchRef` → auto-normalized to `creates` with a
|
|
20
|
+
* logged warning (#4496 fix 5): a refactor declaration against a
|
|
21
|
+
* path with no history is deterministically a create, so rejecting it
|
|
22
|
+
* only forces a reject→amend→re-persist cycle for a mechanical rewrite.
|
|
23
|
+
* - `refactors-existing` (via `changes`) + path **absent but present in
|
|
24
|
+
* that ref's history** → hard error naming the removing commit and, when
|
|
25
|
+
* git detects one, the rename target (Story #5265). The normalization
|
|
26
|
+
* rescues a mislabel; it must not rescue a plan authored against a file
|
|
27
|
+
* the tree deleted, which propagates into acceptance criteria nothing
|
|
28
|
+
* can satisfy. Genuine mismatches keep failing — a `references`-sourced
|
|
29
|
+
* `refactors-existing` on an absent path is a missing read dependency
|
|
30
|
+
* and stays an error.
|
|
25
31
|
* - `exists` + path **absent** → error (read dependency missing).
|
|
26
32
|
* - `deletes` + path **absent** → error (nothing to delete).
|
|
27
33
|
*
|
|
@@ -78,6 +84,73 @@ function defaultGitRunner({ baseBranchRef, path, cwd }) {
|
|
|
78
84
|
return result.status === 0;
|
|
79
85
|
}
|
|
80
86
|
|
|
87
|
+
/**
|
|
88
|
+
* Parse the `--name-status` line for the commit that last touched `path`
|
|
89
|
+
* into the history verdict (Story #5265).
|
|
90
|
+
*
|
|
91
|
+
* `-M` reports a rename as `R<score>\t<old>\t<new>`. The scan is run over the
|
|
92
|
+
* commit's **whole** rename diff rather than a pathspec-limited one on
|
|
93
|
+
* purpose: rename detection pairs a delete with an add, and restricting the
|
|
94
|
+
* pathspec to the source path filters the add out, so git falls back to
|
|
95
|
+
* reporting a plain `D` and the target is lost. No matching `R` line means
|
|
96
|
+
* the path was deleted outright — still a removal, just without a successor
|
|
97
|
+
* to name.
|
|
98
|
+
*
|
|
99
|
+
* @param {string} stdout
|
|
100
|
+
* @param {string} path
|
|
101
|
+
* @returns {string|null} The rename target, or `null`.
|
|
102
|
+
*/
|
|
103
|
+
function parseRenameTarget(stdout, path) {
|
|
104
|
+
for (const line of String(stdout ?? '').split('\n')) {
|
|
105
|
+
const fields = line.split('\t');
|
|
106
|
+
if (fields.length < 3) continue;
|
|
107
|
+
if (!fields[0].startsWith('R')) continue;
|
|
108
|
+
if (fields[1].trim() !== path) continue;
|
|
109
|
+
const target = fields[2].trim();
|
|
110
|
+
if (target) return target;
|
|
111
|
+
}
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Default git **history** probe: did `baseBranchRef` ever track `path`, and
|
|
117
|
+
* if so, which commit stopped tracking it (Story #5265)?
|
|
118
|
+
*
|
|
119
|
+
* The existence probe above cannot tell a mechanical mislabel ("extend a file
|
|
120
|
+
* I am actually creating") from a plan authored against stale documentation
|
|
121
|
+
* ("extend a file deleted three weeks ago"). Both look identical at the tip —
|
|
122
|
+
* the path is absent — and only history separates them. Injectable exactly
|
|
123
|
+
* like {@link defaultGitRunner} so the discrimination is unit-testable with
|
|
124
|
+
* no repository fixture.
|
|
125
|
+
*
|
|
126
|
+
* Fails **open**: an unreadable ref, a git that errors, or an empty history
|
|
127
|
+
* all report `hadHistory: false`, which preserves the pre-#5265
|
|
128
|
+
* auto-normalisation rather than manufacturing a hard error out of a probe
|
|
129
|
+
* failure.
|
|
130
|
+
*
|
|
131
|
+
* @param {{ baseBranchRef: string, path: string, cwd?: string }} opts
|
|
132
|
+
* @returns {{ hadHistory: boolean, commit: string|null, renamedTo: string|null }}
|
|
133
|
+
*/
|
|
134
|
+
function defaultHistoryRunner({ baseBranchRef, path, cwd }) {
|
|
135
|
+
const absent = { hadHistory: false, commit: null, renamedTo: null };
|
|
136
|
+
const root = cwd ?? process.cwd();
|
|
137
|
+
const last = gitSpawn(root, 'rev-list', '-1', baseBranchRef, '--', path);
|
|
138
|
+
const commit = last.status === 0 ? String(last.stdout ?? '').trim() : '';
|
|
139
|
+
if (!commit) return absent;
|
|
140
|
+
const status = gitSpawn(
|
|
141
|
+
root,
|
|
142
|
+
'show',
|
|
143
|
+
'--name-status',
|
|
144
|
+
'-M',
|
|
145
|
+
'--diff-filter=R',
|
|
146
|
+
'--format=',
|
|
147
|
+
commit,
|
|
148
|
+
);
|
|
149
|
+
const renamedTo =
|
|
150
|
+
status.status === 0 ? parseRenameTarget(status.stdout, path) : null;
|
|
151
|
+
return { hadHistory: true, commit, renamedTo };
|
|
152
|
+
}
|
|
153
|
+
|
|
81
154
|
/**
|
|
82
155
|
* Pull every `(path, assumption, source)` triple from a Story body.
|
|
83
156
|
* `source` is one of `'changes' | 'references'` so error messages can
|
|
@@ -168,8 +241,11 @@ export function hasLegacyChangeBullets(story) {
|
|
|
168
241
|
* - `'predecessor-conflict'` — wave-aware: a concurrent Story (no
|
|
169
242
|
* `depends_on` ordering) also creates this path. Cross-references the
|
|
170
243
|
* shared-editor conflict finding rather than re-deriving its prose.
|
|
244
|
+
* - `'present-was-removed'` — the base branch **once tracked** this path
|
|
245
|
+
* and no longer does (Story #5265). Names the removing commit, and the
|
|
246
|
+
* rename target when git detected one.
|
|
171
247
|
*
|
|
172
|
-
* @param {{ slug: string, source: string, path: string, assumption: string, expected: string, producerSlug?: string }} mismatch
|
|
248
|
+
* @param {{ slug: string, source: string, path: string, assumption: string, expected: string, producerSlug?: string, removedInCommit?: string, renamedTo?: string|null }} mismatch
|
|
173
249
|
* @returns {string}
|
|
174
250
|
*/
|
|
175
251
|
function renderMismatch({
|
|
@@ -179,6 +255,8 @@ function renderMismatch({
|
|
|
179
255
|
assumption,
|
|
180
256
|
expected,
|
|
181
257
|
producerSlug,
|
|
258
|
+
removedInCommit,
|
|
259
|
+
renamedTo,
|
|
182
260
|
}) {
|
|
183
261
|
if (expected === 'refactors-existing') {
|
|
184
262
|
return `"${slug}" → body.${source} declares assumption="${assumption}" for ${path} but predecessor Story "${producerSlug}" already creates that path — declare assumption="refactors-existing" instead (the file exists in the simulated post-predecessor tree).`;
|
|
@@ -189,9 +267,48 @@ function renderMismatch({
|
|
|
189
267
|
if (expected === 'present') {
|
|
190
268
|
return `"${slug}" → body.${source} declares assumption="${assumption}" for ${path} but the path is absent at the base branch.`;
|
|
191
269
|
}
|
|
270
|
+
if (expected === 'present-was-removed') {
|
|
271
|
+
return renderRemovedPathMismatch({
|
|
272
|
+
slug,
|
|
273
|
+
source,
|
|
274
|
+
path,
|
|
275
|
+
assumption,
|
|
276
|
+
removedInCommit,
|
|
277
|
+
renamedTo,
|
|
278
|
+
});
|
|
279
|
+
}
|
|
192
280
|
return `"${slug}" → body.${source} declares assumption="${assumption}" for ${path} but the path already exists at the base branch.`;
|
|
193
281
|
}
|
|
194
282
|
|
|
283
|
+
/**
|
|
284
|
+
* Render the stale-documentation refusal (Story #5265).
|
|
285
|
+
*
|
|
286
|
+
* The auto-normalisation this replaces is right for a path that never
|
|
287
|
+
* existed and wrong for one the base branch used to track: "extend
|
|
288
|
+
* `<deleted file>`" is not a mechanical mislabel a rewrite can fix — it is a
|
|
289
|
+
* plan authored against documentation the tree has outgrown, and rewriting it
|
|
290
|
+
* to `creates` would resurrect a file somebody deliberately removed and
|
|
291
|
+
* propagate the stale premise into acceptance criteria nothing can satisfy.
|
|
292
|
+
* The removing commit is named because it is the shortest route to *what
|
|
293
|
+
* replaced it*.
|
|
294
|
+
*
|
|
295
|
+
* @param {{ slug: string, source: string, path: string, assumption: string, removedInCommit?: string, renamedTo?: string|null }} mismatch
|
|
296
|
+
* @returns {string}
|
|
297
|
+
*/
|
|
298
|
+
function renderRemovedPathMismatch({
|
|
299
|
+
slug,
|
|
300
|
+
source,
|
|
301
|
+
path,
|
|
302
|
+
assumption,
|
|
303
|
+
removedInCommit,
|
|
304
|
+
renamedTo,
|
|
305
|
+
}) {
|
|
306
|
+
const successor = renamedTo
|
|
307
|
+
? ` git detects it was renamed to ${renamedTo} — retarget the declaration there.`
|
|
308
|
+
: ' Retarget the declaration at the path that replaced it, or declare assumption="creates" if this Story genuinely reintroduces the file.';
|
|
309
|
+
return `"${slug}" → body.${source} declares assumption="${assumption}" for ${path} but the base branch removed that path in commit ${removedInCommit}. The plan is authored against stale documentation, not a mislabelled create, so it is refused rather than normalized.${successor}`;
|
|
310
|
+
}
|
|
311
|
+
|
|
195
312
|
/**
|
|
196
313
|
* Render an auto-normalization (#4496 fix 5) into a stable warning string.
|
|
197
314
|
* Kept pure and exported through the report so callers log a
|
|
@@ -290,12 +407,21 @@ function predecessorMutator(index, path, predecessors) {
|
|
|
290
407
|
* @param {object} opts
|
|
291
408
|
* @param {object[]} opts.tickets
|
|
292
409
|
* @param {string} opts.baseBranchRef
|
|
293
|
-
* @param {Function} [opts.gitRunner]
|
|
410
|
+
* @param {Function} [opts.gitRunner] Existence probe at `baseBranchRef`.
|
|
411
|
+
* @param {Function} [opts.historyRunner] History probe (Story #5265),
|
|
412
|
+
* injectable exactly like `gitRunner`; returns
|
|
413
|
+
* `{ hadHistory, commit, renamedTo }`.
|
|
294
414
|
* @param {string} [opts.cwd]
|
|
295
|
-
* @returns {{ errors: string[], warnings: string[], mismatches: Array }}
|
|
415
|
+
* @returns {{ errors: string[], warnings: string[], mismatches: Array, normalizations: Array }}
|
|
296
416
|
*/
|
|
297
417
|
export function validateStoryFileAssumptions(opts) {
|
|
298
|
-
const {
|
|
418
|
+
const {
|
|
419
|
+
tickets,
|
|
420
|
+
baseBranchRef,
|
|
421
|
+
gitRunner = defaultGitRunner,
|
|
422
|
+
historyRunner = defaultHistoryRunner,
|
|
423
|
+
cwd,
|
|
424
|
+
} = opts;
|
|
299
425
|
if (!baseBranchRef || typeof baseBranchRef !== 'string') {
|
|
300
426
|
throw new Error(
|
|
301
427
|
'validateStoryFileAssumptions: baseBranchRef is required and must be a string.',
|
|
@@ -307,6 +433,15 @@ export function validateStoryFileAssumptions(opts) {
|
|
|
307
433
|
const mismatches = [];
|
|
308
434
|
const normalizations = [];
|
|
309
435
|
const probeCache = new Map();
|
|
436
|
+
const historyCache = new Map();
|
|
437
|
+
const probeHistory = (path) =>
|
|
438
|
+
probeRemoval({
|
|
439
|
+
historyRunner,
|
|
440
|
+
baseBranchRef,
|
|
441
|
+
path,
|
|
442
|
+
cwd,
|
|
443
|
+
cache: historyCache,
|
|
444
|
+
});
|
|
310
445
|
|
|
311
446
|
// Wave-aware setup (Story #3960): transitive predecessor sets over the
|
|
312
447
|
// story-level `depends_on` graph, plus per-path create/delete indices so
|
|
@@ -371,13 +506,14 @@ export function validateStoryFileAssumptions(opts) {
|
|
|
371
506
|
// Auto-normalization (#4496 fix 5): a deterministic
|
|
372
507
|
// `refactors-existing`→`creates` rewrite is a warning, never a
|
|
373
508
|
// rejection — genuine mismatches keep flowing to `errors`.
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
509
|
+
const { kind, finding } = classifyMismatch(mismatch, probeHistory);
|
|
510
|
+
if (kind === 'normalization') {
|
|
511
|
+
normalizations.push(finding);
|
|
512
|
+
warnings.push(renderNormalization(finding));
|
|
377
513
|
continue;
|
|
378
514
|
}
|
|
379
|
-
mismatches.push(
|
|
380
|
-
errors.push(renderMismatch(
|
|
515
|
+
mismatches.push(finding);
|
|
516
|
+
errors.push(renderMismatch(finding));
|
|
381
517
|
continue;
|
|
382
518
|
}
|
|
383
519
|
// Wave-aware concurrent-create check (Story #3960): two Stories with
|
|
@@ -413,6 +549,72 @@ export function validateStoryFileAssumptions(opts) {
|
|
|
413
549
|
return { errors, warnings, mismatches, normalizations };
|
|
414
550
|
}
|
|
415
551
|
|
|
552
|
+
/**
|
|
553
|
+
* Route one mismatch to the errors channel or the normalization channel
|
|
554
|
+
* (Story #5265).
|
|
555
|
+
*
|
|
556
|
+
* `checkAssumption` marks a `changes`-sourced `refactors-existing` on an
|
|
557
|
+
* absent path with `normalizedTo: 'creates'` — the #4496 rescue. Whether that
|
|
558
|
+
* rescue actually applies is a *history* question the pure rules table cannot
|
|
559
|
+
* answer, so it is resolved here: no history keeps the rescue, history ending
|
|
560
|
+
* in a removal converts it into a refusal that names the commit.
|
|
561
|
+
*
|
|
562
|
+
* @param {object} mismatch
|
|
563
|
+
* @param {(path: string) => ({ commit: string|null, renamedTo: string|null }|null)} probeHistory
|
|
564
|
+
* @returns {{ kind: 'error'|'normalization', finding: object }}
|
|
565
|
+
*/
|
|
566
|
+
function classifyMismatch(mismatch, probeHistory) {
|
|
567
|
+
if (mismatch.normalizedTo !== 'creates') {
|
|
568
|
+
return { kind: 'error', finding: mismatch };
|
|
569
|
+
}
|
|
570
|
+
const removal = probeHistory(mismatch.path);
|
|
571
|
+
if (removal === null) return { kind: 'normalization', finding: mismatch };
|
|
572
|
+
return {
|
|
573
|
+
kind: 'error',
|
|
574
|
+
finding: {
|
|
575
|
+
slug: mismatch.slug,
|
|
576
|
+
source: mismatch.source,
|
|
577
|
+
path: mismatch.path,
|
|
578
|
+
assumption: mismatch.assumption,
|
|
579
|
+
expected: 'present-was-removed',
|
|
580
|
+
actual: 'removed',
|
|
581
|
+
removedInCommit: removal.commit,
|
|
582
|
+
renamedTo: removal.renamedTo,
|
|
583
|
+
},
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* Memoized history probe: `{ commit, renamedTo }` when `baseBranchRef` once
|
|
589
|
+
* tracked `path` and no longer does, `null` when it never did (Story #5265).
|
|
590
|
+
*
|
|
591
|
+
* One cache per validation run, keyed on the path, because a plan commonly
|
|
592
|
+
* declares the same path across several Stories and the probe costs two git
|
|
593
|
+
* processes. A runner that throws is absorbed as "no history": the whole
|
|
594
|
+
* point of the discrimination is to *add* a refusal for a provable stale
|
|
595
|
+
* declaration, never to convert a probe failure into one.
|
|
596
|
+
*
|
|
597
|
+
* @param {{ historyRunner: Function, baseBranchRef: string, path: string, cwd?: string, cache: Map<string, object|null> }} args
|
|
598
|
+
* @returns {{ commit: string|null, renamedTo: string|null }|null}
|
|
599
|
+
*/
|
|
600
|
+
function probeRemoval({ historyRunner, baseBranchRef, path, cwd, cache }) {
|
|
601
|
+
if (cache.has(path)) return cache.get(path);
|
|
602
|
+
let verdict = null;
|
|
603
|
+
try {
|
|
604
|
+
const report = historyRunner({ baseBranchRef, path, cwd });
|
|
605
|
+
if (report?.hadHistory) {
|
|
606
|
+
verdict = {
|
|
607
|
+
commit: report.commit ?? null,
|
|
608
|
+
renamedTo: report.renamedTo ?? null,
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
} catch {
|
|
612
|
+
verdict = null;
|
|
613
|
+
}
|
|
614
|
+
cache.set(path, verdict);
|
|
615
|
+
return verdict;
|
|
616
|
+
}
|
|
617
|
+
|
|
416
618
|
/**
|
|
417
619
|
* Find the first *concurrent* co-creator of `path` for the Story `slug`:
|
|
418
620
|
* another Story that declares `creates` on the same path with no
|
|
@@ -50,6 +50,19 @@ import { parsePrunedRefs } from './prune.js';
|
|
|
50
50
|
|
|
51
51
|
const TAG = '[git-cleanup]';
|
|
52
52
|
|
|
53
|
+
/**
|
|
54
|
+
* The detection signal the weak-signal guard withholds on, and the reason
|
|
55
|
+
* it records (Story #5283). `content-merged` comes from
|
|
56
|
+
* `git merge-tree --write-tree` finding the branch's changes already
|
|
57
|
+
* present in the base by *some* route — it cannot tell a squash-merge
|
|
58
|
+
* from a branch whose every change was independently reverted, so it is
|
|
59
|
+
* the one signal an unattended `--yes` run must not delete a remote ref
|
|
60
|
+
* on. Named here so the guard, the renderer and the tests share one
|
|
61
|
+
* spelling.
|
|
62
|
+
*/
|
|
63
|
+
const WEAK_SIGNAL_DETECTOR = 'content-merged';
|
|
64
|
+
const WEAK_SIGNAL_REASON = 'weak-signal-needs-confirmation';
|
|
65
|
+
|
|
53
66
|
function evaluateLocalBranch({
|
|
54
67
|
branch,
|
|
55
68
|
baseBranch,
|
|
@@ -167,16 +180,45 @@ function evaluateLocalBranch({
|
|
|
167
180
|
* collections. Deletion is unchanged: a remote-only candidate still needs
|
|
168
181
|
* `--remote`, whichever signal detected it.
|
|
169
182
|
*/
|
|
183
|
+
/**
|
|
184
|
+
* Normalize whatever `prIndexFn` returned into the
|
|
185
|
+
* `{ index, complete }` pair {@link probeAllPrs} emits (Story #5283).
|
|
186
|
+
*
|
|
187
|
+
* The seam is injectable, and a caller that hands back a bare `Map` —
|
|
188
|
+
* every pre-#5283 double does — means "here is the page" without
|
|
189
|
+
* claiming it was exhaustive. That reads as `complete: false`, which
|
|
190
|
+
* keeps the per-branch fallback armed: the conservative direction, since
|
|
191
|
+
* a wrongly-complete page suppresses a probe that would have found a
|
|
192
|
+
* real PR.
|
|
193
|
+
*
|
|
194
|
+
* @param {unknown} value
|
|
195
|
+
* @returns {{ index: Map, complete: boolean }}
|
|
196
|
+
*/
|
|
197
|
+
function normalizePrIndex(value) {
|
|
198
|
+
if (value instanceof Map) return { index: value, complete: false };
|
|
199
|
+
return {
|
|
200
|
+
index: value?.index instanceof Map ? value.index : new Map(),
|
|
201
|
+
complete: value?.complete === true,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
170
205
|
function buildGuardedPrProbe({ cwd, prIndexFn, prFallback, onDegrade }) {
|
|
171
|
-
let
|
|
206
|
+
let bulk;
|
|
172
207
|
try {
|
|
173
|
-
|
|
208
|
+
bulk = normalizePrIndex(prIndexFn(cwd));
|
|
174
209
|
} catch (err) {
|
|
175
210
|
onDegrade(err);
|
|
176
|
-
|
|
211
|
+
bulk = { index: new Map(), complete: false };
|
|
177
212
|
}
|
|
213
|
+
const { index: prIndex, complete } = bulk;
|
|
178
214
|
return (branch, c) => {
|
|
179
215
|
if (prIndex.has(branch)) return prIndex.get(branch);
|
|
216
|
+
// Story #5283: a complete page listed every PR in the repo, so this
|
|
217
|
+
// head ref demonstrably has none. Probing it per-branch spends a `gh`
|
|
218
|
+
// spawn to be told the same thing — once per PR-less branch, which on
|
|
219
|
+
// a checkout full of local scratch branches is the whole point of the
|
|
220
|
+
// bulk fetch undone.
|
|
221
|
+
if (complete) return null;
|
|
180
222
|
try {
|
|
181
223
|
return prFallback(branch, c);
|
|
182
224
|
} catch (err) {
|
|
@@ -302,6 +344,21 @@ function worktreeRootFor(cand) {
|
|
|
302
344
|
/**
|
|
303
345
|
* Pure-ish: execute the branch reap plan.
|
|
304
346
|
*
|
|
347
|
+
* ## Weak-signal guard (Story #5283)
|
|
348
|
+
*
|
|
349
|
+
* `skipWeakSignal` withholds the **remote** delete of any candidate
|
|
350
|
+
* detected only by content-equivalence, recording it on `remote[]` as
|
|
351
|
+
* `{ skipped: true, reason: 'weak-signal-needs-confirmation' }` instead
|
|
352
|
+
* of issuing `git push --delete`. The branch-phase driver arms it on the
|
|
353
|
+
* `--yes` path unless the operator passed `--include-content-merged`,
|
|
354
|
+
* mirroring the stash phase's `--drop-stashes` allowlist: an unattended
|
|
355
|
+
* run may not destroy a remote ref on the weakest merge signal without
|
|
356
|
+
* being told to. The interactive path leaves it disarmed — the prompt
|
|
357
|
+
* already names the weak-signal count and the operator answered it.
|
|
358
|
+
*
|
|
359
|
+
* Local deletion is deliberately untouched: a local ref is recoverable
|
|
360
|
+
* from the remote, which is exactly what the guard preserves.
|
|
361
|
+
*
|
|
305
362
|
* Ref-reap is decoupled from worktree-reap (Story #3598): every
|
|
306
363
|
* already-merged candidate has its local ref (and remote ref, in
|
|
307
364
|
* `--remote` mode) deleted regardless of whether its worktree directory
|
|
@@ -319,12 +376,21 @@ export function executeCleanup(ctx) {
|
|
|
319
376
|
remote,
|
|
320
377
|
removeWorktreeFn = removeWorktree,
|
|
321
378
|
deleteLocalFn = (b, c) => deleteBranchLocal(b, { cwd: c, force: true }),
|
|
322
|
-
deleteRemoteFn = (b, c) => deleteBranchRemote(b, { cwd: c }),
|
|
379
|
+
deleteRemoteFn = (b, c, r) => deleteBranchRemote(b, { cwd: c, remote: r }),
|
|
323
380
|
pruneRemoteFn = (c, r) => pruneRemoteTracking(c, r, parsePrunedRefs),
|
|
324
381
|
recordPendingCleanupFn = recordPendingCleanup,
|
|
325
382
|
remoteName = 'origin',
|
|
383
|
+
skipWeakSignal = false,
|
|
326
384
|
logger = Logger,
|
|
327
385
|
} = ctx;
|
|
386
|
+
// The remote name belongs to `executeCleanup`, not to the per-candidate
|
|
387
|
+
// reap helper, so bind it here (Story #5283). The default deleter used
|
|
388
|
+
// to drop it and let `deleteBranchRemote` fall back to `origin`, which
|
|
389
|
+
// sent every `--remote` delete of an `upstream`-configured checkout at
|
|
390
|
+
// the wrong remote. Binding — rather than closing over it — also hands
|
|
391
|
+
// an injected deleter the same `(branch, cwd, remote)` triple the git
|
|
392
|
+
// invocation is built from, so a test can see which remote was targeted.
|
|
393
|
+
const boundDeleteRemote = (b, c) => deleteRemoteFn(b, c, remoteName);
|
|
328
394
|
const worktrees = [];
|
|
329
395
|
const local = [];
|
|
330
396
|
const remoteResults = [];
|
|
@@ -347,11 +413,31 @@ export function executeCleanup(ctx) {
|
|
|
347
413
|
worktreeRoot: worktreeRootFor(cand),
|
|
348
414
|
});
|
|
349
415
|
if (!reapLocalRef({ cand, deleteLocalFn, cwd, local, failures })) continue;
|
|
350
|
-
if (remote)
|
|
351
|
-
|
|
416
|
+
if (!remote) continue;
|
|
417
|
+
if (skipWeakSignal && cand.detectedBy === WEAK_SIGNAL_DETECTOR) {
|
|
418
|
+
remoteResults.push({
|
|
419
|
+
branch: cand.branch,
|
|
420
|
+
ok: true,
|
|
421
|
+
skipped: true,
|
|
422
|
+
reason: WEAK_SIGNAL_REASON,
|
|
423
|
+
alreadyGone: false,
|
|
424
|
+
detectedBy: cand.detectedBy,
|
|
425
|
+
});
|
|
426
|
+
continue;
|
|
427
|
+
}
|
|
428
|
+
reapRemoteRef({
|
|
429
|
+
cand,
|
|
430
|
+
deleteRemoteFn: boundDeleteRemote,
|
|
431
|
+
cwd,
|
|
432
|
+
remoteResults,
|
|
433
|
+
failures,
|
|
434
|
+
});
|
|
352
435
|
}
|
|
353
436
|
let prune = null;
|
|
354
|
-
|
|
437
|
+
// Prune drops the tracking refs a remote *delete* left behind. A run
|
|
438
|
+
// whose every remote candidate was withheld deleted nothing, so there
|
|
439
|
+
// is nothing stale to prune and no reason to spend the fetch.
|
|
440
|
+
if (remote && remoteResults.some((r) => !r.skipped)) {
|
|
355
441
|
prune = buildPruneSummary({ pruneRemoteFn, cwd, remoteName, failures });
|
|
356
442
|
}
|
|
357
443
|
return {
|
|
@@ -333,13 +333,28 @@ export function probeLatestPr(branch, cwd, runGh = defaultGhRunner) {
|
|
|
333
333
|
* fallback for head refs absent from this page (a branch whose PR fell
|
|
334
334
|
* outside the `--limit` window).
|
|
335
335
|
*
|
|
336
|
-
* Returns an empty
|
|
336
|
+
* Returns an empty index on any failure (non-array, empty, or malformed
|
|
337
337
|
* JSON) so the caller transparently falls back to per-branch probing.
|
|
338
338
|
*
|
|
339
|
+
* ## `complete` — when absence from the page is proof (Story #5283)
|
|
340
|
+
*
|
|
341
|
+
* The returned `complete` flag says whether the page enumerated **every**
|
|
342
|
+
* PR in the repository: true when `gh` returned fewer rows than the
|
|
343
|
+
* `--limit` it was given, which is the only way to know the window did
|
|
344
|
+
* not truncate. On a complete page a head ref's absence is not "the PR
|
|
345
|
+
* fell outside the window" — it is proof that no PR covers that ref at
|
|
346
|
+
* all, so the caller's per-branch fallback can only re-derive the same
|
|
347
|
+
* `null` at the cost of one `gh` spawn per PR-less branch.
|
|
348
|
+
*
|
|
349
|
+
* Every failure mode reports `complete: false`, because an unusable page
|
|
350
|
+
* proves nothing: empty stdout (a degraded `gh`), unparseable JSON, and a
|
|
351
|
+
* non-array payload must all leave the fallback armed. A *parsed* empty
|
|
352
|
+
* array is genuinely complete — a repository with no PRs at all.
|
|
353
|
+
*
|
|
339
354
|
* @param {string} cwd
|
|
340
355
|
* @param {(args: string[], opts: { cwd: string }) => string} runGh
|
|
341
356
|
* @param {number} limit Max rows to fetch in the single page (default 1000).
|
|
342
|
-
* @returns {Map<string, { number: number, state: string, mergedAt: string|null, closedAt: string|null, headRefOid: string|null }
|
|
357
|
+
* @returns {{ index: Map<string, { number: number, state: string, mergedAt: string|null, closedAt: string|null, headRefOid: string|null }>, complete: boolean }}
|
|
343
358
|
*/
|
|
344
359
|
export function probeAllPrs(cwd, runGh = defaultGhRunner, limit = 1000) {
|
|
345
360
|
const out = runGh(
|
|
@@ -357,14 +372,15 @@ export function probeAllPrs(cwd, runGh = defaultGhRunner, limit = 1000) {
|
|
|
357
372
|
);
|
|
358
373
|
const trimmed = (out ?? '').trim();
|
|
359
374
|
const index = new Map();
|
|
360
|
-
|
|
375
|
+
const truncated = { index, complete: false };
|
|
376
|
+
if (!trimmed) return truncated;
|
|
361
377
|
let parsed;
|
|
362
378
|
try {
|
|
363
379
|
parsed = JSON.parse(trimmed);
|
|
364
380
|
} catch {
|
|
365
|
-
return
|
|
381
|
+
return truncated;
|
|
366
382
|
}
|
|
367
|
-
if (!Array.isArray(parsed)) return
|
|
383
|
+
if (!Array.isArray(parsed)) return truncated;
|
|
368
384
|
for (const row of parsed) {
|
|
369
385
|
const headRefName =
|
|
370
386
|
typeof row?.headRefName === 'string' ? row.headRefName : null;
|
|
@@ -379,7 +395,7 @@ export function probeAllPrs(cwd, runGh = defaultGhRunner, limit = 1000) {
|
|
|
379
395
|
headRefOid: row.headRefOid ?? null,
|
|
380
396
|
});
|
|
381
397
|
}
|
|
382
|
-
return index;
|
|
398
|
+
return { index, complete: parsed.length < limit };
|
|
383
399
|
}
|
|
384
400
|
|
|
385
401
|
const SHA_RE = /^[0-9a-f]{7,40}$/i;
|
|
@@ -22,10 +22,23 @@ const CLI_OPTIONS = {
|
|
|
22
22
|
include: { type: 'string', multiple: true, default: [] },
|
|
23
23
|
exclude: { type: 'string', multiple: true, default: [] },
|
|
24
24
|
'drop-stashes': { type: 'string', multiple: true, default: [] },
|
|
25
|
+
'include-content-merged': { type: 'boolean', default: false },
|
|
25
26
|
base: { type: 'string' },
|
|
26
27
|
cwd: { type: 'string' },
|
|
27
28
|
};
|
|
28
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Normalize a repeatable flag's parsed value to a list. `parseArgs` yields
|
|
32
|
+
* an array for a `multiple: true` option, but only once the flag appears;
|
|
33
|
+
* this keeps the three repeatable flags reading identically.
|
|
34
|
+
*
|
|
35
|
+
* @param {unknown} value
|
|
36
|
+
* @returns {string[]}
|
|
37
|
+
*/
|
|
38
|
+
function asList(value) {
|
|
39
|
+
return Array.isArray(value) ? value : [];
|
|
40
|
+
}
|
|
41
|
+
|
|
29
42
|
function resolveActivePhases(values) {
|
|
30
43
|
const anyPhaseFlag =
|
|
31
44
|
values['fast-forward-main'] === true ||
|
|
@@ -44,6 +57,14 @@ function resolveActivePhases(values) {
|
|
|
44
57
|
/**
|
|
45
58
|
* Pure: parse argv into the normalized CLI option bag.
|
|
46
59
|
*
|
|
60
|
+
* Every flag the CLI honours MUST be declared in {@link CLI_OPTIONS}:
|
|
61
|
+
* `parseArgs` runs with `strict: false`, so an undeclared flag is not
|
|
62
|
+
* rejected — it is silently absorbed and the option it was meant to set
|
|
63
|
+
* stays at its default. For `--include-content-merged` that failure mode
|
|
64
|
+
* is destructive in the quiet direction's opposite: the operator asks to
|
|
65
|
+
* include the weak-signal candidates, the flag is dropped, and the run
|
|
66
|
+
* withholds them anyway.
|
|
67
|
+
*
|
|
47
68
|
* @param {string[]} argv
|
|
48
69
|
* @returns {{
|
|
49
70
|
* dryRun: boolean,
|
|
@@ -55,6 +76,7 @@ function resolveActivePhases(values) {
|
|
|
55
76
|
* include: string[],
|
|
56
77
|
* exclude: string[],
|
|
57
78
|
* dropStashes: string[],
|
|
79
|
+
* includeContentMerged: boolean,
|
|
58
80
|
* base: string|null,
|
|
59
81
|
* cwd: string|null,
|
|
60
82
|
* }}
|
|
@@ -73,11 +95,10 @@ export function parseCleanupArgs(argv) {
|
|
|
73
95
|
yes: values.yes === true,
|
|
74
96
|
json: values.json === true,
|
|
75
97
|
phases: resolveActivePhases(values),
|
|
76
|
-
include:
|
|
77
|
-
exclude:
|
|
78
|
-
dropStashes:
|
|
79
|
-
|
|
80
|
-
: [],
|
|
98
|
+
include: asList(values.include),
|
|
99
|
+
exclude: asList(values.exclude),
|
|
100
|
+
dropStashes: asList(values['drop-stashes']),
|
|
101
|
+
includeContentMerged: values['include-content-merged'] === true,
|
|
81
102
|
base: typeof values.base === 'string' ? values.base : null,
|
|
82
103
|
cwd: typeof values.cwd === 'string' ? values.cwd : null,
|
|
83
104
|
};
|
|
@@ -218,7 +218,8 @@ function countActionableCandidates(candidates, remote) {
|
|
|
218
218
|
*
|
|
219
219
|
* @param {object} state
|
|
220
220
|
* @param {object} state.plan Output of `planCleanup`.
|
|
221
|
-
* @param {object} state.opts CLI options (`dryRun`, `yes`, `remote
|
|
221
|
+
* @param {object} state.opts CLI options (`dryRun`, `yes`, `remote`,
|
|
222
|
+
* `includeContentMerged`).
|
|
222
223
|
* @param {string} state.cwd Working directory.
|
|
223
224
|
*/
|
|
224
225
|
export function decideBranchPhase(state) {
|
|
@@ -254,7 +255,17 @@ export function decideBranchPhase(state) {
|
|
|
254
255
|
executeArgs,
|
|
255
256
|
};
|
|
256
257
|
}
|
|
257
|
-
|
|
258
|
+
// Story #5283: the unattended arm. Nobody saw the weak-signal note
|
|
259
|
+
// above, so a `content-merged` candidate's remote ref is withheld
|
|
260
|
+
// unless the operator opted in with `--include-content-merged`.
|
|
261
|
+
return {
|
|
262
|
+
kind: 'execute',
|
|
263
|
+
plan,
|
|
264
|
+
executeArgs: {
|
|
265
|
+
...executeArgs,
|
|
266
|
+
skipWeakSignal: opts.includeContentMerged !== true,
|
|
267
|
+
},
|
|
268
|
+
};
|
|
258
269
|
}
|
|
259
270
|
|
|
260
271
|
/**
|
|
@@ -227,18 +227,40 @@ export function renderLatestPrSkipLine(skip) {
|
|
|
227
227
|
return null;
|
|
228
228
|
}
|
|
229
229
|
|
|
230
|
-
/**
|
|
230
|
+
/**
|
|
231
|
+
* Remedy text per withheld-delete reason (Story #5283). A withheld entry
|
|
232
|
+
* that named no way to proceed would read as an unexplained refusal, so
|
|
233
|
+
* every reason the executor can record gets its own next step here.
|
|
234
|
+
*/
|
|
235
|
+
const WITHHELD_HINTS = {
|
|
236
|
+
'weak-signal-needs-confirmation':
|
|
237
|
+
'detected only by content-equivalence; re-run interactively or pass --include-content-merged',
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Pure: render a per-branch execution line.
|
|
242
|
+
*
|
|
243
|
+
* An entry marked `skipped` is a delete the executor deliberately
|
|
244
|
+
* withheld rather than one it attempted — it must not render with the
|
|
245
|
+
* `✅` of a completed reap, which is exactly the misreport that would let
|
|
246
|
+
* an operator believe an unattended run had cleaned up a ref it left
|
|
247
|
+
* standing.
|
|
248
|
+
*/
|
|
231
249
|
export function renderExecutionLine(entry, scope) {
|
|
232
|
-
const icon = entry.ok ? '✅' : '❌';
|
|
233
250
|
const label = scope.padEnd(8);
|
|
234
|
-
const
|
|
251
|
+
const tagName =
|
|
235
252
|
scope === 'local' || scope === 'remote' ? entry.branch : entry.path;
|
|
253
|
+
if (entry.skipped) {
|
|
254
|
+
const hint = WITHHELD_HINTS[entry.reason];
|
|
255
|
+
return `${TAG} ⏭️ ${label} ${tagName} — withheld${hint ? ` (${hint})` : ` (${entry.reason})`}`;
|
|
256
|
+
}
|
|
257
|
+
const icon = entry.ok ? '✅' : '❌';
|
|
236
258
|
const note = entry.alreadyGone
|
|
237
259
|
? ' (already gone)'
|
|
238
260
|
: entry.dirty
|
|
239
261
|
? ' (forced — was dirty)'
|
|
240
262
|
: '';
|
|
241
|
-
return `${TAG} ${icon} ${label} ${
|
|
263
|
+
return `${TAG} ${icon} ${label} ${tagName}${note}`;
|
|
242
264
|
}
|
|
243
265
|
|
|
244
266
|
/** Pure: render the optional prune line. */
|
|
@@ -286,7 +308,15 @@ export function renderExecutionSummary(result) {
|
|
|
286
308
|
deferredCount > 0
|
|
287
309
|
? ` (${deferredCount} worktree(s) deferred to sweep)`
|
|
288
310
|
: '';
|
|
289
|
-
|
|
311
|
+
// Story #5283: withheld remote entries are recorded on `remote[]` but
|
|
312
|
+
// were never deleted — counting them would overstate the reap.
|
|
313
|
+
const remoteDeleted = result.remote.filter((r) => !r.skipped).length;
|
|
314
|
+
const withheldCount = result.remote.length - remoteDeleted;
|
|
315
|
+
const withheldNote =
|
|
316
|
+
withheldCount > 0
|
|
317
|
+
? ` (${withheldCount} remote delete(s) withheld — weaker signal)`
|
|
318
|
+
: '';
|
|
319
|
+
return `${TAG} ✅ Reaped ${result.local.length} local + ${remoteDeleted} remote + ${result.worktrees.length} worktree(s)${pruneNote}.${deferredNote}${withheldNote}`;
|
|
290
320
|
}
|
|
291
321
|
|
|
292
322
|
const EMPTY_RESULT = Object.freeze({
|