moflo 4.12.12 → 4.13.1
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/guidance/shipped/moflo-cli-reference.md +45 -1
- package/.claude/guidance/shipped/moflo-cross-install-memory-sharing.md +1 -0
- package/.claude/helpers/gate.cjs +33 -4
- package/.claude/skills/fl/phases.md +51 -17
- package/README.md +95 -1
- package/bin/gate.cjs +33 -4
- package/dist/src/cli/commands/index.js +5 -0
- package/dist/src/cli/commands/worktree.js +408 -0
- package/dist/src/cli/config/moflo-config.js +57 -0
- package/dist/src/cli/init/embedded-helpers.js +1 -1
- package/dist/src/cli/services/worktree-provision.js +400 -0
- package/dist/src/cli/version.js +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MoFlo Worktree Command — #1481.
|
|
3
|
+
*
|
|
4
|
+
* Lifecycle + provisioning for git worktrees, so `/flo -wt` produces a RUNNABLE
|
|
5
|
+
* workspace instead of a bare checkout. This file owns git invocation and output
|
|
6
|
+
* formatting only; every platform-sensitive filesystem decision lives in
|
|
7
|
+
* `../services/worktree-provision.ts` where a unit test can reach it (Rule #1).
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* flo worktree add <branch> [--from <ref>] [--no-provision] [--json]
|
|
11
|
+
* flo worktree list [--json]
|
|
12
|
+
* flo worktree remove <branch|path> [--force] [--json]
|
|
13
|
+
*/
|
|
14
|
+
import { spawnSync } from 'node:child_process';
|
|
15
|
+
import { existsSync } from 'node:fs';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import { findProjectRoot } from '../services/project-root.js';
|
|
18
|
+
import { loadMofloConfig } from '../config/moflo-config.js';
|
|
19
|
+
import { WORKTREE_STATE_FILE_POSIX, allocateIndex, computeWorktreePath, isProvisionedPath, resolveForCompare, provisionWorktree, readWorktreeState, writeWorktreeState, } from '../services/worktree-provision.js';
|
|
20
|
+
/**
|
|
21
|
+
* Run a git command. Never `shell: true` — args are passed as an array so a
|
|
22
|
+
* branch name containing shell metacharacters cannot be reinterpreted, and so
|
|
23
|
+
* the same call works identically on all three platforms.
|
|
24
|
+
*/
|
|
25
|
+
function git(args, cwd) {
|
|
26
|
+
const result = spawnSync('git', args, { cwd, encoding: 'utf8' });
|
|
27
|
+
return {
|
|
28
|
+
ok: !result.error && result.status === 0,
|
|
29
|
+
stdout: (result.stdout ?? '').trim(),
|
|
30
|
+
stderr: (result.stderr ?? result.error?.message ?? '').trim(),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Parse `git worktree list --porcelain`. The first record is always the primary
|
|
35
|
+
* working tree; linked worktrees follow. Records are blank-line separated, and
|
|
36
|
+
* `branch` is a full ref (`refs/heads/x`) or absent when detached.
|
|
37
|
+
*/
|
|
38
|
+
function listWorktrees(repoRoot) {
|
|
39
|
+
const result = git(['worktree', 'list', '--porcelain'], repoRoot);
|
|
40
|
+
if (!result.ok)
|
|
41
|
+
return [];
|
|
42
|
+
const entries = [];
|
|
43
|
+
let current = {};
|
|
44
|
+
const flush = () => {
|
|
45
|
+
if (!current.path)
|
|
46
|
+
return;
|
|
47
|
+
entries.push({
|
|
48
|
+
path: current.path,
|
|
49
|
+
branch: current.branch ? current.branch.replace(/^refs\/heads\//, '') : null,
|
|
50
|
+
state: readWorktreeState(current.path),
|
|
51
|
+
primary: entries.length === 0,
|
|
52
|
+
});
|
|
53
|
+
current = {};
|
|
54
|
+
};
|
|
55
|
+
for (const line of result.stdout.split(/\r?\n/)) {
|
|
56
|
+
if (line.startsWith('worktree ')) {
|
|
57
|
+
flush();
|
|
58
|
+
current.path = line.slice('worktree '.length).trim();
|
|
59
|
+
}
|
|
60
|
+
else if (line.startsWith('branch ')) {
|
|
61
|
+
current.branch = line.slice('branch '.length).trim();
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
flush();
|
|
65
|
+
return entries;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* The ref a new worktree branches from when `--from` is not given.
|
|
69
|
+
*
|
|
70
|
+
* `origin/HEAD` is the authoritative answer but is not always configured in a
|
|
71
|
+
* fresh clone, so fall back to `gh` (which the rest of this repo's tooling
|
|
72
|
+
* already assumes) and finally to whichever of `origin/main`/`origin/master`
|
|
73
|
+
* exists. Returns null when none resolve — better a clear error than silently
|
|
74
|
+
* branching off the wrong ref.
|
|
75
|
+
*
|
|
76
|
+
* Deliberately NOT shared with `getDefaultBranch` in `commands/github.ts`: that
|
|
77
|
+
* one returns a bare branch name and falls back to the literal `'main'`, which
|
|
78
|
+
* is right for generating a CI workflow and wrong here — silently branching a
|
|
79
|
+
* user's work off a guessed ref is the failure this returns null to avoid. It
|
|
80
|
+
* also tries `gh` first, where this prefers git (faster, and works offline).
|
|
81
|
+
*/
|
|
82
|
+
function resolveDefaultBase(repoRoot) {
|
|
83
|
+
const head = git(['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'], repoRoot);
|
|
84
|
+
if (head.ok && head.stdout)
|
|
85
|
+
return head.stdout.replace(/^refs\/remotes\//, '');
|
|
86
|
+
const gh = spawnSync('gh', ['repo', 'view', '--json', 'defaultBranchRef', '--jq', '.defaultBranchRef.name'], {
|
|
87
|
+
cwd: repoRoot,
|
|
88
|
+
encoding: 'utf8',
|
|
89
|
+
});
|
|
90
|
+
if (!gh.error && gh.status === 0) {
|
|
91
|
+
const name = (gh.stdout ?? '').trim();
|
|
92
|
+
if (name)
|
|
93
|
+
return `origin/${name}`;
|
|
94
|
+
}
|
|
95
|
+
for (const candidate of ['origin/main', 'origin/master']) {
|
|
96
|
+
if (git(['rev-parse', '--verify', '--quiet', candidate], repoRoot).ok)
|
|
97
|
+
return candidate;
|
|
98
|
+
}
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
function renderSteps(steps) {
|
|
102
|
+
return steps
|
|
103
|
+
.map(step => {
|
|
104
|
+
const mark = step.status === 'done' ? '✓' : step.status === 'skipped' ? '·' : '✗';
|
|
105
|
+
const detail = step.detail ? ` (${step.detail})` : '';
|
|
106
|
+
return ` ${mark} ${step.kind} ${step.target}${detail}`;
|
|
107
|
+
})
|
|
108
|
+
.join('\n');
|
|
109
|
+
}
|
|
110
|
+
// =============================================================================
|
|
111
|
+
// add
|
|
112
|
+
// =============================================================================
|
|
113
|
+
async function cmdAdd(ctx) {
|
|
114
|
+
const branch = ctx.args?.[1];
|
|
115
|
+
const json = ctx.flags.json === true;
|
|
116
|
+
if (!branch) {
|
|
117
|
+
return { success: false, message: 'Usage: flo worktree add <branch> [--from <ref>]', exitCode: 1 };
|
|
118
|
+
}
|
|
119
|
+
const repoRoot = findProjectRoot({ cwd: ctx.cwd });
|
|
120
|
+
const config = loadMofloConfig(repoRoot);
|
|
121
|
+
const worktreeConfig = config.worktree;
|
|
122
|
+
const target = computeWorktreePath(repoRoot, branch, worktreeConfig?.dir);
|
|
123
|
+
const existing = listWorktrees(repoRoot);
|
|
124
|
+
// Resolve the needle once, then compare resolved strings — realpathing both
|
|
125
|
+
// sides inside the scan costs 4 walks per worktree for the same answer.
|
|
126
|
+
const resolvedTarget = resolveForCompare(target);
|
|
127
|
+
const alreadyRegistered = existing.find(entry => resolveForCompare(entry.path) === resolvedTarget);
|
|
128
|
+
// Reuse rather than recreate: a prior run may have left work in this tree, and
|
|
129
|
+
// deleting a directory we did not just create is never this command's call.
|
|
130
|
+
if (!alreadyRegistered) {
|
|
131
|
+
if (existsSync(target)) {
|
|
132
|
+
return {
|
|
133
|
+
success: false,
|
|
134
|
+
message: `Path already exists but is not a registered worktree: ${target}\nRemove it by hand, or pass a different branch name.`,
|
|
135
|
+
exitCode: 1,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
const explicitFrom = typeof ctx.flags.from === 'string';
|
|
139
|
+
const base = explicitFrom ? ctx.flags.from : resolveDefaultBase(repoRoot);
|
|
140
|
+
if (!base) {
|
|
141
|
+
return {
|
|
142
|
+
success: false,
|
|
143
|
+
message: 'Could not resolve a default base ref (no origin/HEAD, no gh, no origin/main or origin/master). Pass --from <ref>.',
|
|
144
|
+
exitCode: 1,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
// Fetch so the DEFAULT base is current — branching a ticket off a stale
|
|
148
|
+
// origin/main is the failure this guards. An explicit `--from` that already
|
|
149
|
+
// resolves locally (a tag, another branch) is the user naming a specific
|
|
150
|
+
// commit, so skip the network round trip there. A fetch failure is never
|
|
151
|
+
// fatal: an offline machine should still get its worktree.
|
|
152
|
+
const baseIsLocal = git(['rev-parse', '--verify', '--quiet', base], repoRoot).ok;
|
|
153
|
+
if (!(explicitFrom && baseIsLocal))
|
|
154
|
+
git(['fetch', 'origin'], repoRoot);
|
|
155
|
+
const created = git(['worktree', 'add', '-b', branch, target, base], repoRoot);
|
|
156
|
+
if (!created.ok) {
|
|
157
|
+
return { success: false, message: `git worktree add failed: ${created.stderr}`, exitCode: 1 };
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
// Re-adding an existing worktree MUST keep its index. `existing` includes that
|
|
161
|
+
// worktree, so allocating afresh would hand it a new number and rewrite
|
|
162
|
+
// worktree.json — silently shifting every port a consumer derived from
|
|
163
|
+
// MOFLO_WORKTREE_INDEX in a tree they are already working in.
|
|
164
|
+
const index = alreadyRegistered?.state?.index ??
|
|
165
|
+
allocateIndex(existing.map(entry => entry.state?.index).filter((n) => typeof n === 'number'));
|
|
166
|
+
let provisioned = true;
|
|
167
|
+
// Distinct from `!provisioned`: skipping provisioning by request is not a
|
|
168
|
+
// failure, so it must not colour the exit code.
|
|
169
|
+
let provisionFailed = false;
|
|
170
|
+
let steps = [];
|
|
171
|
+
// Positive name, negative read (#1474): the parser turns `--no-provision`
|
|
172
|
+
// into `flags.provision = false`; an option DECLARED `no-provision` would be
|
|
173
|
+
// an unreachable no-op.
|
|
174
|
+
if (ctx.flags.provision === false) {
|
|
175
|
+
// Still record state so `list` reports the tree as moflo-created and the
|
|
176
|
+
// index stays allocated against it.
|
|
177
|
+
writeWorktreeState(target, { branch, index, primaryRoot: repoRoot, provisioned: false });
|
|
178
|
+
provisioned = false;
|
|
179
|
+
}
|
|
180
|
+
else {
|
|
181
|
+
const result = provisionWorktree({
|
|
182
|
+
primaryRoot: repoRoot,
|
|
183
|
+
worktreePath: target,
|
|
184
|
+
branch,
|
|
185
|
+
index,
|
|
186
|
+
config: worktreeConfig,
|
|
187
|
+
jsonMode: json,
|
|
188
|
+
});
|
|
189
|
+
provisioned = result.provisioned;
|
|
190
|
+
provisionFailed = !result.provisioned;
|
|
191
|
+
steps = result.steps;
|
|
192
|
+
}
|
|
193
|
+
if (json) {
|
|
194
|
+
console.log(JSON.stringify({ path: target, branch, index, provisioned, steps }));
|
|
195
|
+
return { success: !provisionFailed, exitCode: provisionFailed ? 1 : 0 };
|
|
196
|
+
}
|
|
197
|
+
const lines = [`Worktree: ${target}`, `Branch: ${branch}`, `Index: ${index}`];
|
|
198
|
+
if (steps.length > 0)
|
|
199
|
+
lines.push('Provisioning:', renderSteps(steps));
|
|
200
|
+
else if (ctx.flags.provision === false)
|
|
201
|
+
lines.push('Provisioning: skipped (--no-provision)');
|
|
202
|
+
else if (!worktreeConfig) {
|
|
203
|
+
lines.push('Provisioning: none configured (add a `worktree:` block to moflo.yaml)');
|
|
204
|
+
}
|
|
205
|
+
lines.push(`Remove with: flo worktree remove ${branch}`);
|
|
206
|
+
console.log(lines.join('\n'));
|
|
207
|
+
return { success: !provisionFailed, exitCode: provisionFailed ? 1 : 0 };
|
|
208
|
+
}
|
|
209
|
+
// =============================================================================
|
|
210
|
+
// list
|
|
211
|
+
// =============================================================================
|
|
212
|
+
async function cmdList(ctx) {
|
|
213
|
+
const repoRoot = findProjectRoot({ cwd: ctx.cwd });
|
|
214
|
+
const entries = listWorktrees(repoRoot);
|
|
215
|
+
if (ctx.flags.json === true) {
|
|
216
|
+
console.log(JSON.stringify(entries.map(entry => ({
|
|
217
|
+
path: entry.path,
|
|
218
|
+
branch: entry.branch,
|
|
219
|
+
primary: entry.primary,
|
|
220
|
+
provisioned: entry.state?.provisioned ?? false,
|
|
221
|
+
managed: entry.state !== null,
|
|
222
|
+
index: entry.state?.index ?? null,
|
|
223
|
+
}))));
|
|
224
|
+
return { success: true, exitCode: 0 };
|
|
225
|
+
}
|
|
226
|
+
if (entries.length === 0) {
|
|
227
|
+
console.log('No worktrees.');
|
|
228
|
+
return { success: true, exitCode: 0 };
|
|
229
|
+
}
|
|
230
|
+
const lines = entries.map(entry => {
|
|
231
|
+
const tag = entry.primary
|
|
232
|
+
? 'primary'
|
|
233
|
+
: entry.state === null
|
|
234
|
+
? 'unmanaged'
|
|
235
|
+
: entry.state.provisioned
|
|
236
|
+
? `provisioned #${entry.state.index}`
|
|
237
|
+
: `unprovisioned #${entry.state.index}`;
|
|
238
|
+
return ` ${entry.branch ?? '(detached)'} [${tag}]\n ${entry.path}`;
|
|
239
|
+
});
|
|
240
|
+
console.log(lines.join('\n'));
|
|
241
|
+
return { success: true, exitCode: 0 };
|
|
242
|
+
}
|
|
243
|
+
// =============================================================================
|
|
244
|
+
// remove
|
|
245
|
+
// =============================================================================
|
|
246
|
+
/**
|
|
247
|
+
* Porcelain status lines that represent the USER's work.
|
|
248
|
+
*
|
|
249
|
+
* `flo worktree add` writes `.moflo/worktree.json` into the tree it creates, and
|
|
250
|
+
* `.moflo/` is not gitignored in every project — so a freshly created, untouched
|
|
251
|
+
* worktree reports as dirty. Counting moflo's own bookkeeping as user work would
|
|
252
|
+
* make `remove` demand `--force` on every worktree this command produced, which
|
|
253
|
+
* trains the user to always pass it and defeats the guard entirely.
|
|
254
|
+
*
|
|
255
|
+
* Only that ONE file is excused, never the whole `.moflo/` directory: a worktree
|
|
256
|
+
* may also hold un-pushed SDD specs and plans under `.moflo/specs/`, and those
|
|
257
|
+
* are user-authored work that must still block removal. Reaching that precision
|
|
258
|
+
* requires `-uall` at the call site — porcelain otherwise collapses an untracked
|
|
259
|
+
* directory to a single `?? .moflo/` line, which cannot be told apart from spec
|
|
260
|
+
* work living inside it.
|
|
261
|
+
*
|
|
262
|
+
* Each line is `XY <path>`; a rename is `XY <old> -> <new>`, and a path with
|
|
263
|
+
* unusual characters is quoted with C-style escapes. Only the leading two
|
|
264
|
+
* status columns are fixed width, so the path starts at index 3. A filename
|
|
265
|
+
* containing a literal ` -> ` inside quotes would mis-split — harmless, because
|
|
266
|
+
* the mis-split value simply fails to equal the state file and the line counts
|
|
267
|
+
* as user work, which is the safe direction (refuse removal, never delete).
|
|
268
|
+
*/
|
|
269
|
+
function userChanges(porcelain) {
|
|
270
|
+
const stateFile = WORKTREE_STATE_FILE_POSIX;
|
|
271
|
+
return porcelain
|
|
272
|
+
.split(/\r?\n/)
|
|
273
|
+
.filter(line => line.trim().length > 0)
|
|
274
|
+
.filter(line => {
|
|
275
|
+
const entry = line.slice(3).trim();
|
|
276
|
+
const target = (entry.includes(' -> ') ? entry.split(' -> ')[1] : entry).replace(/^"|"$/g, '');
|
|
277
|
+
return target !== stateFile;
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* Gitignored paths in the worktree that `remove` is about to destroy and that
|
|
282
|
+
* provisioning did not put there.
|
|
283
|
+
*
|
|
284
|
+
* `git status --porcelain` never lists ignored files, so the dirty gate above
|
|
285
|
+
* cannot see them — yet removing the worktree deletes them (stock
|
|
286
|
+
* `git worktree remove` does the same; this is inherent to worktree removal,
|
|
287
|
+
* not something --force introduces). Anything `copy:` or `link:` created is
|
|
288
|
+
* excluded: it either still exists in the primary checkout or is a symlink
|
|
289
|
+
* whose target is untouched, so naming it would be noise on every removal.
|
|
290
|
+
*
|
|
291
|
+
* Warns; never blocks. A project whose `setup:` ran `npm ci` has a legitimate
|
|
292
|
+
* `node_modules` here on every single removal, and blocking on that would just
|
|
293
|
+
* teach the user to always pass --force.
|
|
294
|
+
*/
|
|
295
|
+
function unprovisionedIgnoredPaths(worktreePath, config) {
|
|
296
|
+
const status = git(['status', '--porcelain', '--ignored=matching', '-uall'], worktreePath);
|
|
297
|
+
if (!status.ok)
|
|
298
|
+
return [];
|
|
299
|
+
return status.stdout
|
|
300
|
+
.split(/\r?\n/)
|
|
301
|
+
.filter(line => line.startsWith('!! '))
|
|
302
|
+
.map(line => line.slice(3).trim().replace(/^"|"$/g, ''))
|
|
303
|
+
.filter(target => target !== WORKTREE_STATE_FILE_POSIX)
|
|
304
|
+
.filter(target => !isProvisionedPath(target, config));
|
|
305
|
+
}
|
|
306
|
+
async function cmdRemove(ctx) {
|
|
307
|
+
const which = ctx.args?.[1];
|
|
308
|
+
const force = ctx.flags.force === true;
|
|
309
|
+
if (!which) {
|
|
310
|
+
return { success: false, message: 'Usage: flo worktree remove <branch|path> [--force]', exitCode: 1 };
|
|
311
|
+
}
|
|
312
|
+
const repoRoot = findProjectRoot({ cwd: ctx.cwd });
|
|
313
|
+
const entries = listWorktrees(repoRoot);
|
|
314
|
+
// Match by branch first, then by path. The path comparison is realpath-based,
|
|
315
|
+
// so a symlinked tempdir on macOS still matches; the needle resolves once.
|
|
316
|
+
const resolvedCandidate = resolveForCompare(path.resolve(ctx.cwd, which));
|
|
317
|
+
const match = entries.find(entry => entry.branch === which || resolveForCompare(entry.path) === resolvedCandidate);
|
|
318
|
+
if (!match) {
|
|
319
|
+
return { success: false, message: `Not a registered worktree of this repo: ${which}`, exitCode: 1 };
|
|
320
|
+
}
|
|
321
|
+
if (match.primary) {
|
|
322
|
+
return { success: false, message: 'Refusing to remove the primary working tree.', exitCode: 1 };
|
|
323
|
+
}
|
|
324
|
+
if (!force) {
|
|
325
|
+
// `-uall` so an untracked directory is not collapsed to one line — see userChanges().
|
|
326
|
+
const status = git(['status', '--porcelain', '-uall'], match.path);
|
|
327
|
+
const dirty = status.ok ? userChanges(status.stdout) : [];
|
|
328
|
+
if (dirty.length > 0) {
|
|
329
|
+
return {
|
|
330
|
+
success: false,
|
|
331
|
+
message: `Worktree has uncommitted changes: ${match.path}\n ${dirty.slice(0, 5).join('\n ')}\nCommit them, or re-run with --force.`,
|
|
332
|
+
exitCode: 1,
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
// Always `--force` at the git layer. `userChanges()` above is the real gate and
|
|
337
|
+
// has already refused anything the user would miss; git's own check cannot tell
|
|
338
|
+
// moflo's untracked `.moflo/worktree.json` from user work, so without this every
|
|
339
|
+
// worktree this command created would be unremovable without `--force`.
|
|
340
|
+
const doomed = unprovisionedIgnoredPaths(match.path, loadMofloConfig(repoRoot).worktree);
|
|
341
|
+
const removed = git(['worktree', 'remove', '--force', match.path], repoRoot);
|
|
342
|
+
if (!removed.ok) {
|
|
343
|
+
return { success: false, message: `git worktree remove failed: ${removed.stderr}`, exitCode: 1 };
|
|
344
|
+
}
|
|
345
|
+
if (ctx.flags.json === true) {
|
|
346
|
+
console.log(JSON.stringify({ removed: match.path, branch: match.branch, discardedIgnored: doomed }));
|
|
347
|
+
return { success: true, exitCode: 0 };
|
|
348
|
+
}
|
|
349
|
+
console.log(`Removed worktree: ${match.path}`);
|
|
350
|
+
if (doomed.length > 0) {
|
|
351
|
+
console.log(` also discarded ${doomed.length} gitignored path(s) that were not provisioned: ` +
|
|
352
|
+
`${doomed.slice(0, 5).join(', ')}${doomed.length > 5 ? ', …' : ''}`);
|
|
353
|
+
}
|
|
354
|
+
return { success: true, exitCode: 0 };
|
|
355
|
+
}
|
|
356
|
+
// =============================================================================
|
|
357
|
+
// Command definition
|
|
358
|
+
// =============================================================================
|
|
359
|
+
const HELP = `Usage: flo worktree <command>
|
|
360
|
+
|
|
361
|
+
Git worktrees as provisioned workspaces (moflo.yaml \`worktree:\` block):
|
|
362
|
+
add <branch> [--from <ref>] [--no-provision] [--json]
|
|
363
|
+
Create a worktree at <repo-parent>/<repo>-worktrees/<branch>
|
|
364
|
+
and provision it (copy / link / setup)
|
|
365
|
+
list [--json] List this repo's worktrees and their provisioning state
|
|
366
|
+
remove <branch|path> [--force] [--json]
|
|
367
|
+
Remove a worktree (refuses a dirty tree without --force)
|
|
368
|
+
|
|
369
|
+
With no \`worktree:\` block in moflo.yaml, \`add\` creates the worktree and
|
|
370
|
+
provisions nothing.`;
|
|
371
|
+
const worktreeCommand = {
|
|
372
|
+
name: 'worktree',
|
|
373
|
+
description: 'Create, list, and remove provisioned git worktrees',
|
|
374
|
+
aliases: ['wt'],
|
|
375
|
+
options: [
|
|
376
|
+
{ name: 'from', description: 'Base ref for the new branch (default: origin/HEAD)', type: 'string' },
|
|
377
|
+
{
|
|
378
|
+
name: 'provision',
|
|
379
|
+
description: 'Run copy/link/setup after creating the worktree (--no-provision to skip)',
|
|
380
|
+
type: 'boolean',
|
|
381
|
+
default: true,
|
|
382
|
+
},
|
|
383
|
+
{ name: 'force', description: 'Remove even with uncommitted changes', type: 'boolean' },
|
|
384
|
+
{ name: 'json', description: 'Emit machine-readable JSON', type: 'boolean' },
|
|
385
|
+
],
|
|
386
|
+
examples: [
|
|
387
|
+
{ command: 'flo worktree add feature/1481-provisioning', description: 'Create + provision a worktree' },
|
|
388
|
+
{ command: 'flo worktree list', description: 'Show every worktree and its state' },
|
|
389
|
+
{ command: 'flo worktree remove feature/1481-provisioning', description: 'Clean up when the PR is merged' },
|
|
390
|
+
],
|
|
391
|
+
action: async (ctx) => {
|
|
392
|
+
const sub = ctx.args?.[0];
|
|
393
|
+
switch (sub) {
|
|
394
|
+
case 'add':
|
|
395
|
+
return cmdAdd(ctx);
|
|
396
|
+
case 'list':
|
|
397
|
+
return cmdList(ctx);
|
|
398
|
+
case 'remove':
|
|
399
|
+
return cmdRemove(ctx);
|
|
400
|
+
default:
|
|
401
|
+
console.log(HELP);
|
|
402
|
+
return { success: !sub, exitCode: sub ? 1 : 0 };
|
|
403
|
+
}
|
|
404
|
+
},
|
|
405
|
+
};
|
|
406
|
+
export default worktreeCommand;
|
|
407
|
+
export { worktreeCommand };
|
|
408
|
+
//# sourceMappingURL=worktree.js.map
|
|
@@ -190,6 +190,39 @@ function coerceMemoryBackend(raw) {
|
|
|
190
190
|
}
|
|
191
191
|
return DEFAULT_CONFIG.memory.backend;
|
|
192
192
|
}
|
|
193
|
+
/**
|
|
194
|
+
* Coerce a `worktree.copy` / `worktree.link` entry to a string array (#1481).
|
|
195
|
+
* Both keys accept a bare string for the common single-entry case; anything
|
|
196
|
+
* that is neither a string nor an array of strings is dropped rather than
|
|
197
|
+
* throwing — a malformed entry must never stop a consumer's config loading.
|
|
198
|
+
*/
|
|
199
|
+
function coercePathList(raw) {
|
|
200
|
+
const list = typeof raw === 'string' ? [raw] : Array.isArray(raw) ? raw : undefined;
|
|
201
|
+
if (!list)
|
|
202
|
+
return undefined;
|
|
203
|
+
const cleaned = list
|
|
204
|
+
.filter((v) => typeof v === 'string')
|
|
205
|
+
.map(v => v.trim())
|
|
206
|
+
.filter(v => v.length > 0);
|
|
207
|
+
return cleaned.length > 0 ? cleaned : undefined;
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Parse the optional `worktree:` block (#1481). Returns `undefined` when the
|
|
211
|
+
* block is absent or contains nothing usable, so "not configured" stays
|
|
212
|
+
* distinguishable from "configured empty". Unknown sub-keys are ignored.
|
|
213
|
+
*/
|
|
214
|
+
function parseWorktreeConfig(raw) {
|
|
215
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
216
|
+
return undefined;
|
|
217
|
+
const block = raw;
|
|
218
|
+
const dir = typeof block.dir === 'string' && block.dir.trim().length > 0 ? block.dir.trim() : undefined;
|
|
219
|
+
const copy = coercePathList(block.copy);
|
|
220
|
+
const link = coercePathList(block.link);
|
|
221
|
+
const setup = typeof block.setup === 'string' && block.setup.trim().length > 0 ? block.setup.trim() : undefined;
|
|
222
|
+
if (!dir && !copy && !link && !setup)
|
|
223
|
+
return undefined;
|
|
224
|
+
return { ...(dir && { dir }), ...(copy && { copy }), ...(link && { link }), ...(setup && { setup }) };
|
|
225
|
+
}
|
|
193
226
|
/**
|
|
194
227
|
* Parse raw config object into typed config, merging with defaults.
|
|
195
228
|
*/
|
|
@@ -265,6 +298,10 @@ function mergeConfig(raw, root) {
|
|
|
265
298
|
return typeof v === 'string' && v.trim().length > 0 ? v.trim() : undefined;
|
|
266
299
|
})(),
|
|
267
300
|
},
|
|
301
|
+
// #1481 — optional worktree provisioning. The whole block stays `undefined`
|
|
302
|
+
// when absent so an existing consumer `moflo.yaml` is unaffected, and so
|
|
303
|
+
// `flo worktree add` can distinguish "not configured" from "configured empty".
|
|
304
|
+
worktree: parseWorktreeConfig(raw.worktree),
|
|
268
305
|
hooks: {
|
|
269
306
|
pre_edit: raw.hooks?.pre_edit ?? raw.hooks?.preEdit ?? DEFAULT_CONFIG.hooks.pre_edit,
|
|
270
307
|
post_edit: raw.hooks?.post_edit ?? raw.hooks?.postEdit ?? DEFAULT_CONFIG.hooks.post_edit,
|
|
@@ -508,6 +545,26 @@ memory:
|
|
|
508
545
|
# worktrees never produce. Conductor recipe: set hydrate_from AND snapshot_to
|
|
509
546
|
# to the SAME absolute path. Overridable per-process via MOFLO_SNAPSHOT_TO.
|
|
510
547
|
|
|
548
|
+
# Worktree provisioning (#1481) — makes "flo worktree add" (and "/flo -wt")
|
|
549
|
+
# produce a RUNNABLE workspace, not just a valid checkout. Entirely optional:
|
|
550
|
+
# with this block absent, a new worktree is created and nothing is provisioned.
|
|
551
|
+
# worktree:
|
|
552
|
+
# dir: ../myrepo-worktrees
|
|
553
|
+
# Where worktrees are created. Defaults to <repo-parent>/<repo>-worktrees.
|
|
554
|
+
# copy: [".env", ".env.*"]
|
|
555
|
+
# Gitignored files a fresh checkout lacks, copied from the primary checkout.
|
|
556
|
+
# Sources must live inside the primary checkout. NOTE: this relocates secret
|
|
557
|
+
# material to a directory OUTSIDE the repo and outside its .gitignore — keep
|
|
558
|
+
# the worktree dir out of any repo you commit.
|
|
559
|
+
# link: ["node_modules"]
|
|
560
|
+
# Symlinked (junctioned on Windows) from the primary checkout. Opt-in with no
|
|
561
|
+
# default: a symlinked root node_modules is fragile under npm workspaces —
|
|
562
|
+
# prefer "setup: npm ci" if your project uses them.
|
|
563
|
+
# setup: "npm ci"
|
|
564
|
+
# Run inside the new worktree after copy/link, with MOFLO_WORKTREE_INDEX in
|
|
565
|
+
# its environment (a small integer unique among live worktrees) so a project
|
|
566
|
+
# with fixed dev-server ports can offset them per workspace.
|
|
567
|
+
|
|
511
568
|
# Hook toggles (all on by default — disable to slim down)
|
|
512
569
|
hooks:
|
|
513
570
|
pre_edit: true # Track file edits for learning
|