@polymorphism-tech/morph-spec 8.27.0 → 8.28.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 +1 -0
- package/README.md +2 -2
- package/bin/morph-spec.js +9 -4
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +3 -2
- package/docs/QUICKSTART.md +1 -1
- package/framework/MORPH.md +8 -8
- package/framework/docs/CLI.md +6 -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 +1 -1
- 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 +1 -1
- 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 +1 -1
- 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/doctor.js +9 -4
- package/src/commands/project/worktree.js +45 -3
- package/src/commands/state/advance.js +12 -0
- package/src/commands/state/approve.js +20 -0
- package/src/commands/state/create.js +4 -1
- package/src/commands/state/delete.js +63 -4
- package/src/commands/state/finish.js +20 -2
- package/src/commands/state/gate-check.js +13 -0
- package/src/commands/state/gate-decision.js +9 -0
- package/src/commands/state/handoff.js +48 -6
- package/src/commands/state/list.js +124 -10
- package/src/commands/state/score.js +9 -0
- package/src/lib/generators/handoff-generator.js +11 -4
- package/src/lib/worktree/feature-truth.js +248 -0
- package/src/lib/worktree/fleet-scanner.js +1 -65
- package/src/lib/worktree/worktree-audit.js +103 -17
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* feature-truth.js — where a feature's authoritative `feature.json` actually
|
|
3
|
+
* lives, and how to read it from a place that isn't the feature's own tree.
|
|
4
|
+
*
|
|
5
|
+
* `create --worktree` writes a COMPLETE feature.json into the primary root
|
|
6
|
+
* (status, approvalGates, tasks, gateDecisions) and only then copies it into
|
|
7
|
+
* the worktree. From that instant the branch-committed copy (`morph/{f}`) is
|
|
8
|
+
* the truth and the root's copy never changes again — the root sees the
|
|
9
|
+
* feature exactly as it was at birth, forever, unless someone reads through.
|
|
10
|
+
*
|
|
11
|
+
* `branchToReadFrom`/`branchView` are moved verbatim from fleet-scanner.js —
|
|
12
|
+
* they already solved this (comments B3/B3b there), fleet just never shared
|
|
13
|
+
* them. `resolveFeatureTree` is the missing piece for CONSUMERS OTHER than
|
|
14
|
+
* fleet: "which tree/branch actually owns this feature's state", used by
|
|
15
|
+
* `list`, `handoff`, and the mutator guard (assertOwnerTree).
|
|
16
|
+
*
|
|
17
|
+
* Fail-open throughout: no repo, no branch, no blob, unparseable JSON never
|
|
18
|
+
* throws — callers get null/false and fall back to reading the disk.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { execFileSync } from 'child_process';
|
|
22
|
+
import { existsSync, readFileSync } from 'fs';
|
|
23
|
+
import { join } from 'path';
|
|
24
|
+
import { isGitRepo, resolvePrimaryRoot, findWorktreeForFeature, buildBranchName } from '../../commands/project/worktree.js';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Which branch holds this feature's truth, when the working tree at `path` does
|
|
28
|
+
* NOT — or `null` when the working tree IS the feature's own checkout.
|
|
29
|
+
*
|
|
30
|
+
* The ONE place this condition lives. `tasks` (B3) and `phase`/`pendingGates`
|
|
31
|
+
* (B3b) are the same defect read through different files, so they must agree on
|
|
32
|
+
* when to look at the branch; two drifting copies of this test would resurrect
|
|
33
|
+
* the bug on one field while the other stayed fixed.
|
|
34
|
+
*
|
|
35
|
+
* A worktree entry is the feature's checkout by construction. A root entry is
|
|
36
|
+
* only the feature's checkout when the root happens to be ON `morph/{f}`.
|
|
37
|
+
*
|
|
38
|
+
* @returns {string|null} branch name to read from, or null for "read the disk"
|
|
39
|
+
*/
|
|
40
|
+
export function branchToReadFrom(root, feature, location, branch) {
|
|
41
|
+
if (location === 'worktree' || !root || !feature) return null;
|
|
42
|
+
const featureBranch = branch || `morph/${feature}`;
|
|
43
|
+
try {
|
|
44
|
+
const current = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: root, stdio: 'pipe' })
|
|
45
|
+
.toString().trim();
|
|
46
|
+
return current === featureBranch ? null : featureBranch;
|
|
47
|
+
} catch {
|
|
48
|
+
return null; // no HEAD to compare against — trust the disk
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The feature's committed state on its own branch: the feature.json blob plus
|
|
54
|
+
* the set of files under its feature dir.
|
|
55
|
+
*
|
|
56
|
+
* `git ls-tree -z` is deliberate: without it git QUOTES paths containing
|
|
57
|
+
* non-ASCII bytes (`"a\303\247/…"`), which would silently fail the prefix strip
|
|
58
|
+
* and leave the artifact set empty — a predicate that is always false looks
|
|
59
|
+
* exactly like "this feature has no artifacts".
|
|
60
|
+
*
|
|
61
|
+
* Fail-open in layers: no blob → null (caller reads the disk); no tree → the
|
|
62
|
+
* gates alone still beat a stale working tree.
|
|
63
|
+
*
|
|
64
|
+
* @returns {{ fj: Object, files: Set<string> }|null}
|
|
65
|
+
*/
|
|
66
|
+
export function branchView(root, feature, featureBranch) {
|
|
67
|
+
const prefix = `.morph/features/${feature}/`;
|
|
68
|
+
let fj;
|
|
69
|
+
try {
|
|
70
|
+
fj = JSON.parse(execFileSync('git', ['show', `${featureBranch}:${prefix}feature.json`], {
|
|
71
|
+
cwd: root, stdio: 'pipe',
|
|
72
|
+
}).toString());
|
|
73
|
+
} catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const files = new Set();
|
|
78
|
+
try {
|
|
79
|
+
const out = execFileSync('git', ['ls-tree', '-r', '--name-only', '-z', featureBranch, '--', prefix], {
|
|
80
|
+
cwd: root, stdio: 'pipe',
|
|
81
|
+
}).toString();
|
|
82
|
+
for (const raw of out.split('\0')) {
|
|
83
|
+
const p = raw.trim();
|
|
84
|
+
if (p.startsWith(prefix)) files.add(p.slice(prefix.length));
|
|
85
|
+
}
|
|
86
|
+
} catch { /* no tree — gates alone still beat the stale working tree */ }
|
|
87
|
+
|
|
88
|
+
return { fj, files };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** All local `morph/*` branches, as bare feature names — one spawn, a pre-filter. */
|
|
92
|
+
export function morphBranches(root) {
|
|
93
|
+
try {
|
|
94
|
+
const out = execFileSync('git', ['for-each-ref', '--format=%(refname:short)', 'refs/heads/morph/'], {
|
|
95
|
+
cwd: root, stdio: 'pipe',
|
|
96
|
+
}).toString();
|
|
97
|
+
const names = new Set();
|
|
98
|
+
for (const line of out.split('\n')) {
|
|
99
|
+
const b = line.trim();
|
|
100
|
+
if (b) names.add(b.slice('morph/'.length));
|
|
101
|
+
}
|
|
102
|
+
return names;
|
|
103
|
+
} catch {
|
|
104
|
+
return new Set();
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The tree's current branch, or null (detached HEAD / no repo / no commits yet). */
|
|
109
|
+
export function currentBranch(cwd) {
|
|
110
|
+
try {
|
|
111
|
+
const out = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd, stdio: 'pipe' }).toString().trim();
|
|
112
|
+
return out === 'HEAD' ? null : out || null;
|
|
113
|
+
} catch {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Read a feature's local feature.json from `dir`, or null if missing/corrupt. */
|
|
119
|
+
function readLocalFeatureJson(dir, feature) {
|
|
120
|
+
const p = join(dir, '.morph', 'features', feature, 'feature.json');
|
|
121
|
+
if (!existsSync(p)) return null;
|
|
122
|
+
try {
|
|
123
|
+
return JSON.parse(readFileSync(p, 'utf8'));
|
|
124
|
+
} catch {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Resolve which TREE (or, failing that, which BRANCH) actually owns a
|
|
131
|
+
* feature's state — "follow the pointer" for any consumer, not just fleet.
|
|
132
|
+
*
|
|
133
|
+
* Six-step search, each step justified by what it's immune to:
|
|
134
|
+
*
|
|
135
|
+
* 1. cwd holds the feature AND cwd is that feature's registered worktree
|
|
136
|
+
* (git's own record, via findWorktreeForFeature) → running inside the
|
|
137
|
+
* owner never counts as a jump.
|
|
138
|
+
* 2. `findWorktreeForFeature` finds a live worktree elsewhere → the tree the
|
|
139
|
+
* GIT REGISTRY says is the owner, not the feature.json pointer — immune
|
|
140
|
+
* to a dangling/stale `feature.worktree` field.
|
|
141
|
+
* 3. the local feature.json's own `worktree.path` field, if it still exists
|
|
142
|
+
* on disk → covers a worktree living outside the conventional path or a
|
|
143
|
+
* registry `git worktree list` can't see for some reason.
|
|
144
|
+
* 4. cwd holds the feature with no worktree registered anywhere → the common
|
|
145
|
+
* case (no `--worktree`, or the worktree was already torn down).
|
|
146
|
+
* 5. no local copy, but `morph/{feature}` has the file committed → the truth
|
|
147
|
+
* exists, just not materialized into any tree right now.
|
|
148
|
+
* 6. nothing anywhere.
|
|
149
|
+
*
|
|
150
|
+
* @param {string} feature
|
|
151
|
+
* @param {string} [cwd]
|
|
152
|
+
* @returns {{
|
|
153
|
+
* source: 'cwd'|'registry'|'pointer'|'branch-only'|'none',
|
|
154
|
+
* tree: string|null,
|
|
155
|
+
* branch: string|null,
|
|
156
|
+
* worktree: {path:string, branch:string}|null,
|
|
157
|
+
* pointerDangling: boolean,
|
|
158
|
+
* }}
|
|
159
|
+
*/
|
|
160
|
+
export function resolveFeatureTree(feature, cwd = process.cwd()) {
|
|
161
|
+
const localFj = readLocalFeatureJson(cwd, feature);
|
|
162
|
+
const pointerPath = localFj?.worktree?.path || null;
|
|
163
|
+
const pointerDangling = Boolean(pointerPath) && !existsSync(pointerPath);
|
|
164
|
+
|
|
165
|
+
const inGit = isGitRepo(cwd);
|
|
166
|
+
const root = inGit ? resolvePrimaryRoot(cwd) : null;
|
|
167
|
+
|
|
168
|
+
// Step 1 + 2: git's own worktree registry is the source of truth for "is
|
|
169
|
+
// there a live worktree for this feature, and where".
|
|
170
|
+
if (inGit && root) {
|
|
171
|
+
const registered = findWorktreeForFeature(feature, root);
|
|
172
|
+
if (registered && existsSync(registered.path)) {
|
|
173
|
+
const branch = registered.branch || buildBranchName(feature);
|
|
174
|
+
if (registered.path.replace(/\\/g, '/') === cwd.replace(/\\/g, '/')) {
|
|
175
|
+
return { source: 'cwd', tree: cwd, branch, worktree: { path: registered.path, branch }, pointerDangling };
|
|
176
|
+
}
|
|
177
|
+
return { source: 'registry', tree: registered.path, branch, worktree: { path: registered.path, branch }, pointerDangling };
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Step 3: the local pointer names a worktree the registry didn't surface
|
|
182
|
+
// (unconventional location, or registry unreadable) but that still exists.
|
|
183
|
+
if (pointerPath && !pointerDangling) {
|
|
184
|
+
const branch = localFj?.worktree?.branch || buildBranchName(feature);
|
|
185
|
+
return { source: 'pointer', tree: pointerPath, branch, worktree: { path: pointerPath, branch }, pointerDangling };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Step 4: this tree holds the feature and nothing else claims to own it.
|
|
189
|
+
if (localFj) {
|
|
190
|
+
return { source: 'cwd', tree: cwd, branch: null, worktree: null, pointerDangling };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Step 5: no local copy anywhere in this tree, but the branch has one.
|
|
194
|
+
if (inGit && root) {
|
|
195
|
+
const branch = buildBranchName(feature);
|
|
196
|
+
const view = branchView(root, feature, branch);
|
|
197
|
+
if (view) {
|
|
198
|
+
return { source: 'branch-only', tree: null, branch, worktree: null, pointerDangling: false };
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Step 6.
|
|
203
|
+
return { source: 'none', tree: null, branch: null, worktree: null, pointerDangling: false };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Should a mutator (approve/advance/score/gate-decision/gate-check) refuse to
|
|
208
|
+
* write this feature's `feature.json` from `cwd`?
|
|
209
|
+
*
|
|
210
|
+
* Blocked ONLY when a live worktree owns the feature and it is NOT cwd —
|
|
211
|
+
* `resolveFeatureTree` sources `'registry'`/`'pointer'`. `'cwd'` (cwd IS the
|
|
212
|
+
* owner, or no worktree exists at all) always passes. This is what stops
|
|
213
|
+
* `morph-spec approve <f> plan` run from the root, with the worktree still
|
|
214
|
+
* alive, from silently stamping the gate onto the root's birth-instant
|
|
215
|
+
* skeleton — branch-drift CREATED by a command, not merely left behind by one.
|
|
216
|
+
*
|
|
217
|
+
* @param {string} feature
|
|
218
|
+
* @param {string} [cwd]
|
|
219
|
+
* @returns {{ blocked: boolean, tree: string|null, branch: string|null }}
|
|
220
|
+
*/
|
|
221
|
+
export function assertOwnerTree(feature, cwd = process.cwd()) {
|
|
222
|
+
const owner = resolveFeatureTree(feature, cwd);
|
|
223
|
+
const blocked = owner.source === 'registry' || owner.source === 'pointer';
|
|
224
|
+
return { blocked, tree: owner.tree, branch: owner.branch };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* The refusal text every mutator prints on an owner-tree block — one place so
|
|
229
|
+
* `approve`/`advance`/`score`/`gate-decision`/`gate-check` can never drift
|
|
230
|
+
* into five different wordings for the same defect. `commandLine` is the
|
|
231
|
+
* exact command the caller should re-run inside the owner tree.
|
|
232
|
+
*
|
|
233
|
+
* @param {string} feature
|
|
234
|
+
* @param {string} tree
|
|
235
|
+
* @param {string|null} branch
|
|
236
|
+
* @param {string} commandLine
|
|
237
|
+
* @returns {Array<{tone: 'red'|'gray'|'cyan'|'yellow', text: string}>}
|
|
238
|
+
*/
|
|
239
|
+
export function ownerTreeRefusalLines(feature, tree, branch, commandLine) {
|
|
240
|
+
return [
|
|
241
|
+
{ tone: 'red', text: `\n❌ A feature "${feature}" não pode ser mutada A PARTIR DESTA ÁRVORE.` },
|
|
242
|
+
{ tone: 'gray', text: ` O worktree dono está vivo em: ${tree}${branch ? ` (branch ${branch})` : ''}` },
|
|
243
|
+
{ tone: 'gray', text: ' Rode o comando LÁ — a mutação é gravada no feature.json daquela árvore:' },
|
|
244
|
+
{ tone: 'cyan', text: ` cd "${tree}" && ${commandLine}` },
|
|
245
|
+
{ tone: 'yellow', text: '\n NÃO use --force aqui: ele carimbaria a mutação na cópia da raiz, duplicando' },
|
|
246
|
+
{ tone: 'yellow', text: ' uma escrita que a outra árvore já tem e desalinhando os dois feature.json.\n' },
|
|
247
|
+
];
|
|
248
|
+
}
|
|
@@ -19,6 +19,7 @@ import { join, resolve } from 'path';
|
|
|
19
19
|
import { isGitRepo, resolvePrimaryRoot, parseWorktreeList } from '../../commands/project/worktree.js';
|
|
20
20
|
import { hasOriginRemote } from '../git/default-branch.js';
|
|
21
21
|
import { registryPath, resolveFleetConfig } from './env-allocator.js';
|
|
22
|
+
import { branchToReadFrom, branchView } from './feature-truth.js';
|
|
22
23
|
import {
|
|
23
24
|
getFeature,
|
|
24
25
|
derivePhaseForFeature,
|
|
@@ -34,71 +35,6 @@ function countFrom(raw) {
|
|
|
34
35
|
return { done: data.tasks.filter((t) => t.status === 'done').length, total: data.tasks.length };
|
|
35
36
|
}
|
|
36
37
|
|
|
37
|
-
/**
|
|
38
|
-
* Which branch holds this feature's truth, when the working tree at `path` does
|
|
39
|
-
* NOT — or `null` when the working tree IS the feature's own checkout.
|
|
40
|
-
*
|
|
41
|
-
* The ONE place this condition lives. `tasks` (B3) and `phase`/`pendingGates`
|
|
42
|
-
* (B3b) are the same defect read through different files, so they must agree on
|
|
43
|
-
* when to look at the branch; two drifting copies of this test would resurrect
|
|
44
|
-
* the bug on one field while the other stayed fixed.
|
|
45
|
-
*
|
|
46
|
-
* A worktree entry is the feature's checkout by construction. A root entry is
|
|
47
|
-
* only the feature's checkout when the root happens to be ON `morph/{f}`.
|
|
48
|
-
*
|
|
49
|
-
* @returns {string|null} branch name to read from, or null for "read the disk"
|
|
50
|
-
*/
|
|
51
|
-
function branchToReadFrom(root, feature, location, branch) {
|
|
52
|
-
if (location === 'worktree' || !root || !feature) return null;
|
|
53
|
-
const featureBranch = branch || `morph/${feature}`;
|
|
54
|
-
try {
|
|
55
|
-
const current = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: root, stdio: 'pipe' })
|
|
56
|
-
.toString().trim();
|
|
57
|
-
return current === featureBranch ? null : featureBranch;
|
|
58
|
-
} catch {
|
|
59
|
-
return null; // no HEAD to compare against — trust the disk
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* The feature's committed state on its own branch: the feature.json blob plus
|
|
65
|
-
* the set of files under its feature dir.
|
|
66
|
-
*
|
|
67
|
-
* `git ls-tree -z` is deliberate: without it git QUOTES paths containing
|
|
68
|
-
* non-ASCII bytes (`"a\303\247/…"`), which would silently fail the prefix strip
|
|
69
|
-
* and leave the artifact set empty — a predicate that is always false looks
|
|
70
|
-
* exactly like "this feature has no artifacts".
|
|
71
|
-
*
|
|
72
|
-
* Fail-open in layers: no blob → null (caller reads the disk); no tree → the
|
|
73
|
-
* gates alone still beat a stale working tree.
|
|
74
|
-
*
|
|
75
|
-
* @returns {{ fj: Object, files: Set<string> }|null}
|
|
76
|
-
*/
|
|
77
|
-
function branchView(root, feature, featureBranch) {
|
|
78
|
-
const prefix = `.morph/features/${feature}/`;
|
|
79
|
-
let fj;
|
|
80
|
-
try {
|
|
81
|
-
fj = JSON.parse(execFileSync('git', ['show', `${featureBranch}:${prefix}feature.json`], {
|
|
82
|
-
cwd: root, stdio: 'pipe',
|
|
83
|
-
}).toString());
|
|
84
|
-
} catch {
|
|
85
|
-
return null;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
const files = new Set();
|
|
89
|
-
try {
|
|
90
|
-
const out = execFileSync('git', ['ls-tree', '-r', '--name-only', '-z', featureBranch, '--', prefix], {
|
|
91
|
-
cwd: root, stdio: 'pipe',
|
|
92
|
-
}).toString();
|
|
93
|
-
for (const raw of out.split('\0')) {
|
|
94
|
-
const p = raw.trim();
|
|
95
|
-
if (p.startsWith(prefix)) files.add(p.slice(prefix.length));
|
|
96
|
-
}
|
|
97
|
-
} catch { /* no tree — gates alone still beat the stale working tree */ }
|
|
98
|
-
|
|
99
|
-
return { fj, files };
|
|
100
|
-
}
|
|
101
|
-
|
|
102
38
|
/**
|
|
103
39
|
* Task progress for one feature.
|
|
104
40
|
*
|
|
@@ -22,13 +22,14 @@
|
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
24
|
import { execFileSync } from 'child_process';
|
|
25
|
-
import { existsSync, readdirSync, statSync } from 'fs';
|
|
25
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'fs';
|
|
26
26
|
import { join, resolve } from 'path';
|
|
27
27
|
import {
|
|
28
28
|
isGitRepo,
|
|
29
29
|
resolvePrimaryRoot,
|
|
30
30
|
parseWorktreeList,
|
|
31
31
|
WORKTREE_DIR_NAMES,
|
|
32
|
+
clearDanglingWorktreeRegistration,
|
|
32
33
|
} from '../../commands/project/worktree.js';
|
|
33
34
|
|
|
34
35
|
const norm = (p) => resolve(p).replace(/\\/g, '/');
|
|
@@ -65,6 +66,36 @@ function featureCommittedOnBranch(root, feature) {
|
|
|
65
66
|
}
|
|
66
67
|
}
|
|
67
68
|
|
|
69
|
+
/**
|
|
70
|
+
* Every feature's `worktree` registration in the primary root — the OTHER
|
|
71
|
+
* direction from `knownFeatures`/`dirsOnDisk`: not "does git/disk know about
|
|
72
|
+
* this worktree", but "does the ROOT's feature.json still point somewhere
|
|
73
|
+
* real". `create --worktree` writes this pointer once at birth
|
|
74
|
+
* (`{path, branch, createdAt}`) and nothing updates it afterwards except
|
|
75
|
+
* `finish`'s own teardown — so a directory removed any other way (by hand,
|
|
76
|
+
* `git worktree prune` run directly, a failed teardown that still cleared the
|
|
77
|
+
* field, an unrelated script) leaves it dangling with nothing to notice.
|
|
78
|
+
*
|
|
79
|
+
* @param {string} root
|
|
80
|
+
* @returns {Array<{ feature: string, path: string, branch: string|null }>}
|
|
81
|
+
*/
|
|
82
|
+
function featureRegistrations(root) {
|
|
83
|
+
const out = [];
|
|
84
|
+
try {
|
|
85
|
+
const dir = join(root, '.morph', 'features');
|
|
86
|
+
if (!existsSync(dir)) return out;
|
|
87
|
+
for (const feature of readdirSync(dir)) {
|
|
88
|
+
const fjPath = join(dir, feature, 'feature.json');
|
|
89
|
+
if (!existsSync(fjPath)) continue;
|
|
90
|
+
try {
|
|
91
|
+
const fj = JSON.parse(readFileSync(fjPath, 'utf8'));
|
|
92
|
+
if (fj?.worktree?.path) out.push({ feature, path: fj.worktree.path, branch: fj.worktree.branch || null });
|
|
93
|
+
} catch { /* corrupt feature.json — nothing to register */ }
|
|
94
|
+
}
|
|
95
|
+
} catch { /* fail-open */ }
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
|
|
68
99
|
/** Directories physically present under either worktree root. */
|
|
69
100
|
function dirsOnDisk(root) {
|
|
70
101
|
const found = [];
|
|
@@ -87,14 +118,25 @@ function dirsOnDisk(root) {
|
|
|
87
118
|
* Reconcile git's registry, the disk and morph's features.
|
|
88
119
|
*
|
|
89
120
|
* Issue types:
|
|
90
|
-
* • registered-missing
|
|
91
|
-
* • non-morph-branch
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
* •
|
|
121
|
+
* • registered-missing — git has a worktree whose directory is gone
|
|
122
|
+
* • non-morph-branch — a registered worktree under worktrees/ on a
|
|
123
|
+
* branch that is not `morph/*`, so `fleet`
|
|
124
|
+
* cannot see it
|
|
125
|
+
* • unregistered-dir — a directory under worktrees/ git never
|
|
126
|
+
* registered
|
|
127
|
+
* • no-feature — a `morph/{f}` worktree for a feature morph
|
|
128
|
+
* does not know
|
|
129
|
+
* • dangling-registration — a feature's ROOT feature.json still points
|
|
130
|
+
* at a `worktree.path` that no longer exists
|
|
131
|
+
* (the symmetric OTHER direction from
|
|
132
|
+
* `no-feature`: the worktree is gone, but the
|
|
133
|
+
* root's pointer to it survived)
|
|
134
|
+
* • unregistered-registration — a feature's `worktree.path` names a
|
|
135
|
+
* directory that exists but git's own
|
|
136
|
+
* registry does not know as a worktree
|
|
95
137
|
*
|
|
96
138
|
* @param {string} [cwd]
|
|
97
|
-
* @returns {Array<{ type: string, detail: string, path?: string, fix: string }>}
|
|
139
|
+
* @returns {Array<{ type: string, detail: string, path?: string, feature?: string, fix: string }>}
|
|
98
140
|
*/
|
|
99
141
|
export function auditWorktrees(cwd = process.cwd()) {
|
|
100
142
|
if (!isGitRepo(cwd)) return [];
|
|
@@ -161,25 +203,69 @@ export function auditWorktrees(cwd = process.cwd()) {
|
|
|
161
203
|
});
|
|
162
204
|
}
|
|
163
205
|
|
|
206
|
+
// The other direction: does the ROOT's own bookkeeping still point at a
|
|
207
|
+
// live worktree? `git worktree list`/disk only see what's PHYSICALLY
|
|
208
|
+
// there; a registration surviving past its worktree's removal is invisible
|
|
209
|
+
// to every check above.
|
|
210
|
+
for (const reg of featureRegistrations(root)) {
|
|
211
|
+
const path = norm(reg.path);
|
|
212
|
+
if (!existsSync(reg.path)) {
|
|
213
|
+
issues.push({
|
|
214
|
+
type: 'dangling-registration',
|
|
215
|
+
feature: reg.feature,
|
|
216
|
+
path,
|
|
217
|
+
detail: `"${reg.feature}" registra worktree.path=${path} na raiz, mas o diretório não existe`,
|
|
218
|
+
fix: `morph-spec doctor --worktrees --prune`,
|
|
219
|
+
});
|
|
220
|
+
} else if (!registeredPaths.has(path)) {
|
|
221
|
+
issues.push({
|
|
222
|
+
type: 'unregistered-registration',
|
|
223
|
+
feature: reg.feature,
|
|
224
|
+
path,
|
|
225
|
+
detail: `"${reg.feature}" registra worktree.path=${path}, mas o git não o conhece como worktree — a próxima \`worktree setup\` recusará com exit 3`,
|
|
226
|
+
fix: `inspecione o diretório; se não serve mais, apague-o e rode morph-spec doctor --worktrees --prune`,
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
164
231
|
return issues;
|
|
165
232
|
}
|
|
166
233
|
|
|
167
234
|
/**
|
|
168
|
-
* Drop git's admin records for worktrees whose directory is already gone
|
|
235
|
+
* Drop git's admin records for worktrees whose directory is already gone, AND
|
|
236
|
+
* clear any feature's `worktree` registration that names one of those same
|
|
237
|
+
* gone directories.
|
|
169
238
|
*
|
|
170
|
-
*
|
|
171
|
-
* everything
|
|
172
|
-
* still exists is exactly the operation that needs
|
|
239
|
+
* Both halves are the ONLY prune offered, on purpose: neither can destroy
|
|
240
|
+
* anything, because everything they touch is already absent from disk.
|
|
241
|
+
* Removing a directory that still exists is exactly the operation that needs
|
|
242
|
+
* a human looking at it.
|
|
243
|
+
*
|
|
244
|
+
* `clearDanglingWorktreeRegistration` re-checks the dangling predicate itself
|
|
245
|
+
* (path unset, or still exists → no-op) rather than trusting a pre-computed
|
|
246
|
+
* list, so this stays correct even called against a stale audit snapshot.
|
|
173
247
|
*
|
|
174
248
|
* @param {string} [cwd]
|
|
175
|
-
* @returns {{ pruned: boolean, error?: string }}
|
|
249
|
+
* @returns {Promise<{ pruned: boolean, error?: string, clearedRegistrations: Array<{feature: string, cleared: boolean}> }>}
|
|
176
250
|
*/
|
|
177
|
-
export function pruneWorktrees(cwd = process.cwd()) {
|
|
178
|
-
if (!isGitRepo(cwd)) return { pruned: false, error: 'not-a-git-repo' };
|
|
251
|
+
export async function pruneWorktrees(cwd = process.cwd()) {
|
|
252
|
+
if (!isGitRepo(cwd)) return { pruned: false, error: 'not-a-git-repo', clearedRegistrations: [] };
|
|
253
|
+
const root = resolvePrimaryRoot(cwd);
|
|
254
|
+
|
|
255
|
+
let pruned = false;
|
|
256
|
+
let error;
|
|
179
257
|
try {
|
|
180
|
-
execFileSync('git', ['worktree', 'prune'], { cwd:
|
|
181
|
-
|
|
258
|
+
execFileSync('git', ['worktree', 'prune'], { cwd: root, stdio: 'pipe' });
|
|
259
|
+
pruned = true;
|
|
182
260
|
} catch (err) {
|
|
183
|
-
|
|
261
|
+
error = (err.message || 'unknown').slice(0, 200);
|
|
184
262
|
}
|
|
263
|
+
|
|
264
|
+
const clearedRegistrations = [];
|
|
265
|
+
for (const reg of featureRegistrations(root)) {
|
|
266
|
+
const { cleared } = await clearDanglingWorktreeRegistration(root, reg.feature);
|
|
267
|
+
if (cleared) clearedRegistrations.push({ feature: reg.feature, cleared: true });
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return { pruned, ...(error ? { error } : {}), clearedRegistrations };
|
|
185
271
|
}
|