@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
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import { join, relative } from 'path';
|
|
2
2
|
import { execSync } from 'child_process';
|
|
3
|
+
import { existsSync } from 'fs';
|
|
3
4
|
import { platform, homedir } from 'os';
|
|
4
5
|
import fs from 'fs-extra';
|
|
5
6
|
import chalk from 'chalk';
|
|
6
7
|
import { logger } from '../../utils/logger.js';
|
|
7
8
|
import { pathExists, readJson } from '../../utils/file-copier.js';
|
|
8
9
|
import { checkClaudeMdImport } from '../../utils/claude-md-injector.js';
|
|
10
|
+
import { buildPrIndex } from '../../lib/git/pr-index.js';
|
|
11
|
+
import { pendingPrFeatures, MORPH_BRANCH_RE } from '../../lib/git/pending-prs.js';
|
|
9
12
|
import {
|
|
10
13
|
checkCLIOutdated,
|
|
11
14
|
checkProjectOutdated,
|
|
@@ -556,10 +559,12 @@ async function doctorMcpCommand(targetPath) {
|
|
|
556
559
|
* Orphan worktrees used to accumulate invisibly: `fleet` only reports worktrees
|
|
557
560
|
* on a `morph/*` branch, so anything else never showed up anywhere.
|
|
558
561
|
*
|
|
559
|
-
* `--prune` runs the
|
|
560
|
-
* directories that are already gone)
|
|
561
|
-
*
|
|
562
|
-
*
|
|
562
|
+
* `--prune` runs the safe repairs: `git worktree prune` (admin records for
|
|
563
|
+
* directories that are already gone) and the removal of EMPTY unregistered
|
|
564
|
+
* residue directories, which by definition hold nothing that can be lost.
|
|
565
|
+
* Deleting a directory that still HOLDS something is reported with the exact
|
|
566
|
+
* command, never performed — same PROVE-or-DECLINE discipline as the finish
|
|
567
|
+
* teardown.
|
|
563
568
|
*/
|
|
564
569
|
export async function doctorWorktreesCommand(targetPath, options = {}) {
|
|
565
570
|
const { auditWorktrees, pruneWorktrees } = await import('../../lib/worktree/worktree-audit.js');
|
|
@@ -568,19 +573,28 @@ export async function doctorWorktreesCommand(targetPath, options = {}) {
|
|
|
568
573
|
const beforeIssues = auditWorktrees(targetPath);
|
|
569
574
|
const beforeMissing = beforeIssues.filter((i) => i.type === 'registered-missing').length;
|
|
570
575
|
const beforeDangling = beforeIssues.filter((i) => i.type === 'dangling-registration').length;
|
|
576
|
+
const beforeEmptyDirs = beforeIssues.filter((i) => i.type === 'unregistered-dir' && i.empty).length;
|
|
571
577
|
const result = await pruneWorktrees(targetPath);
|
|
572
578
|
if (result.pruned) logger.success(`git worktree prune: ${beforeMissing} registro(s) órfão(s) removido(s).`);
|
|
573
579
|
else logger.warn(`prune falhou: ${result.error}`);
|
|
580
|
+
if (result.removedEmptyDirs.length) {
|
|
581
|
+
logger.success(`${result.removedEmptyDirs.length}/${beforeEmptyDirs} diretório(s) vazio(s) removido(s): ${result.removedEmptyDirs.join(', ')}`);
|
|
582
|
+
}
|
|
574
583
|
if (result.clearedRegistrations.length) {
|
|
575
584
|
logger.success(`registro de worktree limpo em ${result.clearedRegistrations.length}/${beforeDangling} feature(s): ${result.clearedRegistrations.map((r) => r.feature).join(', ')}`);
|
|
576
585
|
}
|
|
577
586
|
}
|
|
578
587
|
|
|
579
588
|
const issues = auditWorktrees(targetPath);
|
|
589
|
+
// `severity: 'info'` entries (a worktree carrying unintegrated work) are
|
|
590
|
+
// REPORTED but are not failures — a `doctor --worktrees || ...` in CI must not
|
|
591
|
+
// go red because somebody has an open PR. The human path below splits the same
|
|
592
|
+
// way; both surfaces must agree on what counts as broken.
|
|
593
|
+
const isProblem = (i) => i.severity !== 'info';
|
|
580
594
|
|
|
581
595
|
if (options.json) {
|
|
582
596
|
console.log(JSON.stringify({ issues }, null, 2));
|
|
583
|
-
return issues.
|
|
597
|
+
return !issues.some(isProblem);
|
|
584
598
|
}
|
|
585
599
|
|
|
586
600
|
logger.header('MORPH-SPEC Worktrees');
|
|
@@ -589,18 +603,90 @@ export async function doctorWorktreesCommand(targetPath, options = {}) {
|
|
|
589
603
|
return true;
|
|
590
604
|
}
|
|
591
605
|
|
|
592
|
-
|
|
593
|
-
|
|
606
|
+
// `unintegrated-work` is NOT an inconsistency — it is a worktree doing its
|
|
607
|
+
// job, reported so the reader knows why no cleanup is offered for it. Counting
|
|
608
|
+
// it as a problem is how a diagnostic starts nagging people into destroying
|
|
609
|
+
// things to make the warning go away.
|
|
610
|
+
const informational = issues.filter((i) => !isProblem(i));
|
|
611
|
+
const problems = issues.filter(isProblem);
|
|
612
|
+
|
|
613
|
+
if (problems.length) logger.warn(`${problems.length} inconsistência(s):`);
|
|
614
|
+
for (const i of problems) {
|
|
594
615
|
console.log(` ⚠ ${i.type}`);
|
|
595
616
|
console.log(` ${i.detail}`);
|
|
596
617
|
console.log(` → ${i.fix}`);
|
|
597
618
|
}
|
|
598
|
-
|
|
599
|
-
|
|
619
|
+
for (const i of informational) {
|
|
620
|
+
console.log(` ℹ ${i.type}`);
|
|
621
|
+
console.log(` ${i.detail}`);
|
|
622
|
+
console.log(` → ${i.fix}`);
|
|
623
|
+
}
|
|
624
|
+
// An EMPTY unregistered directory is prunable too — it holds nothing that can
|
|
625
|
+
// be lost — so the hint must mention it, or the one issue `--prune` now fixes
|
|
626
|
+
// automatically would still read as a manual chore.
|
|
627
|
+
const prunable = (i) => i.type === 'registered-missing'
|
|
628
|
+
|| i.type === 'dangling-registration'
|
|
629
|
+
|| (i.type === 'unregistered-dir' && i.empty);
|
|
630
|
+
if (problems.some(prunable) && !options.prune) {
|
|
631
|
+
logger.dim('\n Rode `morph-spec doctor --worktrees --prune` para limpar os registros órfãos e os diretórios vazios.');
|
|
632
|
+
}
|
|
633
|
+
if (!problems.length) {
|
|
634
|
+
logger.success('Nenhuma inconsistência — os itens acima são informativos.');
|
|
635
|
+
return true;
|
|
600
636
|
}
|
|
601
637
|
return false;
|
|
602
638
|
}
|
|
603
639
|
|
|
640
|
+
/** Dias a partir dos quais um PR aberto vira aviso. */
|
|
641
|
+
const PR_STALE_DAYS = 7;
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* As inconsistências de PR desta árvore.
|
|
645
|
+
*
|
|
646
|
+
* pr-awaiting-merge — PR aberto há muito, sem cópia local: trabalho em
|
|
647
|
+
* revisão que nenhuma superfície local reivindica.
|
|
648
|
+
* pr-merged-not-landed — o PR mergeou mas `archive/{f}` não está aqui. O
|
|
649
|
+
* archive viaja no diff do PR, então ou falta um
|
|
650
|
+
* `git pull`, ou ele não estava no diff.
|
|
651
|
+
*
|
|
652
|
+
* Offline-safe: índice `null` devolve `null`, NUNCA lista vazia — lista vazia
|
|
653
|
+
* afirmaria "perguntei e não achei nada", que é diferente de "não pude
|
|
654
|
+
* perguntar". Achado #3: um `[]` disfarçado de "sem problema" é o que fazia
|
|
655
|
+
* `doctor` imprimir `✓ pull requests` mesmo sem conseguir consultar o `gh`.
|
|
656
|
+
*
|
|
657
|
+
* @returns {Array<{feature:string,type:string,detail:string}>|null}
|
|
658
|
+
* null = DESCONHECIDO (índice não pôde ser construído); nunca "sem PRs".
|
|
659
|
+
*/
|
|
660
|
+
function auditPullRequests(root) {
|
|
661
|
+
const index = buildPrIndex(root);
|
|
662
|
+
if (!index) return null;
|
|
663
|
+
const issues = [];
|
|
664
|
+
|
|
665
|
+
for (const p of pendingPrFeatures(root, { index }) || []) {
|
|
666
|
+
if (p.ageDays != null && p.ageDays >= PR_STALE_DAYS) {
|
|
667
|
+
issues.push({
|
|
668
|
+
feature: p.feature,
|
|
669
|
+
type: 'pr-awaiting-merge',
|
|
670
|
+
detail: `PR #${p.number} aberto há ${p.ageDays}d e sem cópia local — mergeie ou feche`,
|
|
671
|
+
});
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
for (const [branch, pr] of index) {
|
|
676
|
+
const m = branch.match(MORPH_BRANCH_RE);
|
|
677
|
+
if (!m || pr.state !== 'MERGED') continue;
|
|
678
|
+
const feature = m[1];
|
|
679
|
+
if (existsSync(join(root, '.morph', 'archive', feature))) continue;
|
|
680
|
+
issues.push({
|
|
681
|
+
feature,
|
|
682
|
+
type: 'pr-merged-not-landed',
|
|
683
|
+
detail: `PR #${pr.number} foi mergeado mas .morph/archive/${feature} não está aqui — falta \`git pull\`?`,
|
|
684
|
+
});
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
return issues;
|
|
688
|
+
}
|
|
689
|
+
|
|
604
690
|
export async function doctorCommand(options = {}) {
|
|
605
691
|
// `--json` and `--prune` only mean something under `--worktrees`. Falling
|
|
606
692
|
// through to the human health check would hand a script a banner and a table
|
|
@@ -1075,6 +1161,32 @@ export async function doctorCommand(options = {}) {
|
|
|
1075
1161
|
hasWarnings = true;
|
|
1076
1162
|
}
|
|
1077
1163
|
|
|
1164
|
+
// Pull requests — o pendurado (aberto há dias sem cópia local) e o mergeado
|
|
1165
|
+
// que não pousou (archive não chegou nesta árvore). Offline-safe: sem `gh`,
|
|
1166
|
+
// sem origin no GitHub, ou qualquer falha, `auditPullRequests` nunca lança
|
|
1167
|
+
// (achado #7 — mesma guarda fail-open que `fleet-scanner.js` já usa para a
|
|
1168
|
+
// síntese idêntica) e devolve `null` — achado #3: um índice desconhecido
|
|
1169
|
+
// renderiza como "não verificado", nunca como `✓ ok`, porque `[]` e
|
|
1170
|
+
// "não pude perguntar" não são a mesma coisa.
|
|
1171
|
+
let prIssues;
|
|
1172
|
+
try {
|
|
1173
|
+
prIssues = auditPullRequests(targetPath);
|
|
1174
|
+
} catch {
|
|
1175
|
+
prIssues = null; // fail-open — um doctor não pode ficar mais ruidoso porque o gh sumiu
|
|
1176
|
+
}
|
|
1177
|
+
if (prIssues === null) {
|
|
1178
|
+
checks.push({ name: 'pull requests', status: 'skip', msg: 'gh indisponível — não verificado' });
|
|
1179
|
+
} else if (prIssues.length === 0) {
|
|
1180
|
+
checks.push({ name: 'pull requests', status: 'ok' });
|
|
1181
|
+
} else {
|
|
1182
|
+
checks.push({
|
|
1183
|
+
name: 'pull requests',
|
|
1184
|
+
status: 'warn',
|
|
1185
|
+
msg: `${prIssues.length} pendência${prIssues.length === 1 ? '' : 's'} (veja abaixo)`,
|
|
1186
|
+
});
|
|
1187
|
+
hasWarnings = true;
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1078
1190
|
// Codebase graph — opcional (ADR-002). Nunca 'missing': um projeto que não
|
|
1079
1191
|
// usa o grafo tem de continuar com o doctor verde.
|
|
1080
1192
|
const graphAudit = auditGraph(targetPath);
|
|
@@ -1130,6 +1242,11 @@ export async function doctorCommand(options = {}) {
|
|
|
1130
1242
|
console.log(chalk.red(` ✗ ${check.name}`) + chalk.gray(' (missing)'));
|
|
1131
1243
|
} else if (check.status === 'error') {
|
|
1132
1244
|
console.log(chalk.red(` ✗ ${check.name}`) + chalk.gray(` (${check.msg})`));
|
|
1245
|
+
} else if (check.status === 'skip') {
|
|
1246
|
+
// "Não verificado" — nem ok nem falha. Nunca soma em hasWarnings/hasErrors:
|
|
1247
|
+
// um `gh` indisponível não pode deixar o doctor mais barulhento nem mudar
|
|
1248
|
+
// o exit code (achado #3).
|
|
1249
|
+
console.log(chalk.gray(` ○ ${check.name}`) + chalk.gray(check.msg ? ` (${check.msg})` : ''));
|
|
1133
1250
|
}
|
|
1134
1251
|
}
|
|
1135
1252
|
|
|
@@ -1144,6 +1261,17 @@ export async function doctorCommand(options = {}) {
|
|
|
1144
1261
|
console.log(chalk.gray(" Fix: 'morph-spec approve' (order), '--description' on create, or 'morph-spec delete' for dead scaffolds."));
|
|
1145
1262
|
}
|
|
1146
1263
|
|
|
1264
|
+
// Per-feature detail for the PR inconsistencies (item: pendurado ou merge
|
|
1265
|
+
// que não pousou).
|
|
1266
|
+
if (prIssues?.length) {
|
|
1267
|
+
logger.blank();
|
|
1268
|
+
console.log(chalk.yellow(' Pull requests pendentes:'));
|
|
1269
|
+
for (const issue of prIssues) {
|
|
1270
|
+
console.log(chalk.gray(` • ${issue.feature} [${issue.type}] — ${issue.detail}`));
|
|
1271
|
+
}
|
|
1272
|
+
console.log(chalk.gray(' `finish --pr` abre o PR; quem encerra a feature é o merge.'));
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1147
1275
|
logger.blank();
|
|
1148
1276
|
|
|
1149
1277
|
if (hasErrors) {
|
|
@@ -55,14 +55,21 @@ export function fleetCommand(options = {}) {
|
|
|
55
55
|
};
|
|
56
56
|
|
|
57
57
|
console.log(chalk.white.bold(
|
|
58
|
-
|
|
58
|
+
// PHASE at 18: `aguardando-merge` (the pr-pending synthesized phase, see
|
|
59
|
+
// fleet-scanner.js) is 16 chars — 1 wider than the col width `list.js`
|
|
60
|
+
// already uses (17) for the identical string. A narrower column here
|
|
61
|
+
// would misalign every column after it just for that one row.
|
|
62
|
+
col('FEATURE', 20) + col('TYPE', 9) + col('PHASE', 18) + col('TASKS', 8)
|
|
59
63
|
+ col('GATES PEND.', 20) + col('WHERE', 24) + col('REMOTO', 16) + col('CLAIM', 16)
|
|
60
64
|
+ col('PORTS', 12) + col('HANDOFF', 9) + 'ATIVIDADE',
|
|
61
65
|
));
|
|
62
66
|
for (const e of fleet.entries) {
|
|
63
67
|
const tasks = e.tasks ? `${e.tasks.done}/${e.tasks.total}` : '-';
|
|
64
68
|
const gates = e.pendingGates.length ? e.pendingGates.join(',') : '-';
|
|
65
|
-
|
|
69
|
+
// pr-pending has no worktree and isn't root-resident either — its branch
|
|
70
|
+
// is the only place it exists, so show that instead of the misleading
|
|
71
|
+
// 'raiz' a location-blind check would print.
|
|
72
|
+
const where = (e.location === 'worktree' || e.location === 'pr-pending') ? e.branch : 'raiz';
|
|
66
73
|
const ports = e.env ? `${e.env.range[0]}-${e.env.range[1]}` : '-';
|
|
67
74
|
const handoff = e.handoff?.exists ? 'sim' : '-';
|
|
68
75
|
const activity = [
|
|
@@ -73,7 +80,7 @@ export function fleetCommand(options = {}) {
|
|
|
73
80
|
// length and append the padding by hand.
|
|
74
81
|
const claimPlain = e.claim ? `${e.claim.live ? 'sessão' : 'morta'} (${claimAge(e.claim)})` : '-';
|
|
75
82
|
console.log(
|
|
76
|
-
col(e.feature, 20) + col(e.workType || '-', 9) + col(e.phase,
|
|
83
|
+
col(e.feature, 20) + col(e.workType || '-', 9) + col(e.phase, 18) + col(tasks, 8)
|
|
77
84
|
+ col(gates, 20) + col(where, 24) + col(remoteCell(e.remote), 16)
|
|
78
85
|
+ claimCell(e.claim) + ' '.repeat(Math.max(1, 16 - claimPlain.length))
|
|
79
86
|
+ col(ports, 12) + col(handoff, 9) + activity,
|
|
@@ -35,6 +35,7 @@ import { existsSync, mkdirSync, symlinkSync, readFileSync, writeFileSync, append
|
|
|
35
35
|
import { join, resolve, dirname } from 'path';
|
|
36
36
|
import chalk from 'chalk';
|
|
37
37
|
import { detectDefaultBranch } from '../../lib/git/default-branch.js';
|
|
38
|
+
import { triageIgnoredPaths } from '../../lib/worktree/ignored-triage.js';
|
|
38
39
|
// The reparse scan lives in hooks/shared because the PreToolUse guardrail needs
|
|
39
40
|
// the SAME answer this file does, and hooks can never import from `src/`.
|
|
40
41
|
import { findReparsePoints } from '../../../framework/hooks/shared/reparse-scan.js';
|
|
@@ -767,23 +768,6 @@ export function unlinkWorktreeInfraLinks(worktreePath, mainCwd) {
|
|
|
767
768
|
*/
|
|
768
769
|
const IGNORED_SHIELDABLE_PREFIXES = ['.morph/'];
|
|
769
770
|
|
|
770
|
-
/**
|
|
771
|
-
* Well-known, regenerable build artifacts — `bin/`, `obj/` (dotnet), a real
|
|
772
|
-
* `node_modules` left behind outside the setup-created junction, and
|
|
773
|
-
* `*.tsbuildinfo` (tsc incremental cache). These are the everyday leftovers of
|
|
774
|
-
* running a build/test cycle inside a worktree, not user data: they are fully
|
|
775
|
-
* reproducible from source, and requiring `--discard-ignored` for every finish
|
|
776
|
-
* that ever ran a build turns the flag from "explicit consent for a real risk"
|
|
777
|
-
* into a rubber stamp nobody reads anymore. Auto-discarding them (like any
|
|
778
|
-
* `discarded` entry, WITHOUT a backup — see the note below) is safe precisely
|
|
779
|
-
* because "regenerable" is a stronger guarantee than "backed up".
|
|
780
|
-
*
|
|
781
|
-
* Deliberately narrow — do NOT add speculative patterns (`dist/`, `.next/`)
|
|
782
|
-
* beyond what's proven to be pure noise; anything else stays in `blocking` by
|
|
783
|
-
* default, requiring the explicit `--discard-ignored` consent.
|
|
784
|
-
*
|
|
785
|
-
* @param {string} p - forward-slash relative path from `git status --ignored`
|
|
786
|
-
*/
|
|
787
771
|
/**
|
|
788
772
|
* Whether a dirty root-side path is EXACTLY a sibling feature's live worktree
|
|
789
773
|
* registration — see the call site (primaryRootUnexplainedDirt, the root-clean
|
|
@@ -811,16 +795,9 @@ function isLiveSiblingWorktreeRegistration(relPath, root, feature) {
|
|
|
811
795
|
}
|
|
812
796
|
}
|
|
813
797
|
|
|
814
|
-
function isDisposableBuildArtifact(p) {
|
|
815
|
-
if (/\.tsbuildinfo$/.test(p)) return true;
|
|
816
|
-
const segments = p.split('/').filter(Boolean);
|
|
817
|
-
return segments.some((s) => s === 'bin' || s === 'obj' || s === 'node_modules');
|
|
818
|
-
}
|
|
819
|
-
|
|
820
798
|
/**
|
|
821
|
-
* Split the worktree's GITIGNORED leftovers into what this command may
|
|
822
|
-
*
|
|
823
|
-
* one) and what it must refuse to destroy.
|
|
799
|
+
* Split the worktree's GITIGNORED leftovers into what this command may delete
|
|
800
|
+
* outright, what it must COPY OUT first, and what it cannot even enumerate.
|
|
824
801
|
*
|
|
825
802
|
* Why this exists: `git worktree remove` deletes gitignored files outright, and
|
|
826
803
|
* the clean-check that gates it uses `git status --porcelain`, which omits them
|
|
@@ -829,14 +806,33 @@ function isDisposableBuildArtifact(p) {
|
|
|
829
806
|
* disposable; morph must not, because "not in git" is exactly the property that
|
|
830
807
|
* makes a file unrecoverable.
|
|
831
808
|
*
|
|
832
|
-
*
|
|
833
|
-
*
|
|
809
|
+
* The buckets, and why refusing is no longer one of them: the earlier version
|
|
810
|
+
* blocked on ANY unrecognised gitignored path, which in practice meant blocking
|
|
811
|
+
* on `.env` files that were byte-identical to the primary root's — nothing was
|
|
812
|
+
* at stake, and the only escape offered (`--discard-ignored`) also destroyed the
|
|
813
|
+
* genuinely unique `keys/` next to them. `triageIgnoredPaths` answers the
|
|
814
|
+
* question that actually decides whether bytes are lost (see ignored-triage.js),
|
|
815
|
+
* so the command can PROCEED in every case, backing up only what exists nowhere
|
|
816
|
+
* else. A refusal survives for exactly one reason now: the backup FAILED.
|
|
817
|
+
*
|
|
818
|
+
* shieldable — morph's own bookkeeping + anything EXCLUSIVE to this worktree.
|
|
819
|
+
* Copied to `finish-backup` before the removal; a failed copy is
|
|
820
|
+
* what declines the removal.
|
|
821
|
+
* discarded — build artifacts (regenerable) and REPLICABLE files (the root
|
|
822
|
+
* holds the same path with the same SHA-256). Deleted without a
|
|
823
|
+
* backup because neither loses bytes.
|
|
824
|
+
* blocking — the fail-CLOSED sentinel only: `git status --ignored` itself
|
|
825
|
+
* failed, so we cannot enumerate what is at stake.
|
|
826
|
+
*
|
|
827
|
+
* `--discard-ignored` no longer means "destroy the unique ones too, blind"; it
|
|
828
|
+
* means SKIP THE BACKUP — the same removal, without the copy.
|
|
834
829
|
*
|
|
835
830
|
* @param {string} worktreePath
|
|
831
|
+
* @param {string} rootPath - the primary root, for the replica comparison
|
|
836
832
|
* @param {{ discardIgnored?: boolean }} options
|
|
837
|
-
* @returns {{ shieldable: string[], blocking: string[], discarded: string[] }}
|
|
833
|
+
* @returns {{ shieldable: string[], blocking: string[], discarded: string[], replicable: string[] }}
|
|
838
834
|
*/
|
|
839
|
-
function classifyIgnoredLeftovers(worktreePath, options = {}) {
|
|
835
|
+
function classifyIgnoredLeftovers(worktreePath, rootPath, options = {}) {
|
|
840
836
|
let out;
|
|
841
837
|
try {
|
|
842
838
|
// `-z` + execFile: NUL-delimited, no shell, no C-style quoting to undo —
|
|
@@ -850,30 +846,30 @@ function classifyIgnoredLeftovers(worktreePath, options = {}) {
|
|
|
850
846
|
// empirically, not assumed.
|
|
851
847
|
out = execFileSync('git', ['status', '--porcelain', '--ignored=matching', '-z'], { cwd: worktreePath, stdio: 'pipe' }).toString();
|
|
852
848
|
} catch {
|
|
853
|
-
return { shieldable: [], blocking: ['<git status --ignored falhou — não foi possível enumerar o que seria destruído>'], discarded: [] };
|
|
849
|
+
return { shieldable: [], blocking: ['<git status --ignored falhou — não foi possível enumerar o que seria destruído>'], discarded: [], replicable: [] };
|
|
854
850
|
}
|
|
855
851
|
|
|
856
|
-
const
|
|
857
|
-
const
|
|
858
|
-
const disposable = [];
|
|
852
|
+
const morphOwned = [];
|
|
853
|
+
const rest = [];
|
|
859
854
|
for (const record of out.split('\0')) {
|
|
860
855
|
if (!record || record.length < 4) continue;
|
|
861
856
|
if (record.slice(0, 2) !== '!!') continue; // only ignored entries; the rest is the clean-check's job
|
|
862
857
|
const p = record.slice(3).replace(/\\/g, '/');
|
|
863
|
-
if (IGNORED_SHIELDABLE_PREFIXES.some((prefix) => p.startsWith(prefix)))
|
|
864
|
-
else
|
|
865
|
-
|
|
858
|
+
if (IGNORED_SHIELDABLE_PREFIXES.some((prefix) => p.startsWith(prefix))) morphOwned.push(p);
|
|
859
|
+
else rest.push(p);
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
const { replicable, disposable, exclusive } = triageIgnoredPaths({ worktreePath, rootPath, paths: rest });
|
|
863
|
+
const shieldable = [...morphOwned, ...exclusive];
|
|
864
|
+
|
|
865
|
+
// Explicit consent = SKIP THE BACKUP, not "widen what dies". The same paths
|
|
866
|
+
// are removed either way; the flag only decides whether a copy is made first.
|
|
867
|
+
// The list is never thrown away — these are the bytes that exist nowhere
|
|
868
|
+
// else, and reporting them is the least this can do.
|
|
869
|
+
if (options.discardIgnored) {
|
|
870
|
+
return { shieldable: [], blocking: [], discarded: [...disposable, ...replicable, ...shieldable], replicable };
|
|
866
871
|
}
|
|
867
|
-
|
|
868
|
-
// thrown away. These are the files that exist nowhere else, and they are the
|
|
869
|
-
// ones about to die; reporting them is the least this can do. (Backing them
|
|
870
|
-
// up instead is not an option: this bucket is where arbitrarily large
|
|
871
|
-
// leftovers land, so a copy is unbounded. The user declared them
|
|
872
|
-
// disposable — they still get told.)
|
|
873
|
-
if (options.discardIgnored) return { shieldable, blocking: [], discarded: [...disposable, ...blockingRest] };
|
|
874
|
-
// Known build artifacts are discarded by default, no flag required — they
|
|
875
|
-
// are reproducible from source, unlike anything else in `blockingRest`.
|
|
876
|
-
return { shieldable, blocking: blockingRest, discarded: disposable };
|
|
872
|
+
return { shieldable, blocking: [], discarded: [...disposable, ...replicable], replicable };
|
|
877
873
|
}
|
|
878
874
|
|
|
879
875
|
/**
|
|
@@ -1177,7 +1173,7 @@ export async function teardownWorktree(worktreePath, { root, feature, branch, op
|
|
|
1177
1173
|
const unlinked = unlinkWorktreeInfraLinks(worktreePath, root);
|
|
1178
1174
|
|
|
1179
1175
|
const scan = findReparsePoints(worktreePath);
|
|
1180
|
-
const ignored = classifyIgnoredLeftovers(worktreePath, options);
|
|
1176
|
+
const ignored = classifyIgnoredLeftovers(worktreePath, root, options);
|
|
1181
1177
|
const blockers = [...scan.points, ...scan.unprovable];
|
|
1182
1178
|
|
|
1183
1179
|
// Back up ONLY once the removal is otherwise cleared: nothing is destroyed on
|
|
@@ -1257,14 +1253,22 @@ export async function teardownWorktree(worktreePath, { root, feature, branch, op
|
|
|
1257
1253
|
const removeCmd = (addressByTarget || !isValidFeatureName(feature))
|
|
1258
1254
|
? `morph-spec worktree remove --target "${worktreePath}"`
|
|
1259
1255
|
: `morph-spec worktree remove ${feature}`;
|
|
1256
|
+
const backupDir = join(root, '.git', 'morph-spec', 'finish-backup');
|
|
1257
|
+
const sample = (list) => `${list.slice(0, 5).join(', ')}${list.length > 5 ? `, +${list.length - 5}` : ''}`;
|
|
1260
1258
|
const declineReason = worktreeRemoved
|
|
1261
1259
|
? null
|
|
1262
1260
|
: ignored.blocking.length
|
|
1263
|
-
? `worktree NÃO removido:
|
|
1261
|
+
? `worktree NÃO removido: não foi possível enumerar os arquivos gitignored (${sample(ignored.blocking)}), então não dá para provar que a remoção não destrói nada. Rode \`git status --ignored\` dentro do worktree para ver o que está travando e tente de novo com \`${removeCmd}\``
|
|
1264
1262
|
: blockers.length
|
|
1265
1263
|
? `worktree NÃO removido: ainda contém link(s)/caminho(s) não verificáveis (${blockers.join(', ')}) que a remoção seguiria para fora, apagando o alvo na raiz. Remova-os e rode \`${removeCmd}\``
|
|
1266
1264
|
: ignored.shieldable.length && ignoredBackup === null
|
|
1267
|
-
|
|
1265
|
+
// The ONLY gitignored refusal left. The old hint was circular — it
|
|
1266
|
+
// said "salve o que importa e rode <the command that just failed>".
|
|
1267
|
+
// This one names WHAT is exclusive, what was replicable (so the
|
|
1268
|
+
// reader stops worrying about those), and WHERE the copy was going.
|
|
1269
|
+
? `worktree NÃO removido: o backup falhou. Estes arquivos existem só neste worktree e a remoção os apagaria: ${sample(ignored.shieldable)}. `
|
|
1270
|
+
+ (ignored.replicable.length ? `(${sample(ignored.replicable)} são idênticos aos da raiz — esses não são o problema.) ` : '')
|
|
1271
|
+
+ `O backup iria para \`${backupDir}\`; libere espaço/permissão ali e rode \`${removeCmd}\`, ou copie os arquivos você mesmo e rode \`${removeCmd} --discard-ignored\` para pular o backup`
|
|
1268
1272
|
: removalFailure?.kind === 'in-use'
|
|
1269
1273
|
? `worktree NÃO removido: o diretório está EM USO por outro processo (instância do Claude Code, VS Code, dev server ou terminal com cwd em "${worktreePath}"). A infra compartilhada ficou DESMONTADA de propósito — feche o que está usando o diretório e rode \`${removeCmd}\`. Se preferir continuar trabalhando nele, restaure a infra com \`morph-spec worktree link --all\``
|
|
1270
1274
|
: `worktree NÃO removido: a remoção falhou${removalFailure?.detail ? ` (${removalFailure.detail})` : ''}. A infra compartilhada ficou DESMONTADA de propósito — rode \`${removeCmd}\` para tentar de novo, ou \`morph-spec worktree link --all\` para voltar a trabalhar nele`;
|
|
@@ -1273,6 +1277,14 @@ export async function teardownWorktree(worktreePath, { root, feature, branch, op
|
|
|
1273
1277
|
worktreeRemoved,
|
|
1274
1278
|
ignoredBackup,
|
|
1275
1279
|
discarded: ignored.discarded,
|
|
1280
|
+
replicable: ignored.replicable,
|
|
1281
|
+
// The infra reparse points this teardown dropped BEFORE letting git near
|
|
1282
|
+
// the directory. Reported because it is the load-bearing safety step of the
|
|
1283
|
+
// whole operation and was previously invisible: the user had no way to see
|
|
1284
|
+
// that `.claude` was a junction into the primary root, nor that it was
|
|
1285
|
+
// unlinked rather than followed. On a removal it is the proof the root's
|
|
1286
|
+
// real `.claude/` was never at risk.
|
|
1287
|
+
unlinked,
|
|
1276
1288
|
blockers,
|
|
1277
1289
|
ignoredBlocking: ignored.blocking,
|
|
1278
1290
|
shieldable: ignored.shieldable,
|
|
@@ -1405,13 +1417,24 @@ export async function worktreeRemoveCommand(featureArg, options = {}) {
|
|
|
1405
1417
|
...(branch ? { branch } : {}),
|
|
1406
1418
|
...(feature ? { feature } : {}),
|
|
1407
1419
|
};
|
|
1420
|
+
// Reported on BOTH paths: on a removal it is the proof the primary root's
|
|
1421
|
+
// real `.claude/`/`.morph/framework/` were unlinked rather than followed; on
|
|
1422
|
+
// a refusal it is what `relinked` did or did not put back.
|
|
1423
|
+
if (teardown.unlinked.length) out.unlinked = teardown.unlinked;
|
|
1408
1424
|
if (teardown.worktreeRemoved) {
|
|
1409
1425
|
if (teardown.discarded.length) out.discardedIgnored = teardown.discarded;
|
|
1426
|
+
// Broken out of `discardedIgnored` on purpose: "deleted" and "deleted
|
|
1427
|
+
// because the identical bytes are still in the root" are not the same
|
|
1428
|
+
// report, and the second is the one that needs no follow-up from the user.
|
|
1429
|
+
if (teardown.replicable.length) out.replicatedInRoot = teardown.replicable;
|
|
1410
1430
|
if (teardown.ignoredBackup) out.ignoredBackup = teardown.ignoredBackup;
|
|
1431
|
+
if (teardown.shieldable.length) out.backedUp = teardown.shieldable;
|
|
1411
1432
|
} else {
|
|
1412
1433
|
out.refused = teardown.refused;
|
|
1413
1434
|
if (teardown.blockers.length) out.reparsePoints = teardown.blockers;
|
|
1414
1435
|
if (teardown.ignoredBlocking.length) out.ignoredFiles = teardown.ignoredBlocking;
|
|
1436
|
+
if (teardown.shieldable.length) out.exclusiveIgnored = teardown.shieldable;
|
|
1437
|
+
if (teardown.replicable.length) out.replicatedInRoot = teardown.replicable;
|
|
1415
1438
|
if (teardown.infraLeftUnlinked.length) out.infraLeftUnlinked = teardown.infraLeftUnlinked;
|
|
1416
1439
|
out.hint = teardown.declineReason;
|
|
1417
1440
|
}
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
cwdIsInsideWorktree,
|
|
14
14
|
} from '../project/worktree.js';
|
|
15
15
|
import { detectDefaultBranch, hasOriginRemote } from '../../lib/git/default-branch.js';
|
|
16
|
+
import { resolveGh } from '../../lib/git/gh.js';
|
|
16
17
|
import { releaseClaim } from '../../../framework/hooks/shared/claim-registry.js';
|
|
17
18
|
import {
|
|
18
19
|
composeProjectName,
|
|
@@ -326,7 +327,7 @@ export async function finishCommand(feature, options = {}) {
|
|
|
326
327
|
branch: expectedBranch,
|
|
327
328
|
base: target,
|
|
328
329
|
archived: archivePath.replace(/\\/g, '/'),
|
|
329
|
-
...(typeof openPr === 'string' ? { pr: openPr } : {}),
|
|
330
|
+
...(typeof openPr === 'string' ? { pr: { number: prNumberFromUrl(openPr), url: openPr } } : {}),
|
|
330
331
|
hint: `'${feature}' já foi arquivada e ${expectedBranch} já tem PR aberto — o finish anterior concluiu. Falta apenas mergear o PR.`,
|
|
331
332
|
});
|
|
332
333
|
process.exit(1);
|
|
@@ -487,7 +488,7 @@ async function integrateMerge({ emit, root, feature, expectedBranch, defaultBran
|
|
|
487
488
|
// VALID (it still names a live directory), so nothing to clean up here —
|
|
488
489
|
// only report where it lives and how to close it later.
|
|
489
490
|
emit({
|
|
490
|
-
finished: true, mode: 'merge', feature, merged: expectedBranch, kept: true, worktreeRemoved: false,
|
|
491
|
+
finished: true, integrated: true, mode: 'merge', feature, merged: expectedBranch, kept: true, worktreeRemoved: false,
|
|
491
492
|
worktree: { path: worktreePath, branch: expectedBranch },
|
|
492
493
|
hint: `worktree preservado em ${worktreePath} — feche depois com \`morph-spec worktree remove ${feature}\`.`,
|
|
493
494
|
...backupNote, ...ucNote,
|
|
@@ -526,10 +527,26 @@ async function integrateMerge({ emit, root, feature, expectedBranch, defaultBran
|
|
|
526
527
|
}
|
|
527
528
|
tryGit(['branch', '-d', expectedBranch], root);
|
|
528
529
|
// The root index (gitignored) was already updated by the archive (same cwd).
|
|
529
|
-
emit({ finished: true, mode: 'merge', feature, merged: expectedBranch, into: defaultBranch, worktreeRemoved: false, nextStep: MERGE_NEXT_STEP, ...ucNote });
|
|
530
|
+
emit({ finished: true, integrated: true, mode: 'merge', feature, merged: expectedBranch, into: defaultBranch, worktreeRemoved: false, nextStep: MERGE_NEXT_STEP, ...ucNote });
|
|
530
531
|
process.exit(0);
|
|
531
532
|
}
|
|
532
533
|
|
|
534
|
+
/**
|
|
535
|
+
* O número do PR a partir da url que o `gh pr create` imprime.
|
|
536
|
+
*
|
|
537
|
+
* Existe porque o número é o que um humano usa para falar do PR ("mergeia o
|
|
538
|
+
* #51"), e a url é a única coisa que o `gh` devolve. Um formato inesperado
|
|
539
|
+
* devolve `null` em vez de um palpite: o payload prefere admitir que não sabe
|
|
540
|
+
* o número a afirmar um errado.
|
|
541
|
+
*
|
|
542
|
+
* @param {string|null} url
|
|
543
|
+
* @returns {number|null}
|
|
544
|
+
*/
|
|
545
|
+
export function prNumberFromUrl(url) {
|
|
546
|
+
const m = typeof url === 'string' ? url.match(/\/pull\/(\d+)(?:[/?#]|$)/) : null;
|
|
547
|
+
return m ? Number(m[1]) : null;
|
|
548
|
+
}
|
|
549
|
+
|
|
533
550
|
// ── PR integration (push + gh, no local merge) ───────────────────────────────
|
|
534
551
|
|
|
535
552
|
async function integratePr({ emit, root, feature, expectedBranch, defaultBranch, isWorktreeFlow, worktreePath, options, prTitle, prBody, arch }) {
|
|
@@ -571,13 +588,23 @@ async function integratePr({ emit, root, feature, expectedBranch, defaultBranch,
|
|
|
571
588
|
dropFromRootIndex(root, feature);
|
|
572
589
|
}
|
|
573
590
|
|
|
591
|
+
const prNumber = prNumberFromUrl(prUrl);
|
|
592
|
+
const prLabel = prNumber ? `#${prNumber}` : 'o PR';
|
|
593
|
+
|
|
574
594
|
const out = {
|
|
575
595
|
finished: true,
|
|
596
|
+
// O comando terminou; a INTEGRAÇÃO não aconteceu. Eram a mesma coisa no
|
|
597
|
+
// payload antigo, e é dessa colisão que sai o "shipped" para uma feature
|
|
598
|
+
// cujo PR seguia aberto: quem lê `finished: true` sem mais nada conclui
|
|
599
|
+
// que acabou. A default só recebe archive/{f} quando o PR mergear.
|
|
600
|
+
integrated: false,
|
|
601
|
+
awaiting: 'merge',
|
|
576
602
|
mode: 'pr',
|
|
577
603
|
feature,
|
|
578
604
|
pushed: expectedBranch,
|
|
579
605
|
base: defaultBranch,
|
|
580
|
-
pr: prUrl,
|
|
606
|
+
pr: { number: prNumber, url: prUrl },
|
|
607
|
+
nextStep: `mergeie ${prLabel} — a feature só estará encerrada quando ${defaultBranch} receber o archive pelo merge`,
|
|
581
608
|
...promotedNote(arch),
|
|
582
609
|
...(shedBackup ? { shedBackup } : {}),
|
|
583
610
|
...(teardown
|
|
@@ -626,6 +653,7 @@ const MERGE_NEXT_STEP = 'integração LOCAL — nada foi publicado. Rode `git pu
|
|
|
626
653
|
function mergeOutput({ feature, branch, worktreePath, teardown, shedBackup }) {
|
|
627
654
|
return {
|
|
628
655
|
finished: true,
|
|
656
|
+
integrated: true,
|
|
629
657
|
mode: 'merge',
|
|
630
658
|
feature,
|
|
631
659
|
merged: branch,
|
|
@@ -641,6 +669,15 @@ function teardownExtras(teardown, worktreePath, branch, deleteBranch) {
|
|
|
641
669
|
const out = {};
|
|
642
670
|
if (teardown.ignoredBackup) out.worktreeIgnoredBackup = teardown.ignoredBackup;
|
|
643
671
|
if (teardown.worktreeRemoved && teardown.discarded.length) out.discardedIgnored = teardown.discarded;
|
|
672
|
+
// WHAT was copied out, and what needed no copy because the primary root holds
|
|
673
|
+
// the identical bytes. Reported on both paths: after a removal these two lists
|
|
674
|
+
// are the whole answer to "did I just lose something?", and the user should
|
|
675
|
+
// not have to open the backup directory to find out.
|
|
676
|
+
if (teardown.shieldable?.length) out.worktreeIgnoredBackedUp = teardown.shieldable;
|
|
677
|
+
if (teardown.replicable?.length) out.replicatedInRoot = teardown.replicable;
|
|
678
|
+
// The infra reparse points dropped BEFORE git touched the directory — the
|
|
679
|
+
// proof the primary root's real `.claude/` was unlinked, never followed.
|
|
680
|
+
if (teardown.unlinked?.length) out.unlinked = teardown.unlinked;
|
|
644
681
|
if (!teardown.worktreeRemoved) {
|
|
645
682
|
out.path = worktreePath;
|
|
646
683
|
out.branch = branch;
|
|
@@ -688,28 +725,6 @@ function gitStatusPorcelain(cwd) {
|
|
|
688
725
|
}
|
|
689
726
|
}
|
|
690
727
|
|
|
691
|
-
/**
|
|
692
|
-
* Resolve the `gh` invocation. Defaults to the `gh` binary on PATH. Overridable
|
|
693
|
-
* via MORPH_GH_BIN: a plain string names a different binary (a corporate wrapper,
|
|
694
|
-
* `hub`, …); a JSON array is a full argv prefix — e.g.
|
|
695
|
-
* `["/path/to/node","/path/to/gh-shim.js"]` — which is how the tests inject a
|
|
696
|
-
* cross-platform stub that execFileSync can actually spawn (Node ≥20 refuses to
|
|
697
|
-
* run `.cmd`/`.bat` shims without `shell:true`, and shell:true would reopen the
|
|
698
|
-
* arg-injection hole the whole file avoids).
|
|
699
|
-
*
|
|
700
|
-
* @returns {{ bin: string, prefix: string[] }}
|
|
701
|
-
*/
|
|
702
|
-
function resolveGh() {
|
|
703
|
-
const raw = process.env.MORPH_GH_BIN;
|
|
704
|
-
if (raw && raw.trim().startsWith('[')) {
|
|
705
|
-
try {
|
|
706
|
-
const arr = JSON.parse(raw);
|
|
707
|
-
if (Array.isArray(arr) && arr.length) return { bin: String(arr[0]), prefix: arr.slice(1).map(String) };
|
|
708
|
-
} catch { /* not JSON — treat as a plain binary name below */ }
|
|
709
|
-
}
|
|
710
|
-
return { bin: raw && raw.trim() ? raw.trim() : 'gh', prefix: [] };
|
|
711
|
-
}
|
|
712
|
-
|
|
713
728
|
function ghAuthOk() {
|
|
714
729
|
const { bin, prefix } = resolveGh();
|
|
715
730
|
try {
|