@polymorphism-tech/morph-spec 8.31.0 → 8.33.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/CLAUDE.md +6 -0
- package/README.md +2 -2
- package/bin/morph-spec.js +20 -2
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +1 -1
- package/docs/QUICKSTART.md +1 -1
- package/framework/CLAUDE.md +2 -1
- package/framework/MORPH.md +2 -2
- package/framework/docs/CLI.md +10 -5
- package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
- package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
- package/framework/skills/core/morph-apply/SKILL.md +3 -2
- package/framework/skills/core/morph-brainstorming/SKILL.md +1 -1
- package/framework/skills/core/morph-eval/SKILL.md +1 -1
- package/framework/skills/core/morph-hotfix/SKILL.md +2 -2
- package/framework/skills/core/morph-implement/SKILL.md +1 -1
- package/framework/skills/core/morph-plan/SKILL.md +1 -1
- package/framework/skills/core/morph-proposal/SKILL.md +1 -1
- package/framework/skills/core/morph-review/SKILL.md +2 -2
- package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
- package/framework/skills/core/morph-standards/SKILL.md +1 -1
- package/framework/skills/core/morph-uiux/SKILL.md +1 -1
- package/package.json +1 -1
- package/src/commands/project/branch.js +170 -0
- package/src/commands/project/doctor.js +137 -9
- package/src/commands/project/fleet.js +10 -3
- package/src/commands/project/worktree.js +73 -50
- package/src/commands/state/finish.js +41 -26
- package/src/commands/state/list.js +79 -6
- package/src/core/state/lifecycle-audit.js +127 -0
- package/src/lib/git/branch-classifier.js +177 -0
- package/src/lib/git/gh.js +30 -0
- package/src/lib/git/pending-prs.js +89 -0
- package/src/lib/git/pr-index.js +70 -0
- package/src/lib/tasks/test-runner.js +200 -38
- package/src/lib/validators/verify-runner.js +30 -4
- package/src/lib/worktree/fleet-scanner.js +51 -21
- package/src/lib/worktree/ignored-triage.js +197 -0
- package/src/lib/worktree/worktree-audit.js +200 -35
|
@@ -7,6 +7,8 @@ import { missingIdentity } from '../../core/state/feature-identity.js';
|
|
|
7
7
|
import { derivePhaseFrom } from '../../../framework/hooks/shared/state-reader.js';
|
|
8
8
|
import { isGitRepo } from '../project/worktree.js';
|
|
9
9
|
import { morphBranches, currentBranch, branchView } from '../../lib/worktree/feature-truth.js';
|
|
10
|
+
import { buildPrIndex } from '../../lib/git/pr-index.js';
|
|
11
|
+
import { pendingPrFeatures, prForFeature } from '../../lib/git/pending-prs.js';
|
|
10
12
|
|
|
11
13
|
/**
|
|
12
14
|
* `morph-spec list [--all] [--json] [--no-branch]` — the feature audit board.
|
|
@@ -176,16 +178,51 @@ function flagArchivedElsewhere(cwd, rows) {
|
|
|
176
178
|
}
|
|
177
179
|
}
|
|
178
180
|
|
|
181
|
+
/**
|
|
182
|
+
* A linha de uma feature que existe SÓ como PR aberto — sem pasta ativa nem
|
|
183
|
+
* arquivada nesta árvore. É o estado que o `finish --pr` deixa: o scaffold do
|
|
184
|
+
* root foi apagado pelo shed e o archive viaja no diff do PR, chegando na
|
|
185
|
+
* default apenas no merge. Sem esta linha o trabalho fica sem dono no quadro,
|
|
186
|
+
* e uma branch sem dono é candidata a poda.
|
|
187
|
+
*/
|
|
188
|
+
function pendingRow(p) {
|
|
189
|
+
return {
|
|
190
|
+
feature: p.feature,
|
|
191
|
+
name: null,
|
|
192
|
+
description: null,
|
|
193
|
+
workType: null,
|
|
194
|
+
status: 'in_progress',
|
|
195
|
+
phase: 'aguardando-merge',
|
|
196
|
+
gates: { proposal: false, uiux: false, plan: false, review: false },
|
|
197
|
+
selfAssessed: false,
|
|
198
|
+
flags: ['pr-pending'],
|
|
199
|
+
location: 'pr-pending',
|
|
200
|
+
createdAt: p.createdAt,
|
|
201
|
+
updatedAt: null,
|
|
202
|
+
source: 'pr',
|
|
203
|
+
branch: p.branch,
|
|
204
|
+
worktree: null,
|
|
205
|
+
pr: { number: p.number, state: p.state, url: p.url, ageDays: p.ageDays },
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
179
209
|
const mark = (on) => (on ? chalk.green('✓') : chalk.gray('·'));
|
|
180
210
|
const truncate = (s, n) => (s.length > n ? `${s.slice(0, n - 1)}…` : s);
|
|
181
211
|
|
|
182
212
|
function whereLabel(row) {
|
|
213
|
+
if (row.location === 'pr-pending') return `branch ${row.branch}`;
|
|
183
214
|
if (row.location === 'archived') return '-';
|
|
184
215
|
if (row.worktree) return row.worktree.exists ? `worktree ${basename(row.worktree.path)}` : '⚠ worktree ausente';
|
|
185
216
|
if (row.source === 'branch') return `branch ${row.branch}`;
|
|
186
217
|
return 'raiz';
|
|
187
218
|
}
|
|
188
219
|
|
|
220
|
+
const prCell = (pr) => {
|
|
221
|
+
if (!pr) return '-';
|
|
222
|
+
const state = String(pr.state || '').toLowerCase();
|
|
223
|
+
return `#${pr.number} ${state}`;
|
|
224
|
+
};
|
|
225
|
+
|
|
189
226
|
export function listCommand(options = {}) {
|
|
190
227
|
const cwd = options.cwd || process.cwd();
|
|
191
228
|
const rows = readFeaturesIn(cwd, 'features', 'active');
|
|
@@ -195,6 +232,32 @@ export function listCommand(options = {}) {
|
|
|
195
232
|
// Commander's `--no-branch` sets options.branch = false; anything else (undefined/true) reads the branch.
|
|
196
233
|
if (options.branch !== false) resolveBranchTruth(cwd, rows);
|
|
197
234
|
|
|
235
|
+
// Estado do PR — uma consulta ao `gh` por invocação, compartilhada pela
|
|
236
|
+
// coluna e pela síntese. `--no-branch` (o modo offline rápido) pula as duas.
|
|
237
|
+
//
|
|
238
|
+
// `prIndex` continua `null` quando a pergunta não pôde ser feita (sem `gh`,
|
|
239
|
+
// sem auth, remoto não-GitHub, ou `--no-branch`). É o mesmo sinal que
|
|
240
|
+
// `--json` já expõe como `pr: null` por linha — a tabela texto usa o mesmo
|
|
241
|
+
// fato para decidir se a coluna PR sequer aparece (achado #2): índice `null`
|
|
242
|
+
// degrada para o comportamento de antes, sem coluna e sem `-` que afirme
|
|
243
|
+
// ausência.
|
|
244
|
+
let prIndex = null;
|
|
245
|
+
if (options.branch !== false) {
|
|
246
|
+
prIndex = buildPrIndex(cwd);
|
|
247
|
+
for (const row of rows) {
|
|
248
|
+
const pr = prForFeature(prIndex, row.feature);
|
|
249
|
+
row.pr = pr ? { number: pr.number, state: pr.state, url: pr.url, ageDays: null } : null;
|
|
250
|
+
}
|
|
251
|
+
const known = new Set(rows.map((r) => r.feature));
|
|
252
|
+
// `null` propaga desconhecido: nenhuma linha sintetizada, e nenhuma
|
|
253
|
+
// afirmação de que não há pendência.
|
|
254
|
+
for (const p of pendingPrFeatures(cwd, { index: prIndex }) || []) {
|
|
255
|
+
if (!known.has(p.feature)) rows.push(pendingRow(p));
|
|
256
|
+
}
|
|
257
|
+
} else {
|
|
258
|
+
for (const row of rows) row.pr = null;
|
|
259
|
+
}
|
|
260
|
+
|
|
198
261
|
rows.sort((a, b) => a.feature.localeCompare(b.feature));
|
|
199
262
|
|
|
200
263
|
if (options.json) {
|
|
@@ -208,22 +271,32 @@ export function listCommand(options = {}) {
|
|
|
208
271
|
}
|
|
209
272
|
|
|
210
273
|
const col = (s, w) => String(s ?? '-').padEnd(w);
|
|
274
|
+
// A coluna PR só aparece quando o índice foi construído com sucesso (achado
|
|
275
|
+
// #2) — um índice `null` não vira uma coluna cheia de `-`, que afirmaria
|
|
276
|
+
// "sem PR" para algo que na verdade é "não sabemos".
|
|
277
|
+
const showPr = Boolean(prIndex);
|
|
278
|
+
// `pr-pending` é estado normal de trabalho em voo, não inconsistência —
|
|
279
|
+
// não deve marcar `⚠` nem contar no rodapé (achado #6). Continua em
|
|
280
|
+
// `flags` no `--json`: é metadado real, só não é anomalia.
|
|
281
|
+
const warnFlags = (r) => r.flags.filter((f) => f !== 'pr-pending');
|
|
211
282
|
console.log(chalk.white.bold(
|
|
212
|
-
col('FEATURE', 22) + col('TYPE', 9) + col('STATUS', 13) + col('PHASE',
|
|
283
|
+
col('FEATURE', 22) + col('TYPE', 9) + col('STATUS', 13) + col('PHASE', 17) + col('WHERE', 22) + (showPr ? col('PR', 12) : '')
|
|
213
284
|
+ col('G1', 4) + col('G2', 4) + col('G3', 4) + col('', 3) + 'DESCRIPTION',
|
|
214
285
|
));
|
|
215
286
|
for (const r of rows) {
|
|
216
|
-
const flag = r.
|
|
217
|
-
const desc =
|
|
218
|
-
|
|
287
|
+
const flag = warnFlags(r).length ? chalk.yellow('⚠') : ' ';
|
|
288
|
+
const desc = r.location === 'pr-pending'
|
|
289
|
+
? chalk.yellow(`aguardando merge${r.pr?.ageDays != null ? ` há ${r.pr.ageDays}d` : ''} — sem cópia local`)
|
|
290
|
+
: (r.selfAssessed ? chalk.yellow('[G3 self-assessed] ') : '')
|
|
291
|
+
+ (r.description ? truncate(r.description, 34) : chalk.yellow('(sem descrição)'));
|
|
219
292
|
// padEnd counts the invisible color codes; pad the plain marks manually.
|
|
220
293
|
console.log(
|
|
221
|
-
col(r.feature, 22) + col(r.workType, 9) + col(r.status, 13) + col(r.phase,
|
|
294
|
+
col(r.feature, 22) + col(r.workType, 9) + col(r.status, 13) + col(r.phase, 17) + col(whereLabel(r), 22) + (showPr ? col(prCell(r.pr), 12) : '')
|
|
222
295
|
+ mark(r.gates.proposal) + ' ' + mark(r.gates.plan) + ' ' + mark(r.gates.review) + ' '
|
|
223
296
|
+ flag + ' ' + desc,
|
|
224
297
|
);
|
|
225
298
|
}
|
|
226
|
-
const flagged = rows.filter((r) => r.
|
|
299
|
+
const flagged = rows.filter((r) => warnFlags(r).length);
|
|
227
300
|
if (flagged.length) {
|
|
228
301
|
console.log(chalk.gray(`\n⚠ ${flagged.length} com inconsistência — rode 'morph-spec doctor' para detalhes.`));
|
|
229
302
|
}
|
|
@@ -12,6 +12,9 @@
|
|
|
12
12
|
* the next loadState, but surfaced so it's not a mystery)
|
|
13
13
|
* • branch-drift — the root's copy of a feature.json disagrees with the copy
|
|
14
14
|
* committed on `morph/{f}` (B5 — the root's view lags)
|
|
15
|
+
* • abandoned-scaffold — an `in_progress` feature that never produced
|
|
16
|
+
* anything: no artifacts, no approved gate, and a branch
|
|
17
|
+
* with no commit of its own (see abandonedScaffold)
|
|
15
18
|
* • corrupt — a feature.json that won't parse
|
|
16
19
|
*/
|
|
17
20
|
|
|
@@ -20,6 +23,8 @@ import { existsSync, readdirSync, readFileSync, statSync } from 'fs';
|
|
|
20
23
|
import { join } from 'path';
|
|
21
24
|
import { gateOrderViolations } from './gate-order.js';
|
|
22
25
|
import { missingIdentity } from './feature-identity.js';
|
|
26
|
+
import { detectDefaultBranch } from '../../lib/git/default-branch.js';
|
|
27
|
+
import { commitsAhead, resolveBaseRef } from '../../lib/git/branch-classifier.js';
|
|
23
28
|
|
|
24
29
|
/** The operational truth of a feature.json: its status and which gates are approved. */
|
|
25
30
|
function operationalShape(fj) {
|
|
@@ -72,6 +77,125 @@ function branchDrift(baseDir, feature, rootFj) {
|
|
|
72
77
|
}
|
|
73
78
|
}
|
|
74
79
|
|
|
80
|
+
/**
|
|
81
|
+
* How long a scaffold has to sit untouched before it counts as abandoned.
|
|
82
|
+
*
|
|
83
|
+
* A dead scaffold and a feature born five minutes ago have the SAME shape:
|
|
84
|
+
* `create` forks `morph/{f}` off the default branch without committing, so at
|
|
85
|
+
* birth the branch has no commit of its own and the folder holds nothing but
|
|
86
|
+
* `feature.json`. Only elapsed time separates the two — and a check that fires
|
|
87
|
+
* on every fresh `create` is one people learn to scroll past.
|
|
88
|
+
*/
|
|
89
|
+
const ABANDONED_SCAFFOLD_MIN_AGE_MS = 24 * 60 * 60 * 1000;
|
|
90
|
+
|
|
91
|
+
/** Newest timestamp the feature carries, in ms; falls back to the folder's mtime. */
|
|
92
|
+
function lastTouched(fdir, fj) {
|
|
93
|
+
let newest = 0;
|
|
94
|
+
for (const ts of [fj?.updatedAt, fj?.createdAt]) {
|
|
95
|
+
const t = ts ? Date.parse(ts) : NaN;
|
|
96
|
+
if (!Number.isNaN(t)) newest = Math.max(newest, t);
|
|
97
|
+
}
|
|
98
|
+
if (newest) return newest;
|
|
99
|
+
try { return statSync(fdir).mtimeMs; } catch { return 0; }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Does the feature dir hold any FILE other than its own `feature.json`?
|
|
104
|
+
*
|
|
105
|
+
* Files, not entries: `create` scaffolds the phase folders empty, so an empty
|
|
106
|
+
* `0-proposal/` is the absence of work, not evidence of it.
|
|
107
|
+
*/
|
|
108
|
+
function hasArtifacts(fdir) {
|
|
109
|
+
let entries;
|
|
110
|
+
try { entries = readdirSync(fdir, { withFileTypes: true }); } catch { return false; }
|
|
111
|
+
for (const e of entries) {
|
|
112
|
+
if (e.isDirectory()) {
|
|
113
|
+
if (hasArtifacts(join(fdir, e.name))) return true;
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (e.name === 'feature.json') continue;
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Commits on the feature's branch that the base does not already contain, or
|
|
124
|
+
* `null` when git cannot answer (no repo, no resolvable base, no branch, no
|
|
125
|
+
* rev-list). "Cannot prove" must never read as "proved zero" — the predicate
|
|
126
|
+
* below hinges on this number, so an unanswerable repo has to stay silent.
|
|
127
|
+
*
|
|
128
|
+
* The counting itself is `branch-classifier.js`'s `commitsAhead` against its
|
|
129
|
+
* `resolveBaseRef`, NOT a second copy: `origin/{default}` is the authority on
|
|
130
|
+
* what actually landed, and two drifting answers to "does this branch carry
|
|
131
|
+
* anything of its own?" is exactly what the `doctor`/`branch prune` split was
|
|
132
|
+
* unified to avoid.
|
|
133
|
+
*
|
|
134
|
+
* Note the field evidence this exists for: the branch pointed at the merge
|
|
135
|
+
* commit of somebody else's PR. That is the base's own tip — reachable from
|
|
136
|
+
* the base, and therefore zero commits of its own.
|
|
137
|
+
*/
|
|
138
|
+
function ownCommitCount(baseDir, branch) {
|
|
139
|
+
try {
|
|
140
|
+
execFileSync('git', ['rev-parse', '--git-dir'], { cwd: baseDir, stdio: 'pipe' });
|
|
141
|
+
} catch {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
const baseRef = resolveBaseRef(detectDefaultBranch(baseDir), baseDir);
|
|
145
|
+
if (!baseRef) return null;
|
|
146
|
+
try {
|
|
147
|
+
execFileSync('git', ['show-ref', '--verify', '--quiet', `refs/heads/${branch}`], { cwd: baseDir, stdio: 'pipe' });
|
|
148
|
+
} catch {
|
|
149
|
+
return 0; // the branch never existed — nothing was ever committed for it
|
|
150
|
+
}
|
|
151
|
+
return commitsAhead(baseRef, branch, baseDir);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The orphan skeleton `create --worktree` can leave behind: a feature.json on
|
|
156
|
+
* its own, `status: in_progress`, no tasks, no gates, and a branch parked on
|
|
157
|
+
* somebody else's commit. Every one of those alone is normal at some point in
|
|
158
|
+
* a feature's life; together, and only after the feature has gone stale, they
|
|
159
|
+
* mean nobody ever came back to it.
|
|
160
|
+
*
|
|
161
|
+
* The artifact test is deliberately broader than the "0 tasks" the report
|
|
162
|
+
* named: a proposal drafted and waiting on Gate 1 also has zero tasks and is
|
|
163
|
+
* very much alive. What a dead scaffold has is NOTHING — checked in the root
|
|
164
|
+
* and, if the feature registered one, in its worktree, where uncommitted work
|
|
165
|
+
* would otherwise be invisible to both the disk scan and `rev-list`.
|
|
166
|
+
*
|
|
167
|
+
* @returns {string|null} the issue detail, or null when the feature is alive
|
|
168
|
+
*/
|
|
169
|
+
function abandonedScaffold(baseDir, feature, fdir, fj) {
|
|
170
|
+
if (fj?.status !== 'in_progress') return null;
|
|
171
|
+
|
|
172
|
+
// "0 gates": a gate stamped `policy:{workType}` at birth (chore/hotfix) was
|
|
173
|
+
// never a decision anyone took, so it must not read as progress.
|
|
174
|
+
const decided = Object.values(fj.approvalGates || {}).some(
|
|
175
|
+
(g) => g?.approved === true && !String(g?.approvedBy ?? '').startsWith('policy:'),
|
|
176
|
+
);
|
|
177
|
+
if (decided) return null;
|
|
178
|
+
|
|
179
|
+
if (hasArtifacts(fdir)) return null;
|
|
180
|
+
const wtFeatureDir = fj.worktree?.path
|
|
181
|
+
? join(fj.worktree.path, '.morph', 'features', feature)
|
|
182
|
+
: null;
|
|
183
|
+
if (wtFeatureDir && existsSync(wtFeatureDir) && hasArtifacts(wtFeatureDir)) return null;
|
|
184
|
+
|
|
185
|
+
const age = Date.now() - lastTouched(fdir, fj);
|
|
186
|
+
if (age < ABANDONED_SCAFFOLD_MIN_AGE_MS) return null;
|
|
187
|
+
|
|
188
|
+
// The registered branch when the feature has one, `morph/{f}` otherwise —
|
|
189
|
+
// same name `branchDrift` above assumes.
|
|
190
|
+
const branch = fj.worktree?.branch || `morph/${feature}`;
|
|
191
|
+
if (ownCommitCount(baseDir, branch) !== 0) return null;
|
|
192
|
+
|
|
193
|
+
const days = Math.floor(age / (24 * 60 * 60 * 1000));
|
|
194
|
+
return `in_progress há ${days}d sem nada produzido: 0 artefatos, 0 gates aprovados `
|
|
195
|
+
+ `e \`${branch}\` sem commit próprio — rode \`morph-spec delete ${feature} --archive\` `
|
|
196
|
+
+ 'ou retome a feature';
|
|
197
|
+
}
|
|
198
|
+
|
|
75
199
|
/**
|
|
76
200
|
* @param {string} [baseDir] - project root (defaults to cwd)
|
|
77
201
|
* @returns {Array<{ feature: string, type: string, detail: string }>} issues
|
|
@@ -106,6 +230,9 @@ export function auditFeatureLifecycle(baseDir = process.cwd()) {
|
|
|
106
230
|
const drift = branchDrift(baseDir, name, fj);
|
|
107
231
|
if (drift) issues.push({ feature: name, type: 'branch-drift', detail: drift });
|
|
108
232
|
|
|
233
|
+
const dead = abandonedScaffold(baseDir, name, fdir, fj);
|
|
234
|
+
if (dead) issues.push({ feature: name, type: 'abandoned-scaffold', detail: dead });
|
|
235
|
+
|
|
109
236
|
const miss = missingIdentity(fj);
|
|
110
237
|
if (miss.length) {
|
|
111
238
|
issues.push({ feature: name, type: 'identity', detail: `missing ${miss.join(', ')}` });
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* branch-classifier.js — answer "is this branch's work already in the default
|
|
3
|
+
* branch?" for a repository that squash-merges.
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS EXISTS: a squash-merge puts the WORK in `origin/master` while
|
|
6
|
+
* leaving none of the branch's own commits reachable from it. So
|
|
7
|
+
* `git branch --merged origin/master` does not list the branch, and
|
|
8
|
+
* `git branch -d` refuses it as unmerged. The only remaining command is
|
|
9
|
+
* `git branch -D`, which is byte-identical to the command that discards real,
|
|
10
|
+
* never-integrated work — the tool gives the user no way to tell the two apart,
|
|
11
|
+
* and after enough of these the habit becomes "just -D it", which is how work
|
|
12
|
+
* gets lost.
|
|
13
|
+
*
|
|
14
|
+
* Every branch lands in exactly one class, and only the two MERGED ones are
|
|
15
|
+
* ever deletable:
|
|
16
|
+
*
|
|
17
|
+
* merged-ancestral — git itself proves it: the branch tip is an ancestor of
|
|
18
|
+
* the default. A plain merge, or a fast-forward.
|
|
19
|
+
* merged-squash — git cannot see it, but the evidence is positive: either
|
|
20
|
+
* GitHub reports the PR as MERGED, or the branch's tree is
|
|
21
|
+
* IDENTICAL to the default's (every change it carries is
|
|
22
|
+
* already there under a different commit).
|
|
23
|
+
* open — a PR is OPEN for it. Never deleted: the PR is the whole
|
|
24
|
+
* reason the branch must survive.
|
|
25
|
+
* in-worktree — checked out by a live worktree. Never deleted.
|
|
26
|
+
* unmerged — none of the above could be PROVEN. This is the fail-safe
|
|
27
|
+
* default and covers "no `gh`", "no origin", and "genuinely
|
|
28
|
+
* unintegrated work" alike, because from here they are the
|
|
29
|
+
* same thing: not proven integrated.
|
|
30
|
+
*
|
|
31
|
+
* Nothing here mutates. `pruneBranches` in commands/project/branch.js does the
|
|
32
|
+
* deleting, and it deletes ONLY what this file classified as merged.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { execFileSync } from 'child_process';
|
|
36
|
+
|
|
37
|
+
function git(args, cwd) {
|
|
38
|
+
return execFileSync('git', args, { cwd, stdio: 'pipe' }).toString();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Local branches with their tips. Excludes nothing — the caller decides.
|
|
43
|
+
*
|
|
44
|
+
* @returns {Array<{ name: string, sha: string, current: boolean, worktreePath: string|null }>}
|
|
45
|
+
*/
|
|
46
|
+
export function listLocalBranches(cwd) {
|
|
47
|
+
try {
|
|
48
|
+
const out = git(['for-each-ref', '--format=%(refname:short)%09%(objectname)%09%(HEAD)%09%(worktreepath)', 'refs/heads/'], cwd);
|
|
49
|
+
return out.split('\n').filter(Boolean).map((line) => {
|
|
50
|
+
const [name, sha, head, worktreePath] = line.split('\t');
|
|
51
|
+
return { name, sha, current: head === '*', worktreePath: worktreePath || null };
|
|
52
|
+
});
|
|
53
|
+
} catch {
|
|
54
|
+
return [];
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Is `ancestor` reachable from `descendant`? False on any git error. */
|
|
59
|
+
function isAncestor(ancestor, descendant, cwd) {
|
|
60
|
+
try {
|
|
61
|
+
git(['merge-base', '--is-ancestor', ancestor, descendant], cwd);
|
|
62
|
+
return true;
|
|
63
|
+
} catch {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Does the branch introduce NOTHING the base does not already have?
|
|
70
|
+
*
|
|
71
|
+
* `git diff --quiet <base> <branch>` compares the two TREES: an empty diff
|
|
72
|
+
* means every file the branch touched already reads exactly as the branch left
|
|
73
|
+
* it in the base. That is precisely the state a squash-merge produces, and it
|
|
74
|
+
* is a positive proof rather than an inference from history shape.
|
|
75
|
+
*
|
|
76
|
+
* Deliberately NOT `base...branch` (the three-dot form diffs against the merge
|
|
77
|
+
* base, which for a squash-merged branch still shows the branch's own changes).
|
|
78
|
+
*
|
|
79
|
+
* Fail-CLOSED: any git error reads as "introduces something", never as merged.
|
|
80
|
+
*/
|
|
81
|
+
function treeIsIdentical(base, branch, cwd) {
|
|
82
|
+
try {
|
|
83
|
+
execFileSync('git', ['diff', '--quiet', base, branch], { cwd, stdio: 'pipe' });
|
|
84
|
+
return true;
|
|
85
|
+
} catch {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* How many commits the branch carries that the base does not. Used only for
|
|
92
|
+
* REPORTING — the classification never turns on it, because a squash-merged
|
|
93
|
+
* branch legitimately has commits ahead while carrying no unintegrated work.
|
|
94
|
+
*
|
|
95
|
+
* @returns {number|null} null when it cannot be computed
|
|
96
|
+
*/
|
|
97
|
+
export function commitsAhead(base, branch, cwd) {
|
|
98
|
+
try {
|
|
99
|
+
const out = git(['rev-list', '--count', `${base}..${branch}`], cwd).trim();
|
|
100
|
+
const n = Number(out);
|
|
101
|
+
return Number.isFinite(n) ? n : null;
|
|
102
|
+
} catch {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Resolve the ref to compare against: `origin/<default>` when the remote-tracking
|
|
109
|
+
* ref exists (the authority on what has actually landed), else the local branch.
|
|
110
|
+
*
|
|
111
|
+
* @returns {string|null} null when neither resolves — the caller must then
|
|
112
|
+
* classify everything as `unmerged`, because there is no baseline to prove
|
|
113
|
+
* integration against.
|
|
114
|
+
*/
|
|
115
|
+
export function resolveBaseRef(defaultBranch, cwd) {
|
|
116
|
+
if (!defaultBranch) return null;
|
|
117
|
+
for (const ref of [`refs/remotes/origin/${defaultBranch}`, `refs/heads/${defaultBranch}`]) {
|
|
118
|
+
try {
|
|
119
|
+
git(['show-ref', '--verify', '--quiet', ref], cwd);
|
|
120
|
+
return ref.replace(/^refs\/(remotes|heads)\//, '');
|
|
121
|
+
} catch { /* try the next */ }
|
|
122
|
+
}
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Classify one branch. Pure decision logic over already-gathered facts, so the
|
|
128
|
+
* table above is testable without a GitHub account.
|
|
129
|
+
*
|
|
130
|
+
* ORDER IS THE POLICY, and the two protective classes come FIRST: a branch that
|
|
131
|
+
* is checked out, or that carries an OPEN PR, is never deleted even if its tree
|
|
132
|
+
* happens to match the base — a PR being open means a human still has business
|
|
133
|
+
* with it.
|
|
134
|
+
*
|
|
135
|
+
* @param {object} b - from listLocalBranches
|
|
136
|
+
* @param {{ baseRef: string|null, cwd: string, pr: {number:number,state:string}|null|undefined }} ctx
|
|
137
|
+
* @returns {{ name: string, kind: string, reason: string, pr: number|null, ahead: number|null, deletable: boolean }}
|
|
138
|
+
*/
|
|
139
|
+
export function classifyBranch(b, { baseRef, cwd, pr }) {
|
|
140
|
+
const prNum = pr?.number ?? null;
|
|
141
|
+
const mk = (kind, reason, deletable) => ({
|
|
142
|
+
name: b.name, kind, reason, pr: prNum, ahead: baseRef ? commitsAhead(baseRef, b.name, cwd) : null, deletable,
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
if (b.current || b.worktreePath) {
|
|
146
|
+
return mk('in-worktree', b.current ? 'é a branch atual (HEAD)' : `está em uso pelo worktree ${b.worktreePath}`, false);
|
|
147
|
+
}
|
|
148
|
+
if (pr?.state === 'OPEN') {
|
|
149
|
+
return mk('open', `PR #${prNum} está OPEN — a branch é o que o PR aponta`, false);
|
|
150
|
+
}
|
|
151
|
+
if (!baseRef) {
|
|
152
|
+
return mk('unmerged', 'não há branch default resolvível (origin/HEAD, main ou master) para comparar', false);
|
|
153
|
+
}
|
|
154
|
+
if (isAncestor(b.name, baseRef, cwd)) {
|
|
155
|
+
return mk('merged-ancestral', `já é ancestral de ${baseRef}`, true);
|
|
156
|
+
}
|
|
157
|
+
if (pr?.state === 'MERGED') {
|
|
158
|
+
return mk('merged-squash', `PR #${prNum} está MERGED (squash-merge: os commits não entram em ${baseRef}, o trabalho sim)`, true);
|
|
159
|
+
}
|
|
160
|
+
if (treeIsIdentical(baseRef, b.name, cwd)) {
|
|
161
|
+
return mk('merged-squash', `a árvore é idêntica à de ${baseRef} — tudo que a branch muda já está lá`, true);
|
|
162
|
+
}
|
|
163
|
+
return mk('unmerged', `carrega mudanças que não estão em ${baseRef} e nenhum PR MERGED comprova o contrário`, false);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Classify every local branch except the default itself.
|
|
168
|
+
*
|
|
169
|
+
* @param {{ cwd: string, defaultBranch: string|null, prIndex: Map|null }} ctx
|
|
170
|
+
* @returns {Array} one entry per branch, in `for-each-ref` order
|
|
171
|
+
*/
|
|
172
|
+
export function classifyBranches({ cwd, defaultBranch, prIndex }) {
|
|
173
|
+
const baseRef = resolveBaseRef(defaultBranch, cwd);
|
|
174
|
+
return listLocalBranches(cwd)
|
|
175
|
+
.filter((b) => b.name !== defaultBranch)
|
|
176
|
+
.map((b) => classifyBranch(b, { baseRef, cwd, pr: prIndex?.get(b.name) ?? null }));
|
|
177
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* gh.js — como o `gh` é invocado, num lugar só.
|
|
3
|
+
*
|
|
4
|
+
* Extraído de `finish.js`, onde nasceu, porque o `pr-index.js` chamava `'gh'`
|
|
5
|
+
* cru: o caminho de SUCESSO do índice era intestável, já que não havia por
|
|
6
|
+
* onde injetar um stub. Duas formas de chamar o mesmo binário também são duas
|
|
7
|
+
* formas de discordar sobre qual binário é.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Resolve a invocação do `gh`. Por padrão, o `gh` do PATH. Sobrescrevível via
|
|
12
|
+
* MORPH_GH_BIN: uma string simples nomeia outro binário (um wrapper corporativo,
|
|
13
|
+
* `hub`, …); um array JSON é um prefixo de argv COMPLETO — por exemplo
|
|
14
|
+
* `["/path/to/node","/path/to/gh-shim.js"]` — que é como os testes injetam um
|
|
15
|
+
* stub cross-platform que o execFileSync consegue spawnar (Node ≥20 recusa
|
|
16
|
+
* rodar shims `.cmd`/`.bat` sem `shell:true`, e `shell:true` reabriria o buraco
|
|
17
|
+
* de injeção de argumento que estes caminhos evitam).
|
|
18
|
+
*
|
|
19
|
+
* @returns {{ bin: string, prefix: string[] }}
|
|
20
|
+
*/
|
|
21
|
+
export function resolveGh() {
|
|
22
|
+
const raw = process.env.MORPH_GH_BIN;
|
|
23
|
+
if (raw && raw.trim().startsWith('[')) {
|
|
24
|
+
try {
|
|
25
|
+
const arr = JSON.parse(raw);
|
|
26
|
+
if (Array.isArray(arr) && arr.length) return { bin: String(arr[0]), prefix: arr.slice(1).map(String) };
|
|
27
|
+
} catch { /* não é JSON — trata como nome de binário abaixo */ }
|
|
28
|
+
}
|
|
29
|
+
return { bin: raw && raw.trim() ? raw.trim() : 'gh', prefix: [] };
|
|
30
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pending-prs.js — as features que existem SÓ como PR aberto.
|
|
3
|
+
*
|
|
4
|
+
* `finish --pr` deixa o root primário sem vestígio da feature: o
|
|
5
|
+
* `shedRootFeatureScope` apaga `.morph/features/{f}` e o archive foi commitado
|
|
6
|
+
* na branch `morph/{f}`, chegando na default apenas no merge. Entre abrir e
|
|
7
|
+
* mergear, portanto, nenhuma superfície local sabe que aquela branch tem dono —
|
|
8
|
+
* foi assim que uma branch com PR #51 aberto quase foi varrida.
|
|
9
|
+
*
|
|
10
|
+
* Este módulo devolve o dono. Não há estado persistido a consultar, e é de
|
|
11
|
+
* propósito: o PR aberto no GitHub É o registro, a branch é sempre
|
|
12
|
+
* `morph/{feature}`, e o `pr-index.js` já é o leitor único desse fato. Lembrar
|
|
13
|
+
* o número numa segunda cópia só criaria como divergir dele.
|
|
14
|
+
*
|
|
15
|
+
* `null` (do índice ou daqui) significa DESCONHECIDO e jamais "não há
|
|
16
|
+
* pendência" — a mesma regra que torna seguros os caminhos destrutivos que
|
|
17
|
+
* leem o índice.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { existsSync } from 'fs';
|
|
21
|
+
import { join } from 'path';
|
|
22
|
+
import { buildPrIndex } from './pr-index.js';
|
|
23
|
+
|
|
24
|
+
/** As branches que o framework cria por feature. */
|
|
25
|
+
export const MORPH_BRANCH_RE = /^morph\/(.+)$/;
|
|
26
|
+
|
|
27
|
+
const DAY_MS = 86400000;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* O PR da branch canônica de uma feature.
|
|
31
|
+
*
|
|
32
|
+
* @param {Map<string,object>|null} index - de buildPrIndex
|
|
33
|
+
* @param {string} feature
|
|
34
|
+
* @returns {{number:number,state:string,mergedAt:string|null,url:string|null,createdAt:string|null}|null}
|
|
35
|
+
* null = desconhecido OU sem PR; quem precisa distinguir os dois olha o índice.
|
|
36
|
+
*/
|
|
37
|
+
export function prForFeature(index, feature) {
|
|
38
|
+
if (!index) return null;
|
|
39
|
+
return index.get(`morph/${feature}`) || null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A feature tem pasta (ativa ou arquivada) nesta árvore? */
|
|
43
|
+
function hasLocalFolder(root, feature) {
|
|
44
|
+
return existsSync(join(root, '.morph', 'features', feature))
|
|
45
|
+
|| existsSync(join(root, '.morph', 'archive', feature));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* As features aguardando merge: PR OPEN numa branch `morph/*` cujo slug não
|
|
50
|
+
* tem pasta nesta árvore.
|
|
51
|
+
*
|
|
52
|
+
* @param {string} root - a raiz a inspecionar (e de onde o `gh` roda)
|
|
53
|
+
* @param {{ index?: Map<string,object>|null, now?: Date }} [opts]
|
|
54
|
+
* `index` injeta um índice já construído (o `list`/`fleet` constroem um por
|
|
55
|
+
* processo e reaproveitam; os testes passam um sintético). Ausente = constrói.
|
|
56
|
+
* @returns {Array<{feature:string,branch:string,number:number,state:string,url:string|null,createdAt:string|null,ageDays:number|null}>|null}
|
|
57
|
+
* null = DESCONHECIDO. Nunca lista vazia por falta de informação.
|
|
58
|
+
*/
|
|
59
|
+
export function pendingPrFeatures(root, opts = {}) {
|
|
60
|
+
const index = opts.index !== undefined ? opts.index : buildPrIndex(root);
|
|
61
|
+
if (!index) return null;
|
|
62
|
+
|
|
63
|
+
const now = opts.now instanceof Date ? opts.now : new Date();
|
|
64
|
+
const out = [];
|
|
65
|
+
for (const [branch, pr] of index) {
|
|
66
|
+
const m = branch.match(MORPH_BRANCH_RE);
|
|
67
|
+
if (!m) continue;
|
|
68
|
+
if (pr.state !== 'OPEN') continue;
|
|
69
|
+
const feature = m[1];
|
|
70
|
+
if (hasLocalFolder(root, feature)) continue;
|
|
71
|
+
|
|
72
|
+
let ageDays = null;
|
|
73
|
+
if (pr.createdAt) {
|
|
74
|
+
const t = Date.parse(pr.createdAt);
|
|
75
|
+
if (!Number.isNaN(t)) ageDays = Math.floor((now.getTime() - t) / DAY_MS);
|
|
76
|
+
}
|
|
77
|
+
out.push({
|
|
78
|
+
feature,
|
|
79
|
+
branch,
|
|
80
|
+
number: pr.number,
|
|
81
|
+
state: pr.state,
|
|
82
|
+
url: pr.url ?? null,
|
|
83
|
+
createdAt: pr.createdAt ?? null,
|
|
84
|
+
ageDays,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
out.sort((a, b) => a.feature.localeCompare(b.feature));
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pr-index.js — one `gh pr list` per process, shared by every caller that needs
|
|
3
|
+
* to answer "does a branch have a PR, and in what state".
|
|
4
|
+
*
|
|
5
|
+
* Extracted from `fleet-scanner.js` because three independent leaders now ask
|
|
6
|
+
* the SAME question and must get the SAME answer:
|
|
7
|
+
*
|
|
8
|
+
* • `morph-spec fleet` — the PR column
|
|
9
|
+
* • `morph-spec branch prune` — a branch with an OPEN PR is never deleted,
|
|
10
|
+
* and a MERGED one is deletable even when git
|
|
11
|
+
* cannot see the merge (squash-merge)
|
|
12
|
+
* • `morph-spec doctor --worktrees` — a worktree whose branch carries an open
|
|
13
|
+
* PR must NOT be offered up for removal
|
|
14
|
+
*
|
|
15
|
+
* Fail-open by construction: no `gh`, not authenticated, not GitHub, or a
|
|
16
|
+
* timeout all yield `null`. `null` means "unknown", NEVER "no PR" — every
|
|
17
|
+
* caller must treat the two differently, because the destructive paths
|
|
18
|
+
* (prune, remove) are only safe when the answer is a positive fact.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { execFileSync } from 'child_process';
|
|
22
|
+
import { resolveGh } from './gh.js';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Build the branch → PR index for a repository.
|
|
26
|
+
*
|
|
27
|
+
* Gated on the origin URL actually pointing at github.com: `gh` against any
|
|
28
|
+
* other host is a guaranteed error, and paying its start-up cost to learn that
|
|
29
|
+
* on every call is pure latency.
|
|
30
|
+
*
|
|
31
|
+
* `--state all` on purpose: a MERGED PR is exactly the evidence a squash-merge
|
|
32
|
+
* erases from git history, and it is the whole reason `branch prune` exists.
|
|
33
|
+
* First wins — `gh` lists newest-first, so an OPEN PR shadows an older closed
|
|
34
|
+
* one on the same branch.
|
|
35
|
+
*
|
|
36
|
+
* @param {string} root - repository root to run `gh` from
|
|
37
|
+
* @param {{ limit?: number, timeout?: number }} [opts]
|
|
38
|
+
* @returns {Map<string,{number:number,state:string,mergedAt:string|null,url:string|null,createdAt:string|null}>|null}
|
|
39
|
+
* null = DESCONHECIDO (sem gh / não é GitHub / falhou), não "sem PRs"
|
|
40
|
+
*/
|
|
41
|
+
export function buildPrIndex(root, opts = {}) {
|
|
42
|
+
const limit = String(opts.limit ?? 200);
|
|
43
|
+
const timeout = opts.timeout ?? 8000;
|
|
44
|
+
try {
|
|
45
|
+
const url = execFileSync('git', ['remote', 'get-url', 'origin'], { cwd: root, stdio: 'pipe' }).toString();
|
|
46
|
+
if (!/github\.com/i.test(url)) return null;
|
|
47
|
+
const { bin, prefix } = resolveGh();
|
|
48
|
+
const out = execFileSync(
|
|
49
|
+
bin,
|
|
50
|
+
[...prefix, 'pr', 'list', '--state', 'all', '--limit', limit,
|
|
51
|
+
'--json', 'number,state,headRefName,mergedAt,url,createdAt'],
|
|
52
|
+
{ cwd: root, stdio: 'pipe', timeout },
|
|
53
|
+
).toString();
|
|
54
|
+
const map = new Map();
|
|
55
|
+
for (const pr of JSON.parse(out)) {
|
|
56
|
+
if (pr?.headRefName && !map.has(pr.headRefName)) {
|
|
57
|
+
map.set(pr.headRefName, {
|
|
58
|
+
number: pr.number,
|
|
59
|
+
state: pr.state,
|
|
60
|
+
mergedAt: pr.mergedAt ?? null,
|
|
61
|
+
url: pr.url ?? null,
|
|
62
|
+
createdAt: pr.createdAt ?? null,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return map;
|
|
67
|
+
} catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|