@polymorphism-tech/morph-spec 8.4.0 → 8.5.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/README.md +8 -5
- package/bin/morph-spec.js +13 -10
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +7 -4
- package/docs/QUICKSTART.md +7 -4
- package/framework/CLAUDE.md +6 -5
- package/framework/MORPH.md +3 -1
- package/framework/commands/morph-archive.md +11 -4
- package/framework/schemas/feature.schema.json +2 -2
- 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 +8 -5
- 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 +3 -3
- 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 +4 -2
- package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
- package/framework/skills/core/morph-standards/SKILL.md +1 -1
- package/package.json +1 -1
- package/src/commands/project/worktree.js +154 -357
- package/src/commands/state/archive.js +148 -70
- package/src/commands/state/create.js +46 -2
- package/src/commands/state/finish.js +422 -0
- package/src/core/state/state-manager.js +2 -2
- package/src/dashboard/scanner/state-scanner.js +1 -1
- package/src/lib/generators/handoff-generator.js +2 -2
- package/src/lib/git/default-branch.js +73 -0
- package/src/lib/worktree/env-allocator.js +1 -1
- package/src/utils/file-copier.js +2 -2
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
import { execFileSync } from 'child_process';
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync } from 'fs';
|
|
3
|
+
import { join, resolve } from 'path';
|
|
4
|
+
import {
|
|
5
|
+
findWorktreeForFeature,
|
|
6
|
+
shedRootFeatureScope,
|
|
7
|
+
primaryRootUnexplainedDirt,
|
|
8
|
+
teardownWorktree,
|
|
9
|
+
resolvePrimaryRoot,
|
|
10
|
+
isValidFeatureName,
|
|
11
|
+
buildBranchName,
|
|
12
|
+
branchExists,
|
|
13
|
+
} from '../project/worktree.js';
|
|
14
|
+
import { detectDefaultBranch, hasOriginRemote } from '../../lib/git/default-branch.js';
|
|
15
|
+
import { runArchive } from './archive.js';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* `morph-spec finish <feature> --pr|--merge` — the single, symmetric close of a
|
|
19
|
+
* feature's lifecycle, for BOTH the worktree flow and the common flow.
|
|
20
|
+
*
|
|
21
|
+
* Order (see the plan's backbone): validate → ARCHIVE inside the `morph/{f}`
|
|
22
|
+
* branch (committed, so the archived state travels in one integration) →
|
|
23
|
+
* integrate:
|
|
24
|
+
* --merge : merge `--no-ff` into the default branch, LOCAL (no push).
|
|
25
|
+
* --pr : push `morph/{f}` + `gh pr create` (does NOT merge locally).
|
|
26
|
+
* → teardown the worktree (PROVE-or-DECLINE) when there is one.
|
|
27
|
+
*
|
|
28
|
+
* Every refusal is fail-CLOSED, exit 1, and — since the `--pr` remote
|
|
29
|
+
* preconditions run BEFORE the archive — a refused `--pr` leaves no side effect.
|
|
30
|
+
* `finished` is the integration (the actual value); `worktreeRemoved` is the
|
|
31
|
+
* best-effort teardown — two independent signals, as before.
|
|
32
|
+
*
|
|
33
|
+
* @param {string} feature
|
|
34
|
+
* @param {{ pr?: boolean, merge?: boolean, keep?: boolean, force?: boolean,
|
|
35
|
+
* discardIgnored?: boolean, cwd?: string }} [options]
|
|
36
|
+
*/
|
|
37
|
+
export async function finishCommand(feature, options = {}) {
|
|
38
|
+
const cwd = options.cwd || process.cwd();
|
|
39
|
+
const root = resolvePrimaryRoot(cwd);
|
|
40
|
+
const emit = (obj) => console.log(JSON.stringify(obj));
|
|
41
|
+
const rootNorm = resolve(root).replace(/\\/g, '/');
|
|
42
|
+
|
|
43
|
+
// Gate 0: valid kebab name — anti command-injection (the name flows into every
|
|
44
|
+
// git/gh call below) and the same gate the worktree path always applied.
|
|
45
|
+
if (!isValidFeatureName(feature)) {
|
|
46
|
+
emit({ finished: false, error: 'invalid-feature-name', feature });
|
|
47
|
+
process.exit(1);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Mode: exactly one of --pr / --merge (they are alternative integrations — the
|
|
51
|
+
// finish merges OR opens a PR, never both, never neither).
|
|
52
|
+
const wantPr = !!options.pr;
|
|
53
|
+
const wantMerge = !!options.merge;
|
|
54
|
+
if (wantPr === wantMerge) {
|
|
55
|
+
emit({ finished: false, error: 'mode-required', hint: 'informe exatamente um de --pr ou --merge', feature });
|
|
56
|
+
process.exit(1);
|
|
57
|
+
}
|
|
58
|
+
const mode = wantPr ? 'pr' : 'merge';
|
|
59
|
+
const expectedBranch = buildBranchName(feature);
|
|
60
|
+
|
|
61
|
+
// ── Resolve the flow ──────────────────────────────────────────────────────
|
|
62
|
+
// findWorktreeForFeature matches ANY worktree on morph/{feature} — including
|
|
63
|
+
// the PRIMARY ROOT itself when the common flow checked the branch out there.
|
|
64
|
+
// A match whose path IS the root is the common flow (no separate worktree to
|
|
65
|
+
// tear down); a match elsewhere is the worktree flow.
|
|
66
|
+
const found = findWorktreeForFeature(feature, root);
|
|
67
|
+
const isWorktreeFlow = !!found && found.path.replace(/\\/g, '/') !== rootNorm;
|
|
68
|
+
|
|
69
|
+
let worktreePath = null;
|
|
70
|
+
let featureCwd = root; // where the feature's own tree (and Gate 3) lives
|
|
71
|
+
|
|
72
|
+
if (isWorktreeFlow) {
|
|
73
|
+
// Validation 0: the worktree must actually be ON the branch we will merge.
|
|
74
|
+
if (found.branch !== found.expectedBranch) {
|
|
75
|
+
emit({
|
|
76
|
+
finished: false,
|
|
77
|
+
error: 'worktree-branch-mismatch',
|
|
78
|
+
feature,
|
|
79
|
+
expectedBranch,
|
|
80
|
+
actualBranch: found.branch,
|
|
81
|
+
path: found.path,
|
|
82
|
+
hint: `o worktree em ${found.path} está em ${found.branch || 'HEAD destacado'}, não em ${expectedBranch}`,
|
|
83
|
+
});
|
|
84
|
+
process.exit(1);
|
|
85
|
+
}
|
|
86
|
+
worktreePath = found.path;
|
|
87
|
+
featureCwd = worktreePath;
|
|
88
|
+
} else {
|
|
89
|
+
// COMMON flow: the feature branch must exist and be the ROOT's current
|
|
90
|
+
// branch (so the archive commits on it and we can checkout the default to
|
|
91
|
+
// merge). A feature with NO morph/{f} branch was born before this flow —
|
|
92
|
+
// route it explicitly to archive-without-integrate, never a generic refusal.
|
|
93
|
+
if (!branchExists(expectedBranch, root)) {
|
|
94
|
+
emit({
|
|
95
|
+
finished: false,
|
|
96
|
+
error: 'no-feature-branch',
|
|
97
|
+
feature,
|
|
98
|
+
hint: `feature sem branch ${expectedBranch} (criada antes deste fluxo) — feche com \`morph-spec archive ${feature}\` (arquivar sem integrar), ou continue o trabalho numa branch`,
|
|
99
|
+
});
|
|
100
|
+
process.exit(1);
|
|
101
|
+
}
|
|
102
|
+
const current = currentBranch(root);
|
|
103
|
+
if (current !== expectedBranch) {
|
|
104
|
+
emit({
|
|
105
|
+
finished: false,
|
|
106
|
+
error: 'not-on-feature-branch',
|
|
107
|
+
feature,
|
|
108
|
+
currentBranch: current,
|
|
109
|
+
expectedBranch,
|
|
110
|
+
hint: `faça checkout de ${expectedBranch} antes do finish (\`git checkout ${expectedBranch}\`)`,
|
|
111
|
+
});
|
|
112
|
+
process.exit(1);
|
|
113
|
+
}
|
|
114
|
+
featureCwd = root;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Validation 1: Gate 3 (review) approved on the feature's own feature.json.
|
|
118
|
+
const fj = readFeatureJson(featureCwd, feature);
|
|
119
|
+
if (!options.force && !(fj?.approvalGates?.review?.approved === true || fj?.approvals?.review?.approved === true)) {
|
|
120
|
+
emit({ finished: false, error: 'review-not-approved', feature });
|
|
121
|
+
process.exit(1);
|
|
122
|
+
}
|
|
123
|
+
const prTitle = `morph: ${feature}`;
|
|
124
|
+
const prBody = (fj?.description && String(fj.description).trim())
|
|
125
|
+
? String(fj.description).trim()
|
|
126
|
+
: `Feature ${feature} — integrada via morph-spec finish --pr.`;
|
|
127
|
+
|
|
128
|
+
// Validation 2: the tree that will carry the archive commit must be clean
|
|
129
|
+
// (work committed), so the scoped archive commit contains only the move.
|
|
130
|
+
const cleanCwd = isWorktreeFlow ? worktreePath : root;
|
|
131
|
+
const dirty = gitStatusPorcelain(cleanCwd);
|
|
132
|
+
if (dirty === null) {
|
|
133
|
+
emit({ finished: false, error: 'status-failed', feature });
|
|
134
|
+
process.exit(1);
|
|
135
|
+
}
|
|
136
|
+
if (dirty.trim() !== '') {
|
|
137
|
+
emit({ finished: false, error: isWorktreeFlow ? 'worktree-dirty' : 'working-dir-dirty', hint: 'commit seu trabalho na branch antes do finish', feature });
|
|
138
|
+
process.exit(1);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Default branch — needed as the merge target and the PR base. Never assume
|
|
142
|
+
// "master"; refuse clearly when it cannot be resolved.
|
|
143
|
+
const defaultBranch = detectDefaultBranch(root);
|
|
144
|
+
if (!defaultBranch) {
|
|
145
|
+
emit({ finished: false, error: 'no-default-branch', hint: 'não foi possível resolver a branch default (origin/HEAD, main ou master)', feature });
|
|
146
|
+
process.exit(1);
|
|
147
|
+
}
|
|
148
|
+
if (defaultBranch === expectedBranch) {
|
|
149
|
+
emit({ finished: false, error: 'feature-is-default', hint: `a branch da feature (${expectedBranch}) é a própria default — nada a integrar`, feature });
|
|
150
|
+
process.exit(1);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ── PR preconditions run BEFORE the archive, so a refused --pr has NO side
|
|
154
|
+
// effect (nothing archived, nothing committed). ─────────────────────────
|
|
155
|
+
if (mode === 'pr') {
|
|
156
|
+
if (!hasOriginRemote(root)) {
|
|
157
|
+
emit({ finished: false, error: 'no-origin-remote', hint: 'não há remoto `origin` para abrir PR — configure um ou use --merge', feature });
|
|
158
|
+
process.exit(1);
|
|
159
|
+
}
|
|
160
|
+
if (!ghAuthOk()) {
|
|
161
|
+
emit({ finished: false, error: 'gh-unavailable', hint: '`gh` não está instalado/autenticado — rode `gh auth login` ou use --merge', feature });
|
|
162
|
+
process.exit(1);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ── ARCHIVE (inside morph/{f}) ────────────────────────────────────────────
|
|
167
|
+
const arch = runArchive(feature, { cwd: featureCwd, force: options.force, git: true });
|
|
168
|
+
if (!arch.ok) {
|
|
169
|
+
emit({ finished: false, error: `archive-${arch.error}`, feature });
|
|
170
|
+
process.exit(1);
|
|
171
|
+
}
|
|
172
|
+
// A move that could not be committed (e.g. missing git identity) would leave
|
|
173
|
+
// the tree dirty and break the checkout/merge below — refuse, recoverable.
|
|
174
|
+
if (arch.archived && !arch.committed) {
|
|
175
|
+
emit({ finished: false, error: 'archive-not-committed', hint: 'o move de archive não pôde ser commitado (identidade git ausente?)', feature });
|
|
176
|
+
process.exit(1);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ── INTEGRATE ─────────────────────────────────────────────────────────────
|
|
180
|
+
if (mode === 'merge') {
|
|
181
|
+
return integrateMerge({ emit, root, feature, expectedBranch, defaultBranch, isWorktreeFlow, worktreePath, options, arch });
|
|
182
|
+
}
|
|
183
|
+
return integratePr({ emit, root, feature, expectedBranch, defaultBranch, isWorktreeFlow, worktreePath, options, prTitle, prBody, arch });
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ── merge integration (local, no push) ───────────────────────────────────────
|
|
187
|
+
|
|
188
|
+
async function integrateMerge({ emit, root, feature, expectedBranch, defaultBranch, isWorktreeFlow, worktreePath, options, arch }) {
|
|
189
|
+
const mergeMsg = `morph-spec finish: merge ${expectedBranch}`;
|
|
190
|
+
|
|
191
|
+
if (isWorktreeFlow) {
|
|
192
|
+
// The root stays on the default; shed this feature's own root-side scaffold,
|
|
193
|
+
// then guard the whole root before landing the merge commit.
|
|
194
|
+
const { shedBackup } = shedRootFeatureScope(root, feature);
|
|
195
|
+
const backupNote = shedBackup ? { shedBackup } : {};
|
|
196
|
+
|
|
197
|
+
const rootClean = primaryRootUnexplainedDirt(root, feature);
|
|
198
|
+
if (!rootClean.ok) {
|
|
199
|
+
emit({ finished: false, error: 'primary-root-status-failed', feature, ...backupNote });
|
|
200
|
+
process.exit(1);
|
|
201
|
+
}
|
|
202
|
+
if (rootClean.dirt.length) {
|
|
203
|
+
emit({ finished: false, error: 'primary-root-dirty', feature, ...backupNote });
|
|
204
|
+
process.exit(1);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
try {
|
|
208
|
+
execFileSync('git', ['merge', '--no-ff', expectedBranch, '-m', mergeMsg], { cwd: root, stdio: 'pipe' });
|
|
209
|
+
} catch (err) {
|
|
210
|
+
tryGit(['merge', '--abort'], root);
|
|
211
|
+
emit({ finished: false, error: 'merge-conflict', output: gitErr(err), hint: 'resolva manualmente ou re-execute sequencialmente', feature, ...backupNote });
|
|
212
|
+
process.exit(1);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (options.keep) {
|
|
216
|
+
dropFromRootIndex(root, feature);
|
|
217
|
+
emit({ finished: true, mode: 'merge', feature, merged: expectedBranch, kept: true, worktreeRemoved: false, ...backupNote });
|
|
218
|
+
process.exit(0);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const teardown = await teardownWorktree(worktreePath, { root, feature, branch: expectedBranch, options, deleteBranch: true });
|
|
222
|
+
dropFromRootIndex(root, feature);
|
|
223
|
+
emit(mergeOutput({ feature, branch: expectedBranch, worktreePath, teardown, shedBackup }));
|
|
224
|
+
process.exit(0);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// COMMON flow: we are on morph/{f} in the root, archive committed. Move to the
|
|
228
|
+
// default and merge the feature branch in.
|
|
229
|
+
try {
|
|
230
|
+
execFileSync('git', ['checkout', defaultBranch], { cwd: root, stdio: 'pipe' });
|
|
231
|
+
} catch (err) {
|
|
232
|
+
emit({ finished: false, error: 'checkout-default-failed', output: gitErr(err), hint: `não foi possível trocar para ${defaultBranch}`, feature });
|
|
233
|
+
process.exit(1);
|
|
234
|
+
}
|
|
235
|
+
try {
|
|
236
|
+
execFileSync('git', ['merge', '--no-ff', expectedBranch, '-m', mergeMsg], { cwd: root, stdio: 'pipe' });
|
|
237
|
+
} catch (err) {
|
|
238
|
+
tryGit(['merge', '--abort'], root);
|
|
239
|
+
emit({
|
|
240
|
+
finished: false,
|
|
241
|
+
error: 'merge-conflict',
|
|
242
|
+
output: gitErr(err),
|
|
243
|
+
// The abort left the working dir on the default WITHOUT the feature — say
|
|
244
|
+
// where the intact work is so the user can recover it.
|
|
245
|
+
hint: `conflito ao mergear ${expectedBranch} em ${defaultBranch}. Seu trabalho está intacto em ${expectedBranch} — rode \`git checkout ${expectedBranch}\` para voltar a ele`,
|
|
246
|
+
feature,
|
|
247
|
+
});
|
|
248
|
+
process.exit(1);
|
|
249
|
+
}
|
|
250
|
+
tryGit(['branch', '-d', expectedBranch], root);
|
|
251
|
+
// The root index (gitignored) was already updated by the archive (same cwd).
|
|
252
|
+
emit({ finished: true, mode: 'merge', feature, merged: expectedBranch, into: defaultBranch, worktreeRemoved: false, nextStep: null });
|
|
253
|
+
process.exit(0);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ── PR integration (push + gh, no local merge) ───────────────────────────────
|
|
257
|
+
|
|
258
|
+
async function integratePr({ emit, root, feature, expectedBranch, defaultBranch, isWorktreeFlow, worktreePath, options, prTitle, prBody }) {
|
|
259
|
+
try {
|
|
260
|
+
execFileSync('git', ['push', '-u', 'origin', expectedBranch], { cwd: root, stdio: 'pipe' });
|
|
261
|
+
} catch (err) {
|
|
262
|
+
emit({ finished: false, error: 'push-failed', output: gitErr(err), hint: 'o archive está commitado localmente — resolva o push e re-execute', feature });
|
|
263
|
+
process.exit(1);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
let prUrl = null;
|
|
267
|
+
try {
|
|
268
|
+
const { bin, prefix } = resolveGh();
|
|
269
|
+
prUrl = execFileSync(bin, [...prefix, 'pr', 'create', '--base', defaultBranch, '--head', expectedBranch, '--title', prTitle, '--body', prBody], { cwd: root, stdio: 'pipe' }).toString().trim();
|
|
270
|
+
} catch (err) {
|
|
271
|
+
emit({ finished: false, error: 'pr-create-failed', output: gitErr(err), hint: 'a branch foi enviada (push OK); abra o PR manualmente com `gh pr create`', feature, pushed: expectedBranch });
|
|
272
|
+
process.exit(1);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Worktree flow: remove the worktree but KEEP the branch (the open PR needs it).
|
|
276
|
+
// Common flow: nothing to tear down; stay on morph/{f} (the default has no
|
|
277
|
+
// feature yet — honest until the PR merges).
|
|
278
|
+
let teardown = null;
|
|
279
|
+
let shedBackup = null;
|
|
280
|
+
if (isWorktreeFlow && !options.keep) {
|
|
281
|
+
// Shed the transient root-side scaffold BEFORE teardown — exactly as --merge
|
|
282
|
+
// does. The real `create --worktree` leaves the root's .morph/features/{f}
|
|
283
|
+
// UNTRACKED; without this, teardown's clearRootWorktreeRegistration would
|
|
284
|
+
// `git add` + commit that scaffold onto the default branch. Then when the PR
|
|
285
|
+
// later merges (carrying features/{f} → archive/{f}), the default would hold
|
|
286
|
+
// the feature in BOTH features/ and archive/ at once — active and archived.
|
|
287
|
+
// The branch/PR owns the authoritative copy; the root scaffold is transient.
|
|
288
|
+
shedBackup = shedRootFeatureScope(root, feature).shedBackup;
|
|
289
|
+
teardown = await teardownWorktree(worktreePath, { root, feature, branch: expectedBranch, options, deleteBranch: false });
|
|
290
|
+
// The shed removed the root's features/{f} scaffold from disk, but runArchive
|
|
291
|
+
// dropped the feature only from the WORKTREE's index (its cwd). Drop it from
|
|
292
|
+
// the ROOT index too — exactly as --merge does — or `doctor`'s lifecycle audit
|
|
293
|
+
// flags a `stale-index` phantom (index lists {f}, no folder on disk).
|
|
294
|
+
dropFromRootIndex(root, feature);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const out = {
|
|
298
|
+
finished: true,
|
|
299
|
+
mode: 'pr',
|
|
300
|
+
feature,
|
|
301
|
+
pushed: expectedBranch,
|
|
302
|
+
base: defaultBranch,
|
|
303
|
+
pr: prUrl,
|
|
304
|
+
...(shedBackup ? { shedBackup } : {}),
|
|
305
|
+
...(teardown
|
|
306
|
+
? { worktreeRemoved: teardown.worktreeRemoved, ...teardownExtras(teardown, worktreePath, expectedBranch, false) }
|
|
307
|
+
: { worktreeRemoved: false }),
|
|
308
|
+
};
|
|
309
|
+
emit(out);
|
|
310
|
+
process.exit(0);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// ── output helpers ────────────────────────────────────────────────────────
|
|
314
|
+
|
|
315
|
+
function mergeOutput({ feature, branch, worktreePath, teardown, shedBackup }) {
|
|
316
|
+
return {
|
|
317
|
+
finished: true,
|
|
318
|
+
mode: 'merge',
|
|
319
|
+
feature,
|
|
320
|
+
merged: branch,
|
|
321
|
+
worktreeRemoved: teardown.worktreeRemoved,
|
|
322
|
+
...(shedBackup ? { shedBackup } : {}),
|
|
323
|
+
...teardownExtras(teardown, worktreePath, branch, true),
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** Shared teardown reporting for both modes (kept identical to the old finish). */
|
|
328
|
+
function teardownExtras(teardown, worktreePath, branch, deleteBranch) {
|
|
329
|
+
const out = {};
|
|
330
|
+
if (teardown.ignoredBackup) out.worktreeIgnoredBackup = teardown.ignoredBackup;
|
|
331
|
+
if (teardown.worktreeRemoved && teardown.discarded.length) out.discardedIgnored = teardown.discarded;
|
|
332
|
+
if (!teardown.worktreeRemoved) {
|
|
333
|
+
out.path = worktreePath;
|
|
334
|
+
out.branch = branch;
|
|
335
|
+
if (teardown.blockers.length) out.reparsePoints = teardown.blockers;
|
|
336
|
+
if (teardown.ignoredBlocking.length) out.ignoredFiles = teardown.ignoredBlocking;
|
|
337
|
+
out.cleanupHint = teardown.declineReason;
|
|
338
|
+
out.relinkHint = `as junctions de infra foram soltas antes desta recusa — para continuar no worktree, rode \`morph-spec worktree link --all --target "${worktreePath}"\``;
|
|
339
|
+
}
|
|
340
|
+
return out;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// ── small git helpers ────────────────────────────────────────────────────────
|
|
344
|
+
|
|
345
|
+
function currentBranch(cwd) {
|
|
346
|
+
try {
|
|
347
|
+
return execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd, stdio: 'pipe' }).toString().trim();
|
|
348
|
+
} catch {
|
|
349
|
+
return null;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function gitStatusPorcelain(cwd) {
|
|
354
|
+
try {
|
|
355
|
+
return execFileSync('git', ['status', '--porcelain'], { cwd, stdio: 'pipe' }).toString();
|
|
356
|
+
} catch {
|
|
357
|
+
return null;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Resolve the `gh` invocation. Defaults to the `gh` binary on PATH. Overridable
|
|
363
|
+
* via MORPH_GH_BIN: a plain string names a different binary (a corporate wrapper,
|
|
364
|
+
* `hub`, …); a JSON array is a full argv prefix — e.g.
|
|
365
|
+
* `["/path/to/node","/path/to/gh-shim.js"]` — which is how the tests inject a
|
|
366
|
+
* cross-platform stub that execFileSync can actually spawn (Node ≥20 refuses to
|
|
367
|
+
* run `.cmd`/`.bat` shims without `shell:true`, and shell:true would reopen the
|
|
368
|
+
* arg-injection hole the whole file avoids).
|
|
369
|
+
*
|
|
370
|
+
* @returns {{ bin: string, prefix: string[] }}
|
|
371
|
+
*/
|
|
372
|
+
function resolveGh() {
|
|
373
|
+
const raw = process.env.MORPH_GH_BIN;
|
|
374
|
+
if (raw && raw.trim().startsWith('[')) {
|
|
375
|
+
try {
|
|
376
|
+
const arr = JSON.parse(raw);
|
|
377
|
+
if (Array.isArray(arr) && arr.length) return { bin: String(arr[0]), prefix: arr.slice(1).map(String) };
|
|
378
|
+
} catch { /* not JSON — treat as a plain binary name below */ }
|
|
379
|
+
}
|
|
380
|
+
return { bin: raw && raw.trim() ? raw.trim() : 'gh', prefix: [] };
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function ghAuthOk() {
|
|
384
|
+
const { bin, prefix } = resolveGh();
|
|
385
|
+
try {
|
|
386
|
+
execFileSync(bin, [...prefix, 'auth', 'status'], { stdio: 'pipe' });
|
|
387
|
+
return true;
|
|
388
|
+
} catch {
|
|
389
|
+
return false;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function tryGit(args, cwd) {
|
|
394
|
+
try { execFileSync('git', args, { cwd, stdio: 'pipe' }); } catch { /* best-effort */ }
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function gitErr(err) {
|
|
398
|
+
return (((err.stdout && err.stdout.toString()) || '') + ((err.stderr && err.stderr.toString()) || '')).slice(0, 2000) || (err.message || '').slice(0, 500);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function readFeatureJson(cwd, feature) {
|
|
402
|
+
try {
|
|
403
|
+
return JSON.parse(readFileSync(join(cwd, '.morph', 'features', feature, 'feature.json'), 'utf8'));
|
|
404
|
+
} catch {
|
|
405
|
+
return null;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** Drop a feature from the thin, gitignored root index (never via saveState). */
|
|
410
|
+
function dropFromRootIndex(root, feature) {
|
|
411
|
+
const rootPath = join(root, '.morph', 'state.json');
|
|
412
|
+
if (!existsSync(rootPath)) return;
|
|
413
|
+
try {
|
|
414
|
+
const idx = JSON.parse(readFileSync(rootPath, 'utf8'));
|
|
415
|
+
if (idx.features && idx.features[feature]) {
|
|
416
|
+
delete idx.features[feature];
|
|
417
|
+
idx.metadata = idx.metadata || {};
|
|
418
|
+
idx.metadata.totalFeatures = Object.keys(idx.features).length;
|
|
419
|
+
writeFileSync(rootPath, JSON.stringify(idx, null, 2), 'utf8');
|
|
420
|
+
}
|
|
421
|
+
} catch { /* index is derivable — non-fatal */ }
|
|
422
|
+
}
|
|
@@ -123,7 +123,7 @@ export function atomicWriteJson(targetPath, data) {
|
|
|
123
123
|
* A formatting mismatch is NOT free, though: the guard compares bytes, so a
|
|
124
124
|
* sibling committed with a trailing newline fails it and gets rewritten. The
|
|
125
125
|
* content is equivalent, but git sees ` M` on a file this caller never meant to
|
|
126
|
-
* touch — enough to fail a whole-tree clean check (see
|
|
126
|
+
* touch — enough to fail a whole-tree clean check (see the finish teardown's clearRootWorktreeRegistration).
|
|
127
127
|
* And the guard only skips IDENTICAL content: a concurrent instance's CHANGED
|
|
128
128
|
* write to a sibling would still be clobbered by the sweep.
|
|
129
129
|
*/
|
|
@@ -373,7 +373,7 @@ export function saveState(state) {
|
|
|
373
373
|
// and shows up as ` M` — dirtying the tree on behalf of a feature the
|
|
374
374
|
// caller never touched. Callers that need to change ONE feature's field
|
|
375
375
|
// should write that feature.json directly instead of round-tripping state
|
|
376
|
-
// through here (see
|
|
376
|
+
// through here (see the finish teardown's clearRootWorktreeRegistration).
|
|
377
377
|
const thinFeatures = {};
|
|
378
378
|
for (const [name, feature] of Object.entries(features)) {
|
|
379
379
|
writeFeatureJson(name, feature);
|
|
@@ -35,7 +35,7 @@ export function scanState(projectDir) {
|
|
|
35
35
|
// (.worktrees/{wt}/.morph/features/*/feature.json). In the v8 multi-instance
|
|
36
36
|
// model a feature's authoritative feature.json is committed on its
|
|
37
37
|
// `morph/{feature}` branch and only lands in the root .morph/features/ after
|
|
38
|
-
// `
|
|
38
|
+
// `morph-spec finish`. This is read-only visibility for the dashboard — root
|
|
39
39
|
// features (the merged, authoritative state) always take precedence, and
|
|
40
40
|
// worktree-sourced entries are tagged with `_worktree` so the UI can
|
|
41
41
|
// distinguish them.
|
|
@@ -111,8 +111,8 @@ function nextAction(feature, phase, tasks, featureName) {
|
|
|
111
111
|
}
|
|
112
112
|
case 'review':
|
|
113
113
|
return approved('review')
|
|
114
|
-
? `Gate 3 aprovado — feche: \`morph-spec
|
|
115
|
-
: `Produza \`4-review/review-report.md\` (avaliador independente) e aprove o Gate 3; depois \`
|
|
114
|
+
? `Gate 3 aprovado — feche: \`morph-spec finish ${featureName} --pr\` (abre PR) ou \`--merge\` (merge local na default). Arquiva + integra num passo.`
|
|
115
|
+
: `Produza \`4-review/review-report.md\` (avaliador independente) e aprove o Gate 3; depois \`morph-spec finish ${featureName} --pr|--merge\`.`;
|
|
116
116
|
default:
|
|
117
117
|
return `Rode \`morph-spec status ${featureName}\` para orientar o próximo passo.`;
|
|
118
118
|
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { execFileSync } from 'child_process';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Git helpers shared by `create` (which branches `morph/{f}` off the default)
|
|
5
|
+
* and `finish` (which merges/PRs back into it). Kept tiny and pure so both
|
|
6
|
+
* call sites read the same source of truth for "what is the default branch"
|
|
7
|
+
* and "is there a remote to push to".
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Does a local branch exist? execFile (argv, never a cmd.exe string) so the
|
|
12
|
+
* name reaches git as one token — the same anti-injection discipline the rest
|
|
13
|
+
* of the worktree/finish path uses.
|
|
14
|
+
*
|
|
15
|
+
* @param {string} branch
|
|
16
|
+
* @param {string} cwd
|
|
17
|
+
* @returns {boolean}
|
|
18
|
+
*/
|
|
19
|
+
function localBranchExists(branch, cwd) {
|
|
20
|
+
try {
|
|
21
|
+
execFileSync('git', ['show-ref', '--verify', '--quiet', `refs/heads/${branch}`], { cwd, stdio: 'pipe' });
|
|
22
|
+
return true;
|
|
23
|
+
} catch {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Resolve the repository's default branch NAME (e.g. `main`), never assuming
|
|
30
|
+
* `master`. Strategy, in order:
|
|
31
|
+
* 1. `refs/remotes/origin/HEAD` symbolic ref — the remote's declared default,
|
|
32
|
+
* the authoritative answer when a remote is configured.
|
|
33
|
+
* 2. A local `main`, then `master` — the fallback for a repo with no remote
|
|
34
|
+
* (or one whose `origin/HEAD` was never set).
|
|
35
|
+
*
|
|
36
|
+
* Returns `null` when none of these resolve (a repo whose default is some other
|
|
37
|
+
* name, with no `origin/HEAD` set) — the caller decides: `create` fails open
|
|
38
|
+
* (no branch), `finish` refuses with a clear message.
|
|
39
|
+
*
|
|
40
|
+
* @param {string} [cwd]
|
|
41
|
+
* @returns {string|null} the default branch name, or null if undeterminable
|
|
42
|
+
*/
|
|
43
|
+
export function detectDefaultBranch(cwd = process.cwd()) {
|
|
44
|
+
try {
|
|
45
|
+
const ref = execFileSync('git', ['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'], { cwd, stdio: 'pipe' })
|
|
46
|
+
.toString()
|
|
47
|
+
.trim();
|
|
48
|
+
// e.g. "refs/remotes/origin/main" → "main"
|
|
49
|
+
const m = ref.match(/^refs\/remotes\/origin\/(.+)$/);
|
|
50
|
+
if (m && m[1]) return m[1];
|
|
51
|
+
} catch { /* no origin/HEAD — fall back to a local default */ }
|
|
52
|
+
|
|
53
|
+
for (const candidate of ['main', 'master']) {
|
|
54
|
+
if (localBranchExists(candidate, cwd)) return candidate;
|
|
55
|
+
}
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Is an `origin` remote configured? `--pr` needs one to push the feature branch;
|
|
61
|
+
* `--merge` is purely local and never calls this.
|
|
62
|
+
*
|
|
63
|
+
* @param {string} [cwd]
|
|
64
|
+
* @returns {boolean}
|
|
65
|
+
*/
|
|
66
|
+
export function hasOriginRemote(cwd = process.cwd()) {
|
|
67
|
+
try {
|
|
68
|
+
const url = execFileSync('git', ['remote', 'get-url', 'origin'], { cwd, stdio: 'pipe' }).toString().trim();
|
|
69
|
+
return url.length > 0;
|
|
70
|
+
} catch {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* worktree of the repo shares.
|
|
15
15
|
*
|
|
16
16
|
* Allocation is deterministic (first free index), idempotent per feature, and
|
|
17
|
-
* released only when `
|
|
17
|
+
* released only when `morph-spec finish` actually removed the worktree. Writes
|
|
18
18
|
* are tmp+rename so a crash never leaves a torn registry; a corrupt registry
|
|
19
19
|
* is treated as empty (fail-open — the cost is a re-allocation, never a crash).
|
|
20
20
|
*/
|
package/src/utils/file-copier.js
CHANGED
|
@@ -225,7 +225,7 @@ export async function updateGitignore(projectPath) {
|
|
|
225
225
|
* Windows with `core.autocrlf=true` (the Git-for-Windows default) and no
|
|
226
226
|
* attribute pinning EOL, Git flags EVERY rewritten feature.json as modified
|
|
227
227
|
* even when the content is byte-identical. That silently breaks the whole-root
|
|
228
|
-
* clean-check in `morph-spec
|
|
228
|
+
* clean-check in `morph-spec finish` as soon as a project has more
|
|
229
229
|
* than one feature — precisely the multi-instance / parallel-worktree flow,
|
|
230
230
|
* where one `saveState` touches sibling feature.json files and marks them dirty.
|
|
231
231
|
* This mirrors the .gitattributes this framework repo already carries for its
|
|
@@ -242,7 +242,7 @@ export async function ensureGitattributes(projectPath) {
|
|
|
242
242
|
`${MARKER} — LF-pin morph state so core.autocrlf never churns it`,
|
|
243
243
|
'# (feature.json is committed + rewritten by saveState with LF; without this,',
|
|
244
244
|
'# Windows autocrlf marks every feature.json dirty and breaks the whole-root',
|
|
245
|
-
'# clean-check in `morph-spec
|
|
245
|
+
'# clean-check in `morph-spec finish`).',
|
|
246
246
|
'.morph/** text=auto eol=lf',
|
|
247
247
|
];
|
|
248
248
|
|