mandrel 2.35.0 → 2.37.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/docs/agentrc-reference.json +3 -1
- package/.agents/docs/configuration.md +27 -0
- package/.agents/rules/testing-standards.md +56 -0
- package/.agents/schemas/agentrc.schema.json +11 -0
- package/.agents/schemas/lifecycle/merge.unlanded.schema.json +2 -1
- package/.agents/schemas/story-deliver-terminal.schema.json +1 -0
- package/.agents/scripts/boot-sweep.js +7 -1
- package/.agents/scripts/check-context-budget.js +195 -103
- package/.agents/scripts/check-cyclomatic.js +112 -42
- package/.agents/scripts/check-doc-links.js +23 -2
- package/.agents/scripts/check-generated-validator.js +202 -0
- package/.agents/scripts/check-lifecycle-lint.js +2 -68
- package/.agents/scripts/check-schema-references.js +2 -28
- package/.agents/scripts/drain-pending-cleanup.js +18 -1
- package/.agents/scripts/git-cleanup.js +2 -0
- package/.agents/scripts/lib/baselines/components.js +32 -2
- package/.agents/scripts/lib/baselines/env-overrides.js +1 -1
- package/.agents/scripts/lib/bootstrap/branch-protection.js +1 -1
- package/.agents/scripts/lib/bootstrap/install-ledger.js +1 -1
- package/.agents/scripts/lib/bootstrap/prompt.js +1 -1
- package/.agents/scripts/lib/bootstrap/quality-bootstrap.js +2 -2
- package/.agents/scripts/lib/checks/loop-health.js +1 -1
- package/.agents/scripts/lib/cli-args.js +0 -2
- package/.agents/scripts/lib/close-validation/gates.js +1 -1
- package/.agents/scripts/lib/close-validation/process.js +1 -1
- package/.agents/scripts/lib/config/ci.js +18 -0
- package/.agents/scripts/lib/config/sync-agentrc.js +1 -1
- package/.agents/scripts/lib/config-settings-schema-delivery.js +13 -0
- package/.agents/scripts/lib/config-settings-schema.js +46 -6
- package/.agents/scripts/lib/coverage-baseline.js +2 -2
- package/.agents/scripts/lib/cpu-pool.js +90 -10
- package/.agents/scripts/lib/crap-utils.js +6 -2
- package/.agents/scripts/lib/cyclomatic-ceiling.js +28 -6
- package/.agents/scripts/lib/cyclomatic-scope.js +144 -0
- package/.agents/scripts/lib/dependency-version.js +86 -0
- package/.agents/scripts/lib/duplicate-search.js +1 -1
- package/.agents/scripts/lib/errors/index.js +1 -1
- package/.agents/scripts/lib/feedback-loop/graduator-core.js +2 -2
- package/.agents/scripts/lib/feedback-loop/prior-feedback-fetcher.js +1 -1
- package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +1 -1
- package/.agents/scripts/lib/generated/agentrc-validator.js +16 -0
- package/.agents/scripts/lib/install-cmd-parser.js +1 -1
- package/.agents/scripts/lib/knip-entry-sync.js +1 -57
- package/.agents/scripts/lib/maintainability-utils.js +108 -10
- package/.agents/scripts/lib/observability/source-classifier.js +1 -1
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/branches.js +22 -7
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes.js +22 -14
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/merged-tip.js +132 -0
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +56 -11
- package/.agents/scripts/lib/orchestration/merge-block-class.js +10 -1
- package/.agents/scripts/lib/orchestration/merge-poll.js +164 -0
- package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +70 -9
- package/.agents/scripts/lib/orchestration/pr-watch.js +1 -30
- package/.agents/scripts/lib/orchestration/reassert-status-column.js +2 -2
- package/.agents/scripts/lib/orchestration/review-providers/codex.js +3 -3
- package/.agents/scripts/lib/orchestration/review-providers/findings-renderer.js +1 -1
- package/.agents/scripts/lib/orchestration/review-providers/native.js +9 -2
- package/.agents/scripts/lib/orchestration/review-providers/review-depth.js +2 -2
- package/.agents/scripts/lib/orchestration/review-providers/security-review.js +3 -3
- package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +145 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +96 -5
- package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +52 -1
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +9 -1
- package/.agents/scripts/lib/orchestration/story-close/format-autofix.js +2 -5
- package/.agents/scripts/lib/orchestration/story-follow-ups.js +1 -1
- package/.agents/scripts/lib/orchestration/ticket-lease.js +34 -9
- package/.agents/scripts/lib/orchestration/ticketing/reads.js +3 -1
- package/.agents/scripts/lib/preflight-runner.js +3 -3
- package/.agents/scripts/lib/qa/qa-session.js +1 -1
- package/.agents/scripts/lib/runtime-deps/manifest.js +1 -7
- package/.agents/scripts/lib/runtime-deps/scan-imports.js +3 -58
- package/.agents/scripts/lib/single-story-sweep/sweep-lock.js +284 -25
- package/.agents/scripts/lib/skills/walk-skill-files.js +1 -1
- package/.agents/scripts/lib/source-text/strip-js-comments.js +115 -0
- package/.agents/scripts/lib/test-isolate/runner.js +3 -3
- package/.agents/scripts/lib/test-runner-contract.js +14 -6
- package/.agents/scripts/lib/test-tiers.js +135 -18
- package/.agents/scripts/lib/transpile.js +16 -3
- package/.agents/scripts/lib/worktree/lifecycle/pending-cleanup.js +169 -31
- package/.agents/scripts/lib/worktree/node-modules-strategy.js +2 -5
- package/.agents/scripts/notify.js +4 -10
- package/.agents/scripts/pr-watch-with-update.js +305 -137
- package/.agents/scripts/providers/github/tickets.js +109 -16
- package/.agents/scripts/quality-preview.js +162 -70
- package/.agents/scripts/run-test-profile.js +8 -5
- package/.agents/scripts/run-tests.js +79 -14
- package/.agents/scripts/single-story-close.js +0 -2
- package/.agents/scripts/single-story-init.js +5 -1
- package/.agents/workflows/audit-documentation.md +5 -6
- package/docs/CHANGELOG.md +50 -0
- package/lib/cli/migrate.js +100 -94
- package/lib/migrations/helpers/retire-agentrc-key.js +200 -0
- package/lib/migrations/index.js +32 -33
- package/lib/migrations/steps/2.1.0-retire-mi-drop-knobs.js +21 -80
- package/lib/migrations/steps/2.1.0-retire-verify-concurrency-cap.js +15 -87
- package/lib/migrations/steps/2.11.0-retire-max-seed-words.js +13 -71
- package/lib/migrations/steps/2.20.0-retire-codebase-snapshot.js +13 -101
- package/lib/migrations/steps/2.32.0-retire-lint-baseline-command.js +13 -100
- package/package.json +9 -5
- package/.agents/scripts/generate-lifecycle-docs.js +0 -237
- package/.agents/scripts/lib/audit-suite/frontmatter-lint.js +0 -32
- package/.agents/scripts/lib/baselines/maintainability-baseline-save.js +0 -37
- package/.agents/scripts/lib/cli/parse-numeric.js +0 -60
- package/.agents/scripts/lib/close-validation/telemetry.js +0 -79
- package/.agents/scripts/lib/orchestration/label-transitions.js +0 -44
- package/.agents/scripts/lib/orchestration/parked-follow-ons.js +0 -147
- package/.agents/scripts/lib/orchestration/phase-runner.js +0 -88
- package/.agents/scripts/lib/orchestration/recut.js +0 -56
|
@@ -17,7 +17,7 @@ import { spawnSync as defaultSpawnSync } from 'node:child_process';
|
|
|
17
17
|
* @param {string} installCmd
|
|
18
18
|
* @returns {{ bin: string, args: string[], shell: boolean }}
|
|
19
19
|
*/
|
|
20
|
-
|
|
20
|
+
function parseInstallCmd(installCmd) {
|
|
21
21
|
const tokens = String(installCmd ?? '')
|
|
22
22
|
.trim()
|
|
23
23
|
.split(/\s+/)
|
|
@@ -59,6 +59,7 @@ import {
|
|
|
59
59
|
resolveKnipEntryPatterns,
|
|
60
60
|
__testing as resolverTesting,
|
|
61
61
|
} from './knip-config-resolver.js';
|
|
62
|
+
import { stripJsComments } from './source-text/strip-js-comments.js';
|
|
62
63
|
|
|
63
64
|
// Only `resolveEntrySync` and `renderEntrySyncReport` are public — they are
|
|
64
65
|
// what `check-knip-entries.js` calls. Everything else is module-private and
|
|
@@ -89,63 +90,6 @@ const INVOCATION_SURFACES = Object.freeze([
|
|
|
89
90
|
{ kind: 'dir', at: SCRIPTS_DIR, test: /\.js$/ },
|
|
90
91
|
]);
|
|
91
92
|
|
|
92
|
-
/**
|
|
93
|
-
* Strip line and block comments from JavaScript source, preserving string and
|
|
94
|
-
* template literals so a `'https://…'` or a `` `${x}//y` `` is not mangled.
|
|
95
|
-
*
|
|
96
|
-
* Comments are stripped before scanning `.js` surfaces so a JSDoc paragraph
|
|
97
|
-
* that merely *names* a CLI ("superseded by `node .agents/scripts/foo.js`")
|
|
98
|
-
* cannot confer liveness on it. Several such mentions exist today; every one
|
|
99
|
-
* of them is prose about a script's internals, not a call.
|
|
100
|
-
*
|
|
101
|
-
* Replaces comment bodies with equivalent whitespace rather than deleting
|
|
102
|
-
* them, so byte offsets and line numbers survive for any future caller that
|
|
103
|
-
* wants to report a position.
|
|
104
|
-
*
|
|
105
|
-
* @param {string} source
|
|
106
|
-
* @returns {string}
|
|
107
|
-
*/
|
|
108
|
-
function stripJsComments(source) {
|
|
109
|
-
const text = String(source ?? '');
|
|
110
|
-
let out = '';
|
|
111
|
-
let i = 0;
|
|
112
|
-
const blank = (s) => s.replace(/[^\n]/g, ' ');
|
|
113
|
-
while (i < text.length) {
|
|
114
|
-
const two = text.slice(i, i + 2);
|
|
115
|
-
if (two === '//') {
|
|
116
|
-
const end = text.indexOf('\n', i);
|
|
117
|
-
const stop = end === -1 ? text.length : end;
|
|
118
|
-
out += blank(text.slice(i, stop));
|
|
119
|
-
i = stop;
|
|
120
|
-
} else if (two === '/*') {
|
|
121
|
-
const end = text.indexOf('*/', i + 2);
|
|
122
|
-
const stop = end === -1 ? text.length : end + 2;
|
|
123
|
-
out += blank(text.slice(i, stop));
|
|
124
|
-
i = stop;
|
|
125
|
-
} else {
|
|
126
|
-
const ch = text[i];
|
|
127
|
-
if (ch === "'" || ch === '"' || ch === '`') {
|
|
128
|
-
let j = i + 1;
|
|
129
|
-
while (j < text.length) {
|
|
130
|
-
if (text[j] === '\\') {
|
|
131
|
-
j += 2;
|
|
132
|
-
continue;
|
|
133
|
-
}
|
|
134
|
-
if (text[j] === ch) break;
|
|
135
|
-
j += 1;
|
|
136
|
-
}
|
|
137
|
-
const stop = Math.min(j + 1, text.length);
|
|
138
|
-
out += text.slice(i, stop);
|
|
139
|
-
i = stop;
|
|
140
|
-
} else {
|
|
141
|
-
out += ch;
|
|
142
|
-
i += 1;
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
return out;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
93
|
/**
|
|
150
94
|
* Recursively list files under `dir`, skipping `node_modules` and `.git`.
|
|
151
95
|
*
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import {
|
|
3
|
+
import { Minimatch } from 'minimatch';
|
|
4
4
|
import { canonicalise as canonicalisePath } from './baselines/path-canon.js';
|
|
5
5
|
import { POOL_SERIAL_THRESHOLD, runOnPool } from './cpu-pool.js';
|
|
6
6
|
import { Logger } from './Logger.js';
|
|
@@ -27,6 +27,57 @@ const IGNORED_DIRS = new Set([
|
|
|
27
27
|
'.next',
|
|
28
28
|
]);
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Compiled-matcher cache for `isIgnoredByGlobs`, keyed on the pattern list.
|
|
32
|
+
*
|
|
33
|
+
* `minimatch(path, glob)` re-parses `glob` into an AST on every call, so the
|
|
34
|
+
* scan was paying O(files × globs) glob *compilations* to answer O(files ×
|
|
35
|
+
* globs) glob *matches* — 12–17 ms over the 619-file tree against 0.5 ms
|
|
36
|
+
* once the patterns are compiled (Story #5109). The number of distinct
|
|
37
|
+
* pattern lists in a process is tiny (one per gate), and each list is
|
|
38
|
+
* config-derived and immutable, so an unbounded `Map` keyed on the joined
|
|
39
|
+
* patterns is bounded in practice.
|
|
40
|
+
*
|
|
41
|
+
* Each entry also memoises the per-path verdict. The baseline gates ask the
|
|
42
|
+
* same question about the same path many times over — `crap.json` carries
|
|
43
|
+
* thousands of method rows spread over a few hundred files, and the evaluate
|
|
44
|
+
* phase filters every row — so the second and later asks about a path cost a
|
|
45
|
+
* `Map` lookup instead of a globstar walk. Both caches are keyed on
|
|
46
|
+
* config-derived, immutable inputs, and both are bounded by the tree the
|
|
47
|
+
* process is scanning.
|
|
48
|
+
*
|
|
49
|
+
* `\u0000` is the join separator because it cannot occur in a glob, so two
|
|
50
|
+
* different lists cannot collide onto one key.
|
|
51
|
+
*
|
|
52
|
+
* @type {Map<string, {matchers: import('minimatch').Minimatch[], verdicts: Map<string, boolean>}>}
|
|
53
|
+
*/
|
|
54
|
+
const IGNORE_MATCHER_CACHE = new Map();
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Compile (once) the `Minimatch` instances for a pattern list, alongside the
|
|
58
|
+
* per-path verdict memo that shares their lifetime.
|
|
59
|
+
*
|
|
60
|
+
* Non-string patterns are dropped rather than compiled — `minimatch()` would
|
|
61
|
+
* have thrown on them, and the functional call site never fed it any, so
|
|
62
|
+
* dropping preserves the observable result set.
|
|
63
|
+
*
|
|
64
|
+
* @param {string[]} ignoreGlobs
|
|
65
|
+
* @returns {{matchers: import('minimatch').Minimatch[], verdicts: Map<string, boolean>}}
|
|
66
|
+
*/
|
|
67
|
+
function ignoreMatcherEntry(ignoreGlobs) {
|
|
68
|
+
const key = ignoreGlobs.join('\u0000');
|
|
69
|
+
let entry = IGNORE_MATCHER_CACHE.get(key);
|
|
70
|
+
if (entry) return entry;
|
|
71
|
+
entry = {
|
|
72
|
+
matchers: ignoreGlobs
|
|
73
|
+
.filter((g) => typeof g === 'string')
|
|
74
|
+
.map((g) => new Minimatch(g, { dot: true })),
|
|
75
|
+
verdicts: new Map(),
|
|
76
|
+
};
|
|
77
|
+
IGNORE_MATCHER_CACHE.set(key, entry);
|
|
78
|
+
return entry;
|
|
79
|
+
}
|
|
80
|
+
|
|
30
81
|
/**
|
|
31
82
|
* Test whether an absolute (or repo-relative) file path matches any of the
|
|
32
83
|
* configured `ignoreGlobs`. This is the single source of truth for how the
|
|
@@ -39,22 +90,62 @@ const IGNORED_DIRS = new Set([
|
|
|
39
90
|
*
|
|
40
91
|
* Matching mirrors `scanDirectory`: the path is reduced to a canonicalised,
|
|
41
92
|
* POSIX, repo-relative form and tested against each glob with minimatch's
|
|
42
|
-
* `{ dot: true }` so dot-prefixed roots like `.agents/` match.
|
|
93
|
+
* `{ dot: true }` so dot-prefixed roots like `.agents/` match. The patterns
|
|
94
|
+
* are compiled at most once per distinct list (see `IGNORE_MATCHER_CACHE`);
|
|
95
|
+
* the matched set is identical to the functional `minimatch()` call this
|
|
96
|
+
* replaced, which is what the `gate-scan-fast-path` test pins over the real
|
|
97
|
+
* configured `ignoreGlobs`.
|
|
43
98
|
*
|
|
44
99
|
* @param {string} filePath absolute or relative path to the source file
|
|
45
100
|
* @param {string[]} ignoreGlobs minimatch patterns; empty/absent is a no-op
|
|
46
101
|
* @param {string} [cwd] root for repo-relative resolution; defaults to cwd
|
|
47
102
|
* @returns {boolean} true when the file matches at least one ignore glob
|
|
48
103
|
*/
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
104
|
+
/**
|
|
105
|
+
* Reduce an absolute-or-relative path to the canonicalised, POSIX,
|
|
106
|
+
* repo-relative form the ignore patterns are written against.
|
|
107
|
+
*
|
|
108
|
+
* @param {string} filePath
|
|
109
|
+
* @param {string} matchCwd
|
|
110
|
+
* @returns {string}
|
|
111
|
+
*/
|
|
112
|
+
function canonicalRelPath(filePath, matchCwd) {
|
|
52
113
|
const absFilePath = path.isAbsolute(filePath)
|
|
53
114
|
? filePath
|
|
54
115
|
: path.resolve(matchCwd, filePath);
|
|
55
116
|
const rawRel = path.relative(matchCwd, absFilePath).replace(/\\/g, '/');
|
|
56
|
-
|
|
57
|
-
|
|
117
|
+
return canonicalisePath(rawRel);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Answer "does this path match any of the entry's patterns", consulting and
|
|
122
|
+
* populating the entry's verdict memo.
|
|
123
|
+
*
|
|
124
|
+
* Split out of `isIgnoredByGlobs` rather than inlined: the memo's
|
|
125
|
+
* hit/miss test is a branch, and folding it into the caller pushed that
|
|
126
|
+
* function from cyclomatic 4 to 5 — over the per-method CRAP contract the
|
|
127
|
+
* pre-push preview enforces. The lookup belongs beside the cache it reads
|
|
128
|
+
* anyway, and the hot function keeps a flat shape.
|
|
129
|
+
*
|
|
130
|
+
* @param {{matchers: import('minimatch').Minimatch[], verdicts: Map<string, boolean>}} entry
|
|
131
|
+
* @param {string} relPath Canonicalised, POSIX, repo-relative path.
|
|
132
|
+
* @returns {boolean}
|
|
133
|
+
*/
|
|
134
|
+
function memoisedIgnoreVerdict(entry, relPath) {
|
|
135
|
+
const memoised = entry.verdicts.get(relPath);
|
|
136
|
+
if (memoised !== undefined) return memoised;
|
|
137
|
+
const verdict = entry.matchers.some((m) => m.match(relPath));
|
|
138
|
+
entry.verdicts.set(relPath, verdict);
|
|
139
|
+
return verdict;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function isIgnoredByGlobs(filePath, ignoreGlobs = [], cwd) {
|
|
143
|
+
if (!Array.isArray(ignoreGlobs) || ignoreGlobs.length === 0) return false;
|
|
144
|
+
const matchCwd = cwd ?? process.cwd();
|
|
145
|
+
return memoisedIgnoreVerdict(
|
|
146
|
+
ignoreMatcherEntry(ignoreGlobs),
|
|
147
|
+
canonicalRelPath(filePath, matchCwd),
|
|
148
|
+
);
|
|
58
149
|
}
|
|
59
150
|
|
|
60
151
|
/**
|
|
@@ -107,7 +198,7 @@ export function scanDirectory(dir, fileList = [], opts = {}) {
|
|
|
107
198
|
* Calculates maintainability scores for a list of file paths.
|
|
108
199
|
*
|
|
109
200
|
* Each file's transpile-then-analyze unit is dispatched to a
|
|
110
|
-
* worker_threads pool
|
|
201
|
+
* worker_threads pool whose width `runOnPool` resolves. Workers
|
|
111
202
|
* are recycled across files so TypeScript loads at most once per
|
|
112
203
|
* worker. The pool is bypassed for batches of fewer than
|
|
113
204
|
* `SERIAL_THRESHOLD` files because spawn overhead dominates at small
|
|
@@ -129,9 +220,16 @@ export function scanDirectory(dir, fileList = [], opts = {}) {
|
|
|
129
220
|
* and nothing said so.
|
|
130
221
|
*
|
|
131
222
|
* @param {string[]} paths
|
|
223
|
+
* @param {{serialThreshold?: number}} [opts] `serialThreshold` overrides the
|
|
224
|
+
* pool-vs-serial cutover for this call only. Production callers omit it;
|
|
225
|
+
* the parity tests use it to drive the pooled path on a small fixture set
|
|
226
|
+
* rather than materialising 256 files to clear the cutover.
|
|
132
227
|
* @returns {Promise<Record<string, number>>}
|
|
133
228
|
*/
|
|
134
|
-
export async function calculateAll(paths) {
|
|
229
|
+
export async function calculateAll(paths, opts = {}) {
|
|
230
|
+
const serialThreshold = Number.isFinite(opts?.serialThreshold)
|
|
231
|
+
? opts.serialThreshold
|
|
232
|
+
: SERIAL_THRESHOLD;
|
|
135
233
|
const cwd = process.cwd();
|
|
136
234
|
const indexed = paths.map((p) => ({
|
|
137
235
|
abs: p,
|
|
@@ -139,7 +237,7 @@ export async function calculateAll(paths) {
|
|
|
139
237
|
}));
|
|
140
238
|
|
|
141
239
|
let perFile;
|
|
142
|
-
if (indexed.length <
|
|
240
|
+
if (indexed.length < serialThreshold) {
|
|
143
241
|
perFile = indexed.map(({ abs, relPath }) => {
|
|
144
242
|
try {
|
|
145
243
|
return { relPath, ...scoreFile(abs) };
|
|
@@ -94,6 +94,7 @@ const FRAMEWORK_SCRIPT_BASENAMES = Object.freeze([
|
|
|
94
94
|
'check-cyclomatic.js',
|
|
95
95
|
'check-dead-exports.js',
|
|
96
96
|
'check-doc-links.js',
|
|
97
|
+
'check-generated-validator.js',
|
|
97
98
|
'check-gherkin-corpus.js',
|
|
98
99
|
'check-knip-entries.js',
|
|
99
100
|
'check-lifecycle-lint.js',
|
|
@@ -113,7 +114,6 @@ const FRAMEWORK_SCRIPT_BASENAMES = Object.freeze([
|
|
|
113
114
|
'evidence-gate.js',
|
|
114
115
|
'generate-config-docs.js',
|
|
115
116
|
'generate-lens-checklists.js',
|
|
116
|
-
'generate-lifecycle-docs.js',
|
|
117
117
|
'generate-skills-index.js',
|
|
118
118
|
'generate-workflows-doc.js',
|
|
119
119
|
'git-cleanup.js',
|
|
@@ -37,15 +37,19 @@ import {
|
|
|
37
37
|
removeWorktree,
|
|
38
38
|
worktreesByBranch,
|
|
39
39
|
} from './git-probes.js';
|
|
40
|
+
import { probeAncestry } from './merged-tip.js';
|
|
40
41
|
import { parsePrunedRefs } from './prune.js';
|
|
41
42
|
|
|
42
43
|
const TAG = '[git-cleanup]';
|
|
43
44
|
|
|
45
|
+
/** Fields a planner verdict forwards onto its `skipped[]` entry. */
|
|
46
|
+
const SKIP_DETAIL_FIELDS = ['prNumber', 'tipSha', 'mergedSha', 'detail'];
|
|
47
|
+
|
|
44
48
|
function skipEntryFromVerdict(branch, verdict) {
|
|
45
49
|
const entry = { branch, reason: verdict.reason };
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
50
|
+
for (const field of SKIP_DETAIL_FIELDS) {
|
|
51
|
+
if (verdict[field] != null) entry[field] = verdict[field];
|
|
52
|
+
}
|
|
49
53
|
return entry;
|
|
50
54
|
}
|
|
51
55
|
|
|
@@ -88,6 +92,7 @@ function evaluateLocalBranch({
|
|
|
88
92
|
wtMap,
|
|
89
93
|
remoteName,
|
|
90
94
|
branchTipShaFn,
|
|
95
|
+
ancestryFn,
|
|
91
96
|
contentEquivalentFn,
|
|
92
97
|
branchLastCommitFn,
|
|
93
98
|
}) {
|
|
@@ -102,6 +107,7 @@ function evaluateLocalBranch({
|
|
|
102
107
|
remoteName,
|
|
103
108
|
localExists: true,
|
|
104
109
|
branchTipShaFn,
|
|
110
|
+
ancestryFn,
|
|
105
111
|
});
|
|
106
112
|
if (verdict.kind === 'skip') {
|
|
107
113
|
return { skip: skipEntryFromVerdict(branch, verdict) };
|
|
@@ -134,6 +140,7 @@ function evaluateLocalBranch({
|
|
|
134
140
|
worktreePath: wt?.path ?? null,
|
|
135
141
|
detectedBy,
|
|
136
142
|
localExists: true,
|
|
143
|
+
behindMerge: verdict.reason === 'tip-behind-merge',
|
|
137
144
|
},
|
|
138
145
|
};
|
|
139
146
|
}
|
|
@@ -147,6 +154,7 @@ function collectRemoteOnlyCandidates({
|
|
|
147
154
|
filter,
|
|
148
155
|
prProbe,
|
|
149
156
|
branchTipShaFn,
|
|
157
|
+
ancestryFn,
|
|
150
158
|
skipped,
|
|
151
159
|
}) {
|
|
152
160
|
const out = [];
|
|
@@ -162,6 +170,7 @@ function collectRemoteOnlyCandidates({
|
|
|
162
170
|
remoteName,
|
|
163
171
|
localExists: false,
|
|
164
172
|
branchTipShaFn,
|
|
173
|
+
ancestryFn,
|
|
165
174
|
});
|
|
166
175
|
if (verdict.kind === 'no-pr') continue;
|
|
167
176
|
if (verdict.kind === 'skip') {
|
|
@@ -176,6 +185,7 @@ function collectRemoteOnlyCandidates({
|
|
|
176
185
|
worktreePath: null,
|
|
177
186
|
detectedBy: 'remote-only',
|
|
178
187
|
localExists: false,
|
|
188
|
+
behindMerge: verdict.reason === 'tip-behind-merge',
|
|
179
189
|
});
|
|
180
190
|
}
|
|
181
191
|
return out;
|
|
@@ -187,10 +197,12 @@ function collectRemoteOnlyCandidates({
|
|
|
187
197
|
* The PR probe classifies each candidate by the **latest** PR on the head
|
|
188
198
|
* ref rather than any historical merge. Branches whose latest PR is OPEN
|
|
189
199
|
* or CLOSED-not-merged are skipped with `reason: 'latest-pr-open'` /
|
|
190
|
-
* `reason: 'latest-pr-closed-not-merged'`.
|
|
191
|
-
*
|
|
192
|
-
*
|
|
193
|
-
* `
|
|
200
|
+
* `reason: 'latest-pr-closed-not-merged'`. A MERGED PR whose `headRefOid`
|
|
201
|
+
* differs from the branch tip is resolved by ancestry in
|
|
202
|
+
* `merged-tip.js` — a tip *behind* the merged head becomes a candidate
|
|
203
|
+
* carrying `behindMerge: true`, a tip *ahead* of it keeps the
|
|
204
|
+
* `tip-diverged-from-merge` force-push skip, and an unresolvable rev
|
|
205
|
+
* skips as `unverifiable`.
|
|
194
206
|
*
|
|
195
207
|
* Performance (Story #3333): when the caller does not inject its own
|
|
196
208
|
* `prProbe`, the planner fires **one** bulk `gh pr list --state all`
|
|
@@ -250,6 +262,7 @@ export function planCleanup(ctx) {
|
|
|
250
262
|
prIndexFn = probeAllPrs,
|
|
251
263
|
prFallback = probeLatestPr,
|
|
252
264
|
branchTipShaFn = branchTipSha,
|
|
265
|
+
ancestryFn = probeAncestry,
|
|
253
266
|
contentEquivalentFn = probeContentEquivalent,
|
|
254
267
|
branchLastCommitFn = branchLastCommitAt,
|
|
255
268
|
refExistsFn = refExists,
|
|
@@ -301,6 +314,7 @@ export function planCleanup(ctx) {
|
|
|
301
314
|
wtMap,
|
|
302
315
|
remoteName,
|
|
303
316
|
branchTipShaFn,
|
|
317
|
+
ancestryFn,
|
|
304
318
|
contentEquivalentFn,
|
|
305
319
|
branchLastCommitFn,
|
|
306
320
|
});
|
|
@@ -318,6 +332,7 @@ export function planCleanup(ctx) {
|
|
|
318
332
|
filter,
|
|
319
333
|
prProbe,
|
|
320
334
|
branchTipShaFn,
|
|
335
|
+
ancestryFn,
|
|
321
336
|
skipped,
|
|
322
337
|
}),
|
|
323
338
|
);
|
|
@@ -17,6 +17,7 @@ import { execFileSync } from 'node:child_process';
|
|
|
17
17
|
|
|
18
18
|
import { gitSpawn } from '../../../git-utils.js';
|
|
19
19
|
import { parseWorktreePorcelain } from '../../../worktree-manager.js';
|
|
20
|
+
import { resolveMergedTip } from './merged-tip.js';
|
|
20
21
|
|
|
21
22
|
export {
|
|
22
23
|
canFastForward,
|
|
@@ -452,6 +453,12 @@ export const __testing = { validSha, firstLsRemoteSha, firstStdoutLine };
|
|
|
452
453
|
* into `skipped[]` and continues.
|
|
453
454
|
* - `{ kind: 'no-pr' }` — caller continues without skipping.
|
|
454
455
|
*
|
|
456
|
+
* A MERGED PR whose `headRefOid` differs from the branch tip is handed
|
|
457
|
+
* to {@link resolveMergedTip}, which resolves it by ancestry — see that
|
|
458
|
+
* module for the `tip-behind-merge` / `tip-diverged-from-merge` /
|
|
459
|
+
* `unverifiable` taxonomy and why a bare SHA inequality could not
|
|
460
|
+
* express it.
|
|
461
|
+
*
|
|
455
462
|
* @param {{
|
|
456
463
|
* prInfo: { number?: number, state?: string, mergedAt?: string|null, headRefOid?: string|null } | null,
|
|
457
464
|
* branch: string,
|
|
@@ -459,8 +466,10 @@ export const __testing = { validSha, firstLsRemoteSha, firstStdoutLine };
|
|
|
459
466
|
* remoteName: string,
|
|
460
467
|
* localExists: boolean,
|
|
461
468
|
* branchTipShaFn: (args: { cwd: string, branch: string, remoteName: string, localExists: boolean }) => string | null,
|
|
469
|
+
* ancestryFn?: Function,
|
|
470
|
+
* mergedTipFn?: typeof resolveMergedTip,
|
|
462
471
|
* }} args
|
|
463
|
-
* @returns {{ kind: 'candidate', prInfo: object } | { kind: 'skip', reason: string, prNumber?: number, tipSha?: string|null, mergedSha?: string|null } | { kind: 'no-pr' }}
|
|
472
|
+
* @returns {{ kind: 'candidate', prInfo: object, reason?: string, tipSha?: string, mergedSha?: string } | { kind: 'skip', reason: string, prNumber?: number, tipSha?: string|null, mergedSha?: string|null, detail?: string } | { kind: 'no-pr' }}
|
|
464
473
|
*/
|
|
465
474
|
export function classifyLatestPr({
|
|
466
475
|
prInfo,
|
|
@@ -469,6 +478,8 @@ export function classifyLatestPr({
|
|
|
469
478
|
remoteName,
|
|
470
479
|
localExists,
|
|
471
480
|
branchTipShaFn,
|
|
481
|
+
ancestryFn,
|
|
482
|
+
mergedTipFn = resolveMergedTip,
|
|
472
483
|
}) {
|
|
473
484
|
if (!prInfo) return { kind: 'no-pr' };
|
|
474
485
|
const state =
|
|
@@ -494,17 +505,14 @@ export function classifyLatestPr({
|
|
|
494
505
|
prNumber: prInfo.number ?? null,
|
|
495
506
|
};
|
|
496
507
|
}
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
}
|
|
508
|
-
}
|
|
509
|
-
return { kind: 'candidate', prInfo };
|
|
508
|
+
const tipVerdict = mergedTipFn({
|
|
509
|
+
prInfo,
|
|
510
|
+
branch,
|
|
511
|
+
cwd,
|
|
512
|
+
remoteName,
|
|
513
|
+
localExists,
|
|
514
|
+
branchTipShaFn,
|
|
515
|
+
ancestryFn,
|
|
516
|
+
});
|
|
517
|
+
return tipVerdict ?? { kind: 'candidate', prInfo };
|
|
510
518
|
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* merged-tip.js — resolve a MERGED PR's head against the branch tip
|
|
3
|
+
* (Story #5086).
|
|
4
|
+
*
|
|
5
|
+
* Owns the ancestry probe and the taxonomy the branches-phase classifier
|
|
6
|
+
* applies when a merged PR's `headRefOid` and the branch tip disagree.
|
|
7
|
+
* Split out of `git-probes.js` so the classifier reads as one call and
|
|
8
|
+
* the taxonomy's own documentation sits next to the code it governs.
|
|
9
|
+
*
|
|
10
|
+
* @module lib/orchestration/git-cleanup/phases/merged-tip
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { gitSpawn } from '../../../git-utils.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Tri-state ancestry probe: is `ancestorSha` reachable from
|
|
17
|
+
* `descendantSha`?
|
|
18
|
+
*
|
|
19
|
+
* Mirrors the contract `checkHeadAncestor` in
|
|
20
|
+
* `lib/worktree/lifecycle/merge-reachability.js` proved out for the
|
|
21
|
+
* worktree-reap gate — the two cannot share an implementation because
|
|
22
|
+
* that one takes a `ctx.git.gitSpawn` / `ctx.repoRoot` bag while
|
|
23
|
+
* git-cleanup's probes take a bare `cwd`.
|
|
24
|
+
*
|
|
25
|
+
* `git merge-base --is-ancestor` exits **0** (ancestor), **1** (not an
|
|
26
|
+
* ancestor) or **128** (a rev it cannot resolve). Folding 128 into
|
|
27
|
+
* "not an ancestor" is the bug this probe exists to prevent: a merged
|
|
28
|
+
* head absent from the local object DB would silently read as a
|
|
29
|
+
* divergence and re-emit the wrong post-merge-force-push diagnosis. Both
|
|
30
|
+
* revs are therefore resolved with `git rev-parse -q --verify` first, and
|
|
31
|
+
* any failure fails closed to the `error` arm — so `merge-base` never
|
|
32
|
+
* runs against a rev git cannot resolve.
|
|
33
|
+
*
|
|
34
|
+
* @param {{ cwd: string, ancestorSha: string, descendantSha: string, spawn?: typeof gitSpawn }} args
|
|
35
|
+
* @returns {{ outcome: 'ancestor' } | { outcome: 'not-ancestor' } | { outcome: 'error', reason: string }}
|
|
36
|
+
*/
|
|
37
|
+
export function probeAncestry({
|
|
38
|
+
cwd,
|
|
39
|
+
ancestorSha,
|
|
40
|
+
descendantSha,
|
|
41
|
+
spawn = gitSpawn,
|
|
42
|
+
}) {
|
|
43
|
+
for (const rev of [ancestorSha, descendantSha]) {
|
|
44
|
+
const res = spawn(
|
|
45
|
+
cwd,
|
|
46
|
+
'rev-parse',
|
|
47
|
+
'--quiet',
|
|
48
|
+
'--verify',
|
|
49
|
+
`${rev}^{commit}`,
|
|
50
|
+
);
|
|
51
|
+
if (res.status !== 0) {
|
|
52
|
+
return { outcome: 'error', reason: `unresolvable rev ${rev}` };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const res = spawn(
|
|
56
|
+
cwd,
|
|
57
|
+
'merge-base',
|
|
58
|
+
'--is-ancestor',
|
|
59
|
+
ancestorSha,
|
|
60
|
+
descendantSha,
|
|
61
|
+
);
|
|
62
|
+
if (res.status === 0) return { outcome: 'ancestor' };
|
|
63
|
+
if (res.status === 1) return { outcome: 'not-ancestor' };
|
|
64
|
+
return {
|
|
65
|
+
outcome: 'error',
|
|
66
|
+
reason: (res.stderr || res.stdout || 'unknown').trim(),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Resolve a MERGED PR's `headRefOid` against the branch's current tip.
|
|
72
|
+
*
|
|
73
|
+
* Returns `null` when there is nothing to resolve — the PR row carries no
|
|
74
|
+
* `headRefOid`, the tip cannot be read, or the tip already matches the
|
|
75
|
+
* merged head — leaving the caller's plain-candidate path untouched.
|
|
76
|
+
*
|
|
77
|
+
* Otherwise the tip is classified by **ancestry**, never by the bare SHA
|
|
78
|
+
* inequality this replaced. That inequality could not tell a branch that
|
|
79
|
+
* is *behind* the merged head from one force-pushed *past* it, and
|
|
80
|
+
* reported both as the latter — advising the operator to push a follow-up
|
|
81
|
+
* commit that, for a stale pre-merge snapshot, does not exist. The three
|
|
82
|
+
* arms:
|
|
83
|
+
*
|
|
84
|
+
* - **ancestor** — 0 commits ahead, every commit landed with the PR:
|
|
85
|
+
* a reap candidate tagged `reason: 'tip-behind-merge'`.
|
|
86
|
+
* - **not-ancestor** — equivalently "≥1 commit ahead", which is why one
|
|
87
|
+
* probe settles the whole taxonomy and no `rev-list` count is needed:
|
|
88
|
+
* the unchanged `tip-diverged-from-merge` force-push skip.
|
|
89
|
+
* - **error** — a rev the local object DB cannot resolve:
|
|
90
|
+
* `reason: 'unverifiable'` carrying the probe's `detail`. Never a
|
|
91
|
+
* silent pass, and never a force-push label.
|
|
92
|
+
*
|
|
93
|
+
* @param {object} args
|
|
94
|
+
* @returns {{ kind: 'candidate', prInfo: object, reason: string, tipSha: string, mergedSha: string } | { kind: 'skip', reason: string, prNumber: number|null, tipSha: string, mergedSha: string, detail?: string } | null}
|
|
95
|
+
*/
|
|
96
|
+
export function resolveMergedTip({
|
|
97
|
+
prInfo,
|
|
98
|
+
branch,
|
|
99
|
+
cwd,
|
|
100
|
+
remoteName,
|
|
101
|
+
localExists,
|
|
102
|
+
branchTipShaFn,
|
|
103
|
+
ancestryFn = probeAncestry,
|
|
104
|
+
}) {
|
|
105
|
+
const mergedSha = prInfo.headRefOid;
|
|
106
|
+
if (!mergedSha) return null;
|
|
107
|
+
const tipSha = branchTipShaFn({ cwd, branch, remoteName, localExists });
|
|
108
|
+
if (!tipSha || tipSha === mergedSha) return null;
|
|
109
|
+
const ancestry = ancestryFn({
|
|
110
|
+
cwd,
|
|
111
|
+
ancestorSha: tipSha,
|
|
112
|
+
descendantSha: mergedSha,
|
|
113
|
+
});
|
|
114
|
+
if (ancestry.outcome === 'ancestor') {
|
|
115
|
+
return {
|
|
116
|
+
kind: 'candidate',
|
|
117
|
+
prInfo,
|
|
118
|
+
reason: 'tip-behind-merge',
|
|
119
|
+
tipSha,
|
|
120
|
+
mergedSha,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
const errored = ancestry.outcome === 'error';
|
|
124
|
+
return {
|
|
125
|
+
kind: 'skip',
|
|
126
|
+
reason: errored ? 'unverifiable' : 'tip-diverged-from-merge',
|
|
127
|
+
prNumber: prInfo.number ?? null,
|
|
128
|
+
tipSha,
|
|
129
|
+
mergedSha,
|
|
130
|
+
...(errored ? { detail: ancestry.reason } : {}),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
@@ -61,6 +61,41 @@ function contentMergedNote(candidate) {
|
|
|
61
61
|
: '';
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
+
/**
|
|
65
|
+
* Pure: render a single behind-the-merged-head candidate annotation.
|
|
66
|
+
*
|
|
67
|
+
* A branch whose tip is a strict ancestor of its merged PR head is a
|
|
68
|
+
* stale pre-merge snapshot — reapable, because every commit on it landed
|
|
69
|
+
* with the PR, but reapable for a different reason than a branch whose
|
|
70
|
+
* tip *matches* the merged head. It used to be skipped outright as a
|
|
71
|
+
* post-merge force-push; the note keeps the two visibly distinct in the
|
|
72
|
+
* dry-run list and the confirmation prompt so the operator can see why a
|
|
73
|
+
* branch that is not at the merged head is nonetheless offered.
|
|
74
|
+
*/
|
|
75
|
+
function behindMergeNote(candidate) {
|
|
76
|
+
return candidate.behindMerge
|
|
77
|
+
? ' (tip behind the merged head — content already landed)'
|
|
78
|
+
: '';
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Pure: every provenance annotation a candidate row carries, in order. */
|
|
82
|
+
function candidateNotes(candidate) {
|
|
83
|
+
return `${contentMergedNote(candidate)}${behindMergeNote(candidate)}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Pure: one candidate row — its detection provenance, worktree, locality
|
|
88
|
+
* and annotations. Split out of {@link renderDryRun} so that renderer
|
|
89
|
+
* stays a loop over rows rather than growing a fourth inline ternary
|
|
90
|
+
* every time a candidate gains a new dimension.
|
|
91
|
+
*/
|
|
92
|
+
function renderCandidateRow(c) {
|
|
93
|
+
const pr = c.prNumber ? `PR #${c.prNumber}` : c.detectedBy;
|
|
94
|
+
const wt = c.hasWorktree ? ` (worktree: ${c.worktreePath})` : '';
|
|
95
|
+
const remoteOnly = c.localExists === false ? ' (remote-only)' : '';
|
|
96
|
+
return ` • ${c.branch} — ${pr}${wt}${remoteOnly}${candidateNotes(c)}`;
|
|
97
|
+
}
|
|
98
|
+
|
|
64
99
|
/**
|
|
65
100
|
* Pure: render the branch-phase candidate list as the operator-facing text
|
|
66
101
|
* block.
|
|
@@ -87,14 +122,7 @@ export function renderDryRun(plan, opts = {}) {
|
|
|
87
122
|
if (plan.candidates.length === 0) {
|
|
88
123
|
lines.push(' (no merged branches to clean up)');
|
|
89
124
|
} else {
|
|
90
|
-
for (const c of plan.candidates)
|
|
91
|
-
const pr = c.prNumber ? `PR #${c.prNumber}` : c.detectedBy;
|
|
92
|
-
const wt = c.hasWorktree ? ` (worktree: ${c.worktreePath})` : '';
|
|
93
|
-
const remoteOnly = c.localExists === false ? ' (remote-only)' : '';
|
|
94
|
-
lines.push(
|
|
95
|
-
` • ${c.branch} — ${pr}${wt}${remoteOnly}${contentMergedNote(c)}`,
|
|
96
|
-
);
|
|
97
|
-
}
|
|
125
|
+
for (const c of plan.candidates) lines.push(renderCandidateRow(c));
|
|
98
126
|
}
|
|
99
127
|
const skipped = plan.skipped ?? [];
|
|
100
128
|
const currentHeadSkip = skipped.find((s) => s.reason === 'current-head');
|
|
@@ -141,6 +169,17 @@ export function renderCandidateList({ plan, opts = {}, baseBranch = null }) {
|
|
|
141
169
|
return renderDryRun(plan, { baseBranch, execute: !opts.dryRun });
|
|
142
170
|
}
|
|
143
171
|
|
|
172
|
+
/**
|
|
173
|
+
* Pure: the tip / merged short-SHA pair both merged-tip skip lines quote,
|
|
174
|
+
* with a placeholder for either side the planner could not resolve.
|
|
175
|
+
*/
|
|
176
|
+
function shortShaPair(skip) {
|
|
177
|
+
return {
|
|
178
|
+
tip: skip.tipSha ? skip.tipSha.slice(0, 7) : '<unknown>',
|
|
179
|
+
merged: skip.mergedSha ? skip.mergedSha.slice(0, 7) : '<unknown>',
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
144
183
|
/**
|
|
145
184
|
* Pure: render a single latest-PR-state skip line. Returns null when the
|
|
146
185
|
* skip reason is not one of the latest-PR family — `renderDryRun` filters
|
|
@@ -148,7 +187,7 @@ export function renderCandidateList({ plan, opts = {}, baseBranch = null }) {
|
|
|
148
187
|
* `current-head`, `filtered`) stay quiet here. `not-merged` gets its own
|
|
149
188
|
* renderer ({@link renderNotMergedSkipLine}).
|
|
150
189
|
*
|
|
151
|
-
* @param {{ branch: string, reason: string, prNumber?: number, tipSha?: string, mergedSha?: string }} skip
|
|
190
|
+
* @param {{ branch: string, reason: string, prNumber?: number, tipSha?: string, mergedSha?: string, detail?: string }} skip
|
|
152
191
|
* @returns {string | null}
|
|
153
192
|
*/
|
|
154
193
|
export function renderLatestPrSkipLine(skip) {
|
|
@@ -161,13 +200,19 @@ export function renderLatestPrSkipLine(skip) {
|
|
|
161
200
|
return `${TAG} ⏭️ ${skip.branch} skipped — ${prRef} is still open`;
|
|
162
201
|
}
|
|
163
202
|
if (skip.reason === 'tip-diverged-from-merge') {
|
|
164
|
-
const tip
|
|
165
|
-
const merged = skip.mergedSha ? skip.mergedSha.slice(0, 7) : '<unknown>';
|
|
203
|
+
const { tip, merged } = shortShaPair(skip);
|
|
166
204
|
return (
|
|
167
205
|
`${TAG} ⏭️ ${skip.branch} skipped — tip ${tip} diverges from ${prRef}'s merged ${merged} (post-merge force-push); ` +
|
|
168
206
|
`resolve by deleting manually (\`git branch -D ${skip.branch}\`) or pushing the follow-up commit`
|
|
169
207
|
);
|
|
170
208
|
}
|
|
209
|
+
if (skip.reason === 'unverifiable') {
|
|
210
|
+
const { tip, merged } = shortShaPair(skip);
|
|
211
|
+
return (
|
|
212
|
+
`${TAG} ⏭️ ${skip.branch} skipped — cannot verify tip ${tip} against ${prRef}'s merged ${merged}${skip.detail ? `: ${skip.detail}` : ''}; ` +
|
|
213
|
+
`fetch the missing commit or inspect the branch by hand before deleting it`
|
|
214
|
+
);
|
|
215
|
+
}
|
|
171
216
|
if (skip.reason === 'latest-pr-unknown-state') {
|
|
172
217
|
return `${TAG} ⏭️ ${skip.branch} skipped — ${prRef} has an unrecognized state`;
|
|
173
218
|
}
|