@polymorphism-tech/morph-spec 8.31.0 → 8.32.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 +5 -0
- package/README.md +2 -2
- package/bin/morph-spec.js +19 -1
- 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 +68 -63
- 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/worktree/fleet-scanner.js +51 -21
- package/src/lib/worktree/ignored-triage.js +197 -0
- package/src/lib/worktree/worktree-audit.js +200 -35
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* branch.js — `morph-spec branch prune`.
|
|
3
|
+
*
|
|
4
|
+
* The gap this closes: after a few features close by squash-merge, the local
|
|
5
|
+
* repo accumulates branches git considers unmerged. `git branch --merged` lists
|
|
6
|
+
* none of them, `git branch -d` refuses each one, and the only command left is
|
|
7
|
+
* `git branch -D` — which is EXACTLY the command for throwing away real work.
|
|
8
|
+
* Once a user has typed `-D` on five safe branches, the sixth (an actual
|
|
9
|
+
* unintegrated feature) gets the same keystroke.
|
|
10
|
+
*
|
|
11
|
+
* So this command never asks the user to make that distinction: it makes it
|
|
12
|
+
* itself, prints the evidence for each branch, and deletes only what it PROVED
|
|
13
|
+
* is already integrated (see branch-classifier.js for the five classes and
|
|
14
|
+
* their order). Everything else is listed with the reason it survives.
|
|
15
|
+
*
|
|
16
|
+
* The remote is a separate decision: `--remote` is required to touch
|
|
17
|
+
* `origin/<branch>`, because a local branch is a local artifact while the remote
|
|
18
|
+
* one may be what someone else, or an open review, is looking at.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { execFileSync } from 'child_process';
|
|
22
|
+
import chalk from 'chalk';
|
|
23
|
+
import { isGitRepo, resolvePrimaryRoot } from './worktree.js';
|
|
24
|
+
import { detectDefaultBranch } from '../../lib/git/default-branch.js';
|
|
25
|
+
import { buildPrIndex } from '../../lib/git/pr-index.js';
|
|
26
|
+
import { classifyBranches } from '../../lib/git/branch-classifier.js';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Delete one local branch.
|
|
30
|
+
*
|
|
31
|
+
* `-D`, not `-d`, and that is the whole point of the classifier: `-d` refuses
|
|
32
|
+
* every squash-merged branch, so a command that used it would be unable to do
|
|
33
|
+
* its one job. The safety does not come from git's own check (which cannot see
|
|
34
|
+
* a squash-merge) — it comes from only ever reaching this function with a
|
|
35
|
+
* branch whose integration was positively proven.
|
|
36
|
+
*/
|
|
37
|
+
function deleteLocal(name, cwd) {
|
|
38
|
+
try {
|
|
39
|
+
execFileSync('git', ['branch', '-D', name], { cwd, stdio: 'pipe' });
|
|
40
|
+
return { deleted: true };
|
|
41
|
+
} catch (err) {
|
|
42
|
+
return { deleted: false, error: (err.stderr?.toString() || err.message || 'unknown').trim().slice(0, 200) };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function deleteRemote(name, cwd) {
|
|
47
|
+
try {
|
|
48
|
+
execFileSync('git', ['push', 'origin', '--delete', name], { cwd, stdio: 'pipe', timeout: 20000 });
|
|
49
|
+
return { deleted: true };
|
|
50
|
+
} catch (err) {
|
|
51
|
+
return { deleted: false, error: (err.stderr?.toString() || err.message || 'unknown').trim().slice(0, 200) };
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Does `origin/<name>` exist locally as a remote-tracking ref? */
|
|
56
|
+
function remoteRefExists(name, cwd) {
|
|
57
|
+
try {
|
|
58
|
+
execFileSync('git', ['show-ref', '--verify', '--quiet', `refs/remotes/origin/${name}`], { cwd, stdio: 'pipe' });
|
|
59
|
+
return true;
|
|
60
|
+
} catch {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Command handler for `morph-spec branch prune [--remote] [--dry-run] [--json]`.
|
|
67
|
+
*
|
|
68
|
+
* Fail-open outside a git repo (exit 0, empty result) — the same discipline the
|
|
69
|
+
* rest of the worktree suite follows: a housekeeping command must never be the
|
|
70
|
+
* reason a script dies.
|
|
71
|
+
*
|
|
72
|
+
* @param {{ remote?: boolean, dryRun?: boolean, json?: boolean, cwd?: string }} [options]
|
|
73
|
+
*/
|
|
74
|
+
export function branchPruneCommand(options = {}) {
|
|
75
|
+
const cwd = options.cwd || process.cwd();
|
|
76
|
+
const emit = (payload, code = 0) => {
|
|
77
|
+
if (options.json) console.log(JSON.stringify(payload, null, 2));
|
|
78
|
+
else printReport(payload, options);
|
|
79
|
+
process.exitCode = code;
|
|
80
|
+
return payload;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
if (!isGitRepo(cwd)) {
|
|
84
|
+
return emit({ pruned: [], kept: [], error: 'not-a-git-repo' });
|
|
85
|
+
}
|
|
86
|
+
const root = resolvePrimaryRoot(cwd);
|
|
87
|
+
const defaultBranch = detectDefaultBranch(root);
|
|
88
|
+
// null = UNKNOWN, never "no PR" — the classifier treats a branch with no
|
|
89
|
+
// positive evidence as unmerged, so a missing `gh` costs conservatism, not
|
|
90
|
+
// safety. Reported so the user can tell "no PR" from "could not ask".
|
|
91
|
+
const prIndex = buildPrIndex(root);
|
|
92
|
+
|
|
93
|
+
const classified = classifyBranches({ cwd: root, defaultBranch, prIndex });
|
|
94
|
+
const pruned = [];
|
|
95
|
+
const kept = [];
|
|
96
|
+
|
|
97
|
+
for (const b of classified) {
|
|
98
|
+
if (!b.deletable) { kept.push(b); continue; }
|
|
99
|
+
if (options.dryRun) {
|
|
100
|
+
pruned.push({ ...b, deleted: false, dryRun: true, remoteDeleted: false });
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
const local = deleteLocal(b.name, root);
|
|
104
|
+
let remoteDeleted = false;
|
|
105
|
+
let remoteError;
|
|
106
|
+
if (local.deleted && options.remote && remoteRefExists(b.name, root)) {
|
|
107
|
+
const r = deleteRemote(b.name, root);
|
|
108
|
+
remoteDeleted = r.deleted;
|
|
109
|
+
if (!r.deleted) remoteError = r.error;
|
|
110
|
+
}
|
|
111
|
+
if (local.deleted) pruned.push({ ...b, deleted: true, remoteDeleted, ...(remoteError ? { remoteError } : {}) });
|
|
112
|
+
else kept.push({ ...b, kind: 'delete-failed', reason: `git branch -D falhou: ${local.error}`, deletable: false });
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return emit({
|
|
116
|
+
defaultBranch,
|
|
117
|
+
prIndexAvailable: prIndex !== null,
|
|
118
|
+
dryRun: options.dryRun === true,
|
|
119
|
+
remote: options.remote === true,
|
|
120
|
+
pruned,
|
|
121
|
+
kept,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const KIND_LABEL = {
|
|
126
|
+
'merged-ancestral': chalk.green('merged'),
|
|
127
|
+
'merged-squash': chalk.green('squash-merged'),
|
|
128
|
+
open: chalk.yellow('PR aberto'),
|
|
129
|
+
'in-worktree': chalk.cyan('em uso'),
|
|
130
|
+
unmerged: chalk.red('não integrada'),
|
|
131
|
+
'delete-failed': chalk.red('falhou'),
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
function printReport(payload, options) {
|
|
135
|
+
if (payload.error === 'not-a-git-repo') {
|
|
136
|
+
console.log(chalk.gray('não é um repositório git — nada a fazer'));
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
const verb = payload.dryRun ? 'seriam apagadas' : 'apagadas';
|
|
140
|
+
console.log(chalk.bold(`\nbranch prune — default: ${payload.defaultBranch || chalk.red('não resolvida')}`));
|
|
141
|
+
if (!payload.prIndexAvailable) {
|
|
142
|
+
// Load-bearing caveat, not noise: without `gh` a squash-merge is only
|
|
143
|
+
// detectable by the tree comparison, so branches that WERE merged may
|
|
144
|
+
// legitimately show up as unmerged here.
|
|
145
|
+
console.log(chalk.gray(' (sem `gh`/GitHub: squash-merge só é detectado por árvore idêntica)'));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (payload.pruned.length) {
|
|
149
|
+
console.log(chalk.bold(`\n ${payload.pruned.length} ${verb}:`));
|
|
150
|
+
for (const b of payload.pruned) {
|
|
151
|
+
const remote = b.remoteDeleted ? chalk.gray(' + origin') : '';
|
|
152
|
+
console.log(` ${KIND_LABEL[b.kind] || b.kind} ${chalk.bold(b.name)}${remote}`);
|
|
153
|
+
console.log(chalk.gray(` ${b.reason}`));
|
|
154
|
+
if (b.remoteError) console.log(chalk.yellow(` origin não apagada: ${b.remoteError}`));
|
|
155
|
+
}
|
|
156
|
+
} else {
|
|
157
|
+
console.log(chalk.gray('\n nada a apagar'));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (payload.kept.length) {
|
|
161
|
+
console.log(chalk.bold(`\n ${payload.kept.length} mantida(s):`));
|
|
162
|
+
for (const b of payload.kept) {
|
|
163
|
+
console.log(` ${KIND_LABEL[b.kind] || b.kind} ${chalk.bold(b.name)}${b.ahead ? chalk.gray(` (+${b.ahead})`) : ''}`);
|
|
164
|
+
console.log(chalk.gray(` ${b.reason}`));
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
if (payload.dryRun) console.log(chalk.gray('\n --dry-run: nada foi apagado'));
|
|
168
|
+
if (!options.remote && payload.pruned.length) console.log(chalk.gray(' branches remotas intactas (use --remote para apagá-las também)'));
|
|
169
|
+
console.log();
|
|
170
|
+
}
|
|
@@ -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
|
}
|