moflo 4.12.12 → 4.13.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/guidance/shipped/moflo-cli-reference.md +45 -1
- package/.claude/guidance/shipped/moflo-cross-install-memory-sharing.md +1 -0
- package/.claude/skills/fl/phases.md +51 -17
- package/README.md +95 -1
- 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/services/worktree-provision.js +400 -0
- package/dist/src/cli/version.js +1 -1
- package/package.json +2 -2
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
---
|
|
6
6
|
|
|
7
|
-
## CLI Commands (
|
|
7
|
+
## CLI Commands (27 Commands, 140+ Subcommands)
|
|
8
8
|
|
|
9
9
|
### Core Commands
|
|
10
10
|
|
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
| `epic` | 3 | Epic orchestrator — run/status/reset with single-branch or auto-merge strategy |
|
|
41
41
|
| `doctor` | 1 | System diagnostics with health checks |
|
|
42
42
|
| `completions` | 4 | Shell completions (bash, zsh, fish, powershell) |
|
|
43
|
+
| `worktree` | 3 | Provisioned git worktrees (add, list, remove) — alias `wt` |
|
|
43
44
|
|
|
44
45
|
### Quick Examples (MCP Preferred)
|
|
45
46
|
|
|
@@ -59,6 +60,49 @@ npx flo daemon start
|
|
|
59
60
|
|
|
60
61
|
---
|
|
61
62
|
|
|
63
|
+
## Provisioned Worktrees (`flo worktree`)
|
|
64
|
+
|
|
65
|
+
A bare `git worktree add` produces a valid checkout and an unrunnable workspace — no
|
|
66
|
+
`node_modules`, none of the gitignored `.env` files, and dev servers that collide with the
|
|
67
|
+
primary checkout on fixed ports. `flo worktree add` creates the worktree **and** provisions it.
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
flo worktree add feature/123-thing # create + provision (this is what /flo -wt runs)
|
|
71
|
+
flo worktree add feature/123 --json # {"path":…,"branch":…,"index":0,"provisioned":true}
|
|
72
|
+
flo worktree list # every worktree + its provisioning state
|
|
73
|
+
flo worktree remove feature/123-thing # refuses a dirty tree unless --force
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Provisioning is driven by an optional `worktree:` block in `moflo.yaml`. **With no block, `add`
|
|
77
|
+
creates the worktree and provisions nothing** — identical to a plain `git worktree add`.
|
|
78
|
+
|
|
79
|
+
```yaml
|
|
80
|
+
worktree:
|
|
81
|
+
dir: ../myrepo-worktrees # default: <repo-parent>/<repo>-worktrees
|
|
82
|
+
copy: [".env", ".env.*"] # gitignored files copied from the primary checkout
|
|
83
|
+
link: ["node_modules"] # symlinked (junctioned on Windows) from the primary checkout
|
|
84
|
+
setup: "npm ci" # run in the new worktree, with MOFLO_WORKTREE_INDEX in its env
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
| Key | Use it when | Watch out for |
|
|
88
|
+
|-----|-------------|---------------|
|
|
89
|
+
| `copy` | A fresh checkout is missing gitignored config a build needs | It relocates **secrets** outside the repo and outside its `.gitignore`. Sources must live inside the primary checkout; `../secrets` is rejected. |
|
|
90
|
+
| `link` | `node_modules` is large and the project does not use npm workspaces | A symlinked root `node_modules` is fragile under npm/yarn workspaces — prefer `setup: npm ci` there. An existing destination is never clobbered. |
|
|
91
|
+
| `setup` | Install/build steps must run per workspace | A non-zero exit marks the provision failed but leaves the worktree in place. |
|
|
92
|
+
|
|
93
|
+
**Port collisions.** moflo cannot rewrite a project's hardcoded ports. It gives each worktree a
|
|
94
|
+
small stable integer — unique among live worktrees, reused when one is removed — as
|
|
95
|
+
`MOFLO_WORKTREE_INDEX` in the `setup` command's environment. Offset your own ports from it:
|
|
96
|
+
|
|
97
|
+
```yaml
|
|
98
|
+
worktree:
|
|
99
|
+
setup: "npm ci && node -e \"require('fs').writeFileSync('.env.local','PORT='+(3500+Number(process.env.MOFLO_WORKTREE_INDEX)*20))\""
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Memory needs no setup: durable learnings already converge across a repo's worktrees
|
|
103
|
+
automatically. See `moflo-cross-install-memory-sharing.md` for the snapshot recipe that also
|
|
104
|
+
skips the structural cold-start.
|
|
105
|
+
|
|
62
106
|
## Available Agents
|
|
63
107
|
|
|
64
108
|
The shipped agent roster — each is invoked via the `Agent` tool with `subagent_type: <name>`. The canonical handle is the `name:` frontmatter inside `.claude/agents/**/*.md` (filename may differ from agent name). Aspirational agents that never shipped were retired — `retired-files.json` enforces auto-prune on consumer upgrade.
|
|
@@ -138,3 +138,4 @@ A foreign-writer warning from the Writers Audit check (`flo doctor -c writers`)
|
|
|
138
138
|
- `.claude/guidance/moflo-memory-strategy.md` — Namespaces, RAG indexing, and the durable-vs-derived split this doc builds on
|
|
139
139
|
- `.claude/guidance/moflo-memory-protocol.md` — Search-and-traverse protocol for the shared `learnings` once it is populated
|
|
140
140
|
- `.claude/guidance/moflo-core-guidance.md` — CLI, daemon, and `moflo.yaml` reference (the `memory` config block)
|
|
141
|
+
- `.claude/guidance/moflo-cli-reference.md` — `flo worktree`, which provisions a new worktree's gitignored files, `node_modules`, and per-workspace port index (memory sharing needs no setup; the rest of the workspace does)
|
|
@@ -106,30 +106,64 @@ implementation, tests, simplify, commit, and PR from inside it. The current chec
|
|
|
106
106
|
untouched. Durable learnings still converge automatically — a worktree shares the repo's
|
|
107
107
|
`<git-common-dir>/moflo/durable.db` (see `/memory-worktree`).
|
|
108
108
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
109
|
+
Use `flo worktree add`. It computes the path, creates the branch off the repo's default
|
|
110
|
+
branch, and **provisions** the tree per the optional `worktree:` block in `moflo.yaml` — copying
|
|
111
|
+
gitignored `.env` material, linking `node_modules`, running a `setup` command. A fresh worktree is
|
|
112
|
+
otherwise a valid checkout and an unrunnable workspace. Do not hand-roll the path or shell
|
|
113
|
+
`git worktree add` directly: the path computation and the copy/link/setup steps are
|
|
114
|
+
platform-sensitive (Rule #1) and live in tested code.
|
|
112
115
|
|
|
113
116
|
```bash
|
|
114
|
-
|
|
115
|
-
|
|
117
|
+
cd "<repo-root>" && flo worktree add "<type>/<issue-number>-<short-desc>" --json
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Bind the repo root to the call. `flo worktree` resolves the repo from its working directory, and in
|
|
121
|
+
Claude Code that resets between calls — run it from the wrong place and it silently targets a
|
|
122
|
+
different repository (`Not a registered worktree of this repo` on the good day, the wrong repo's
|
|
123
|
+
worktree on the bad one).
|
|
116
124
|
|
|
117
|
-
|
|
118
|
-
# Slug the branch's "/" to "-" so the dir is flat and valid on Windows/macOS/Linux.
|
|
119
|
-
node -e "const p=require('path'),cp=require('child_process');const root=cp.execSync('git rev-parse --show-toplevel').toString().trim();const branch=process.argv[1];const slug=branch.replace(/[\\\\/]/g,'-');const dir=p.join(p.dirname(root),p.basename(root)+'-worktrees',slug);console.log(dir)" "<type>/<issue-number>-<short-desc>"
|
|
125
|
+
It prints one JSON object — read `path` from it:
|
|
120
126
|
|
|
121
|
-
|
|
122
|
-
|
|
127
|
+
```json
|
|
128
|
+
{"path":"/abs/path/to/repo-worktrees/type-123-slug","branch":"type/123-slug","index":0,"provisioned":true}
|
|
123
129
|
```
|
|
124
130
|
|
|
125
|
-
Then
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
131
|
+
Then run **every** remaining phase (implement → tests → simplify → commit → PR) against that
|
|
132
|
+
`path`, and report it to the user.
|
|
133
|
+
|
|
134
|
+
**A bare `cd` does not stick.** In Claude Code the Bash working directory resets to the project root
|
|
135
|
+
after each call, so `cd <path>` in one call and `npm test` in the next runs the test in the WRONG
|
|
136
|
+
tree — the primary checkout — and everything looks fine until the PR contains no changes. Bind the
|
|
137
|
+
directory to each command instead:
|
|
138
|
+
|
|
139
|
+
- shell commands — put the `cd` in the *same* call: `cd "<path>" && npm test`
|
|
140
|
+
- git — prefer `git -C "<path>" status` over cd'ing at all
|
|
141
|
+
- file edits — use the absolute path under `<path>`; never a repo-relative one
|
|
142
|
+
|
|
143
|
+
**Fallback — `flo worktree` not available.** The command ships in the same package as this skill,
|
|
144
|
+
so normally they move together. They can still drift apart: a `flo` binary on PATH older than the
|
|
145
|
+
synced `.claude/skills/`, or a moflo source checkout whose change has not been published and
|
|
146
|
+
reinstalled yet. If the command errors with `Unknown command: worktree`, do NOT stop — create the
|
|
147
|
+
worktree the plain way and continue the run, noting to the user that provisioning was skipped:
|
|
148
|
+
|
|
149
|
+
```bash
|
|
150
|
+
git fetch origin
|
|
151
|
+
node -e "const p=require('path'),cp=require('child_process');const root=cp.execSync('git rev-parse --show-toplevel').toString().trim();const branch=process.argv[1];const dir=p.join(p.dirname(root),p.basename(root)+'-worktrees',branch.replace(/[\\/]/g,'-'));console.log(dir)" "<type>/<issue-number>-<short-desc>"
|
|
152
|
+
git worktree add -b "<type>/<issue-number>-<short-desc>" "<computed-path>" origin/main
|
|
153
|
+
```
|
|
129
154
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
155
|
+
The tree is then a valid checkout with no `node_modules` and no gitignored `.env` files — fine for a
|
|
156
|
+
typecheck-only ticket, and not for one that runs the app.
|
|
157
|
+
|
|
158
|
+
Notes:
|
|
159
|
+
- `--from <ref>` overrides the base ref (default: the repo's default branch via `origin/HEAD`).
|
|
160
|
+
- `provisioned: false` means a copy/link/setup step failed — the worktree is still a usable
|
|
161
|
+
checkout. Surface the failing step to the user rather than silently continuing to run tests that
|
|
162
|
+
will fail for want of a dependency.
|
|
163
|
+
- If the branch's worktree already exists (a prior run), `add` reuses it rather than deleting it.
|
|
164
|
+
- Leave the worktree in place after the PR — the user may want to inspect it. Clean up with
|
|
165
|
+
`flo worktree remove "<branch>"` (it refuses a tree with uncommitted changes unless `--force`).
|
|
166
|
+
`flo worktree list` shows every worktree and its provisioning state.
|
|
133
167
|
|
|
134
168
|
### 3.3 Implement
|
|
135
169
|
Follow the plan from the ticket.
|
package/README.md
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<p align="center">
|
|
2
|
-
<img src="https://raw.githubusercontent.com/eric-cielo/moflo/main/docs/
|
|
2
|
+
<img src="https://raw.githubusercontent.com/eric-cielo/moflo/main/docs/Moflo_wide.png?v=7" alt="MoFlo" />
|
|
3
3
|
</p>
|
|
4
4
|
|
|
5
5
|
# MoFlo
|
|
@@ -349,6 +349,89 @@ Inside Claude Code, the `/flo` (or `/fl`) slash command drives GitHub issue exec
|
|
|
349
349
|
|
|
350
350
|
Flags compose: e.g. `/flo -sd -m <issue>` runs the SDD cycle and auto-merges. Each modifier has a `--no-*` form (`--no-sdd`, `--no-verify`, `--no-merge`) to override a `moflo.yaml` default for a single run — including `--no-verify`, since verify-before-done is on by default. For full options and details, type `/flo` with no arguments — Claude Code will display the complete skill documentation. Also available as `/fl`.
|
|
351
351
|
|
|
352
|
+
### Provisioned worktrees (`-w` / `flo worktree`)
|
|
353
|
+
|
|
354
|
+
Running two tickets at once means two worktrees, and a bare `git worktree add` gives you a valid
|
|
355
|
+
checkout that you cannot actually run: no `node_modules`, none of your gitignored `.env` files, and
|
|
356
|
+
dev servers that fight the primary checkout over the same ports. `/flo -w` drives `flo worktree add`,
|
|
357
|
+
which creates the worktree **and** provisions it.
|
|
358
|
+
|
|
359
|
+
Provisioning is opt-in per project. **With no `worktree:` block in `moflo.yaml`, `flo worktree add`
|
|
360
|
+
creates the worktree and provisions nothing** — exactly what a plain `git worktree add` would do.
|
|
361
|
+
|
|
362
|
+
```yaml
|
|
363
|
+
worktree:
|
|
364
|
+
dir: ../myrepo-worktrees # default: <repo-parent>/<repo>-worktrees
|
|
365
|
+
copy: [".env", ".env.*"] # gitignored files copied from the primary checkout
|
|
366
|
+
link: ["node_modules"] # symlinked (junctioned on Windows) from the primary checkout
|
|
367
|
+
setup: "npm ci" # run inside the new worktree after copy/link
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
| Key | Reach for it when | Watch out for |
|
|
371
|
+
|-----|-------------------|---------------|
|
|
372
|
+
| `copy` | A fresh checkout is missing gitignored config your build needs | It relocates **secrets** outside the repo, and outside its `.gitignore`. Sources must live inside the primary checkout — `../secrets` is refused. |
|
|
373
|
+
| `link` | `node_modules` is large and you are not using npm workspaces | A symlinked root `node_modules` is fragile under npm/yarn workspaces — prefer `setup: npm ci` there. An existing path is never clobbered. |
|
|
374
|
+
| `setup` | Install or build steps must run per workspace | A non-zero exit marks the provision failed but leaves the worktree in place. |
|
|
375
|
+
|
|
376
|
+
**Ports.** MoFlo cannot rewrite your hardcoded ports — it has no way to know which files hold them.
|
|
377
|
+
Instead each worktree gets a small integer, unique among live worktrees and reused when one is
|
|
378
|
+
removed, exported to the `setup` command as `MOFLO_WORKTREE_INDEX`. Offset your own ports from it:
|
|
379
|
+
|
|
380
|
+
```yaml
|
|
381
|
+
worktree:
|
|
382
|
+
setup: "npm ci && node -e \"require('fs').writeFileSync('.env.local','PORT='+(3000+Number(process.env.MOFLO_WORKTREE_INDEX)*20))\""
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
`flo worktree remove` refuses a tree with uncommitted changes unless you pass `--force`, and names
|
|
386
|
+
any gitignored files it discarded that provisioning did not create. MoFlo's own
|
|
387
|
+
`.moflo/worktree.json` never counts as "uncommitted work" — otherwise every worktree it created
|
|
388
|
+
would demand `--force` — but un-pushed specs under `.moflo/specs/` do.
|
|
389
|
+
|
|
390
|
+
Memory needs no setup at all: durable learnings already converge across a repo's worktrees
|
|
391
|
+
automatically — see [Sharing learnings across installations](#sharing-learnings-across-installations).
|
|
392
|
+
|
|
393
|
+
#### Running it inside a Claude session
|
|
394
|
+
|
|
395
|
+
`flo worktree` is a plain CLI command with no MCP wrapper, so it works the same whether you type it
|
|
396
|
+
in a terminal or Claude runs it through Bash. `/flo -w` takes the second path — that is all the flag
|
|
397
|
+
does.
|
|
398
|
+
|
|
399
|
+
One thing to know if you drive it yourself from a session, and the reason `/flo -w` handles it for
|
|
400
|
+
you: **Claude Code resets the Bash working directory to the project root after every call.** Two
|
|
401
|
+
consequences, both silent when you get them wrong:
|
|
402
|
+
|
|
403
|
+
```bash
|
|
404
|
+
# ✗ the cd is gone by the next call — this resolves the WRONG repo
|
|
405
|
+
cd path/to/repo
|
|
406
|
+
flo worktree add feature/42-thing --json
|
|
407
|
+
|
|
408
|
+
# ✓ bind the directory to the call
|
|
409
|
+
cd path/to/repo && flo worktree add feature/42-thing --json
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
```bash
|
|
413
|
+
# ✗ runs the tests in the PRIMARY checkout; the run goes green and the PR is empty
|
|
414
|
+
cd "$WORKTREE"
|
|
415
|
+
npm test
|
|
416
|
+
|
|
417
|
+
# ✓ bind it, or skip cd entirely where the tool supports it
|
|
418
|
+
cd "$WORKTREE" && npm test
|
|
419
|
+
git -C "$WORKTREE" status
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
`flo worktree` resolves the repository from its working directory, so a call made from the wrong
|
|
423
|
+
place targets a different repository — usually reported as `Not a registered worktree of this repo`,
|
|
424
|
+
occasionally as work landing somewhere you did not intend. When editing files in the worktree, use
|
|
425
|
+
absolute paths under the path `add` printed rather than repo-relative ones.
|
|
426
|
+
|
|
427
|
+
Read the worktree location from `--json` rather than reconstructing it; the sibling-directory
|
|
428
|
+
convention lives in one tested function, and a second copy of that rule will drift from it.
|
|
429
|
+
|
|
430
|
+
If you are on a `flo` older than the skills synced into `.claude/` — which happens in a MoFlo source
|
|
431
|
+
checkout before a change is published, or when a global `flo` lags the project's devDependency —
|
|
432
|
+
the command reports `Unknown command: worktree`. `/flo -w` falls back to a plain `git worktree add`
|
|
433
|
+
and tells you provisioning was skipped, rather than failing the run.
|
|
434
|
+
|
|
352
435
|
### Spec-Driven Development (SDD)
|
|
353
436
|
|
|
354
437
|
`/flo` can run the full **spec → plan → (review) → implement → verify** cycle — the 2026 agentic-coding pattern — with two independent modifiers. Turning SDD on is opt-in, but once a run is armed for it (via `-sd` or `sdd.default`) **both halves are enforced**: the front half by an implement gate, the back half by verify-before-done.
|
|
@@ -637,6 +720,17 @@ flo gate prompt-reminder # Context bracket tracking
|
|
|
637
720
|
flo gate session-reset # Reset gate state
|
|
638
721
|
```
|
|
639
722
|
|
|
723
|
+
### Worktrees
|
|
724
|
+
|
|
725
|
+
```bash
|
|
726
|
+
flo worktree add feature/123-thing # Create a worktree and provision it (alias: flo wt)
|
|
727
|
+
flo worktree add feature/123 --json # {"path":…,"branch":…,"index":0,"provisioned":true}
|
|
728
|
+
flo worktree add feature/123 --from v2.1.0 # Branch off a specific ref
|
|
729
|
+
flo worktree add feature/123 --no-provision # Create it, skip copy/link/setup
|
|
730
|
+
flo worktree list # Every worktree and its provisioning state
|
|
731
|
+
flo worktree remove feature/123-thing # Refuses a dirty tree unless --force
|
|
732
|
+
```
|
|
733
|
+
|
|
640
734
|
### Diagnostics
|
|
641
735
|
|
|
642
736
|
```bash
|
|
@@ -68,6 +68,11 @@ const commandLoaders = {
|
|
|
68
68
|
epic: () => import('./epic.js'),
|
|
69
69
|
// Spec-Driven Development artifacts (Epic #1269)
|
|
70
70
|
sdd: () => import('./sdd.js'),
|
|
71
|
+
worktree: () => import('./worktree.js'),
|
|
72
|
+
// Alias key, not just `aliases: ['wt']` on the command: lazy commands resolve
|
|
73
|
+
// through `commandLoaders` by name, and an alias declared only on the command
|
|
74
|
+
// object is unreachable until something has already loaded it.
|
|
75
|
+
wt: () => import('./worktree.js'),
|
|
71
76
|
// GitHub Repository Setup
|
|
72
77
|
github: () => import('./github.js'),
|
|
73
78
|
// /flo run ledger + per-run token rollup (#1333).
|
|
@@ -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
|
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Worktree provisioning (#1481).
|
|
3
|
+
*
|
|
4
|
+
* `/flo -wt` and `flo worktree add` create a git worktree; on its own that is a
|
|
5
|
+
* valid checkout and an unrunnable workspace — no `node_modules`, none of the
|
|
6
|
+
* gitignored `.env` files, and no notion that a second worktree's dev servers
|
|
7
|
+
* will collide with the first on fixed ports. This service closes that gap,
|
|
8
|
+
* driven by the optional `worktree:` block in `moflo.yaml`.
|
|
9
|
+
*
|
|
10
|
+
* Every platform-sensitive decision lives here rather than in the command, so
|
|
11
|
+
* the Windows-vs-POSIX branches are reachable from a unit test (Rule #1):
|
|
12
|
+
* - paths built with `path.*`, never separator concatenation
|
|
13
|
+
* - directory links are junctions on Windows (no admin/developer mode needed,
|
|
14
|
+
* and the target must be absolute), plain symlinks on POSIX
|
|
15
|
+
* - copies via `fs.cpSync`; no `cp`/`ln -s`/`mkdir -p`/`find` shell-outs
|
|
16
|
+
* - `setup` runs through a shell on both platforms (it is a user-authored
|
|
17
|
+
* command string, not an argv array) — see `runSetup`
|
|
18
|
+
* - containment checks realpath BOTH sides before comparing (#1145: macOS
|
|
19
|
+
* `/var/folders` vs `/private/var/folders` otherwise false-positives)
|
|
20
|
+
*/
|
|
21
|
+
import { spawnSync } from 'node:child_process';
|
|
22
|
+
import { cpSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, symlinkSync, } from 'node:fs';
|
|
23
|
+
import path from 'node:path';
|
|
24
|
+
import { atomicWriteFileSync } from '../shared/utils/atomic-file-write.js';
|
|
25
|
+
import { globToRegExp } from '../guidance/retriever.js';
|
|
26
|
+
/** Relative path of the per-worktree state file, from the worktree root. */
|
|
27
|
+
export const WORKTREE_STATE_FILE = path.join('.moflo', 'worktree.json');
|
|
28
|
+
/**
|
|
29
|
+
* The same path in git's spelling. `git status --porcelain` reports forward
|
|
30
|
+
* slashes on every platform, so a caller comparing against porcelain output
|
|
31
|
+
* needs this form rather than the host-separator one above.
|
|
32
|
+
*/
|
|
33
|
+
export const WORKTREE_STATE_FILE_POSIX = '.moflo/worktree.json';
|
|
34
|
+
/**
|
|
35
|
+
* Turn a branch name into a single flat directory name. Both separators are
|
|
36
|
+
* replaced: a branch is always `/`-delimited, but a caller may hand us a
|
|
37
|
+
* Windows-style string, and `\` is invalid in an NTFS directory name anyway.
|
|
38
|
+
*/
|
|
39
|
+
export function slugifyBranch(branch) {
|
|
40
|
+
return branch.replace(/[\\/]/g, '-');
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Resolve where a worktree for `branch` belongs.
|
|
44
|
+
*
|
|
45
|
+
* Default: `<repo-parent>/<repo-basename>-worktrees/<slugged-branch>` — a
|
|
46
|
+
* sibling of the checkout, so it is never inside the repo (and so never picked
|
|
47
|
+
* up by the repo's own tooling). `configuredDir` overrides the parent directory
|
|
48
|
+
* and is resolved relative to `repoRoot` when relative.
|
|
49
|
+
*/
|
|
50
|
+
export function computeWorktreePath(repoRoot, branch, configuredDir) {
|
|
51
|
+
const parent = configuredDir
|
|
52
|
+
? path.resolve(repoRoot, configuredDir)
|
|
53
|
+
: path.join(path.dirname(repoRoot), `${path.basename(repoRoot)}-worktrees`);
|
|
54
|
+
return path.join(parent, slugifyBranch(branch));
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Smallest non-negative integer not already taken. Reusing a freed slot (rather
|
|
58
|
+
* than incrementing a counter) keeps the index small and stable, which matters
|
|
59
|
+
* because consumers offset fixed ports from it — an unbounded counter would
|
|
60
|
+
* eventually push a derived port out of range.
|
|
61
|
+
*/
|
|
62
|
+
export function allocateIndex(existing) {
|
|
63
|
+
const taken = new Set(existing.filter(n => Number.isInteger(n) && n >= 0));
|
|
64
|
+
let candidate = 0;
|
|
65
|
+
while (taken.has(candidate))
|
|
66
|
+
candidate++;
|
|
67
|
+
return candidate;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Resolve a path as far as it exists. `realpathSync` throws on a missing path,
|
|
71
|
+
* but a containment check must still work for a destination that has not been
|
|
72
|
+
* created yet — so walk up to the nearest existing ancestor, resolve that, and
|
|
73
|
+
* re-append the remainder.
|
|
74
|
+
*/
|
|
75
|
+
function realpathBestEffort(target) {
|
|
76
|
+
let current = path.resolve(target);
|
|
77
|
+
const trailing = [];
|
|
78
|
+
// Bounded by the path depth: each iteration removes one segment, and
|
|
79
|
+
// `path.dirname` is a fixed point at the filesystem root.
|
|
80
|
+
for (;;) {
|
|
81
|
+
if (existsSync(current))
|
|
82
|
+
return path.join(realpathSync(current), ...trailing.reverse());
|
|
83
|
+
const parent = path.dirname(current);
|
|
84
|
+
if (parent === current)
|
|
85
|
+
return path.resolve(target);
|
|
86
|
+
trailing.push(path.basename(current));
|
|
87
|
+
current = parent;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* A canonical key for path identity: realpath'd, and case-folded on the two
|
|
92
|
+
* platforms whose filesystems are case-insensitive by default.
|
|
93
|
+
*
|
|
94
|
+
* Exported so a caller scanning a list resolves its needle ONCE and compares
|
|
95
|
+
* keys, rather than realpathing both sides on every iteration. Case-folding is
|
|
96
|
+
* what makes plain string equality safe here — `C:\Repo` and `C:\repo` are the
|
|
97
|
+
* same directory on Windows, and `/Users/x/Repo` and `/Users/x/repo` are the
|
|
98
|
+
* same directory on stock APFS.
|
|
99
|
+
*/
|
|
100
|
+
export function resolveForCompare(target) {
|
|
101
|
+
const resolved = realpathBestEffort(target);
|
|
102
|
+
return process.platform === 'win32' || process.platform === 'darwin'
|
|
103
|
+
? resolved.toLowerCase()
|
|
104
|
+
: resolved;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Is `candidate` inside `root`? Both sides go through
|
|
108
|
+
* {@link resolveForCompare} first — the #1145 shape, where an unresolved
|
|
109
|
+
* `/var/folders/...` compared against a resolved `/private/var/folders/...` on
|
|
110
|
+
* macOS made two identical paths look different.
|
|
111
|
+
*/
|
|
112
|
+
export function isInside(root, candidate) {
|
|
113
|
+
const resolvedRoot = resolveForCompare(root);
|
|
114
|
+
const resolvedCandidate = resolveForCompare(candidate);
|
|
115
|
+
if (resolvedCandidate === resolvedRoot)
|
|
116
|
+
return true;
|
|
117
|
+
const rel = path.relative(resolvedRoot, resolvedCandidate);
|
|
118
|
+
// No `rel.length > 0` guard: `path.relative` returns '' for two spellings of
|
|
119
|
+
// the SAME directory that the equality check above missed, and treating that
|
|
120
|
+
// as "not inside" would break every identity test built on this.
|
|
121
|
+
return !rel.startsWith('..') && !path.isAbsolute(rel);
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Expand one `copy:` entry against the primary checkout.
|
|
125
|
+
*
|
|
126
|
+
* Glob support is deliberately narrow — a single `*` in the final segment, so
|
|
127
|
+
* `.env.*` works — because the alternative is either a shell-out (`find` does
|
|
128
|
+
* not exist on Windows) or a full tree walk on a pattern the user thought was
|
|
129
|
+
* cheap. A pattern needing more than this returns nothing rather than silently
|
|
130
|
+
* matching a subset; the caller reports it as skipped.
|
|
131
|
+
*/
|
|
132
|
+
function expandCopyEntry(primaryRoot, entry) {
|
|
133
|
+
const normalized = entry.split(/[\\/]/).filter(Boolean);
|
|
134
|
+
if (normalized.length === 0)
|
|
135
|
+
return [];
|
|
136
|
+
const last = normalized[normalized.length - 1];
|
|
137
|
+
if (!last.includes('*')) {
|
|
138
|
+
const full = path.join(primaryRoot, ...normalized);
|
|
139
|
+
return existsSync(full) ? [full] : [];
|
|
140
|
+
}
|
|
141
|
+
const dir = path.join(primaryRoot, ...normalized.slice(0, -1));
|
|
142
|
+
if (!existsSync(dir))
|
|
143
|
+
return [];
|
|
144
|
+
// Reuse the guidance retriever's translator rather than hand-rolling one: it
|
|
145
|
+
// is anchored, escapes every metacharacter, and was already hardened for the
|
|
146
|
+
// `docs/*.md` matching `docsXmd` bug. Its `*` becomes `[^/]*`, which is exact
|
|
147
|
+
// here because these patterns match bare readdir NAMES, never a path.
|
|
148
|
+
const pattern = globToRegExp(last);
|
|
149
|
+
return readdirSync(dir)
|
|
150
|
+
.filter(name => pattern.test(name))
|
|
151
|
+
.sort()
|
|
152
|
+
.map(name => path.join(dir, name));
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Does a config entry escape `root` before any symlink resolution?
|
|
156
|
+
*
|
|
157
|
+
* Deliberately LEXICAL, unlike {@link isInside}. "Did the user write a path that
|
|
158
|
+
* climbs out of the tree" is a property of the string they wrote, and resolving
|
|
159
|
+
* it first gets the answer wrong in the normal case: on a re-add, the worktree's
|
|
160
|
+
* `node_modules` is already a symlink into the primary checkout, so realpathing
|
|
161
|
+
* the destination reports it as outside the worktree and rejects a link that is
|
|
162
|
+
* exactly the one provisioning just made. A primary checkout whose own
|
|
163
|
+
* `node_modules` is a symlink (pnpm, a shared store) fails the same way.
|
|
164
|
+
*/
|
|
165
|
+
function escapesRoot(root, entry) {
|
|
166
|
+
const rel = path.relative(root, path.resolve(root, entry));
|
|
167
|
+
return rel.startsWith('..') || path.isAbsolute(rel);
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Copy the configured gitignored material into the new worktree.
|
|
171
|
+
*
|
|
172
|
+
* Sources are guarded on both ends: each must resolve inside the primary
|
|
173
|
+
* checkout (so `../secrets` is rejected), and the destination is always the
|
|
174
|
+
* worktree we just created. A missing source is skipped rather than fatal —
|
|
175
|
+
* `.env.local` legitimately does not exist on every machine.
|
|
176
|
+
*/
|
|
177
|
+
function runCopy(primaryRoot, worktreePath, entries) {
|
|
178
|
+
const steps = [];
|
|
179
|
+
for (const entry of entries) {
|
|
180
|
+
if (!isInside(primaryRoot, path.resolve(primaryRoot, entry))) {
|
|
181
|
+
steps.push({
|
|
182
|
+
kind: 'copy',
|
|
183
|
+
target: entry,
|
|
184
|
+
status: 'failed',
|
|
185
|
+
detail: 'resolves outside the primary checkout',
|
|
186
|
+
});
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
const matches = expandCopyEntry(primaryRoot, entry);
|
|
190
|
+
if (matches.length === 0) {
|
|
191
|
+
steps.push({ kind: 'copy', target: entry, status: 'skipped', detail: 'no match' });
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
for (const source of matches) {
|
|
195
|
+
const dest = path.join(worktreePath, path.relative(primaryRoot, source));
|
|
196
|
+
try {
|
|
197
|
+
mkdirSync(path.dirname(dest), { recursive: true });
|
|
198
|
+
cpSync(source, dest, { recursive: true });
|
|
199
|
+
steps.push({ kind: 'copy', target: path.relative(primaryRoot, source), status: 'done' });
|
|
200
|
+
}
|
|
201
|
+
catch (error) {
|
|
202
|
+
steps.push({
|
|
203
|
+
kind: 'copy',
|
|
204
|
+
target: path.relative(primaryRoot, source),
|
|
205
|
+
status: 'failed',
|
|
206
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return steps;
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* The `fs.symlinkSync` type argument for a directory link on this platform.
|
|
215
|
+
*
|
|
216
|
+
* Windows gets a junction: unlike a `'dir'` symlink it needs no admin rights or
|
|
217
|
+
* developer mode. Exported and pure so the Windows branch is assertable from a
|
|
218
|
+
* unit test on any host — the alternative, spying on an ESM `fs` export, is not
|
|
219
|
+
* possible, and gating the assertion on a Windows runner would leave the branch
|
|
220
|
+
* unverified on the two CI legs that run most often.
|
|
221
|
+
*/
|
|
222
|
+
export function linkTypeForPlatform(platform) {
|
|
223
|
+
return platform === 'win32' ? 'junction' : undefined;
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Link the configured paths from the primary checkout into the new worktree.
|
|
227
|
+
* The target is resolved to an ABSOLUTE path before the call on every platform:
|
|
228
|
+
* a relative target silently produces a broken junction on Windows.
|
|
229
|
+
*/
|
|
230
|
+
function runLink(primaryRoot, worktreePath, entries) {
|
|
231
|
+
const steps = [];
|
|
232
|
+
const linkType = linkTypeForPlatform(process.platform);
|
|
233
|
+
for (const entry of entries) {
|
|
234
|
+
const source = path.resolve(primaryRoot, entry);
|
|
235
|
+
const dest = path.resolve(worktreePath, entry);
|
|
236
|
+
// Guard BOTH ends against an escaping entry: `link: ["../x"]` would
|
|
237
|
+
// otherwise source from outside the checkout and write the link outside the
|
|
238
|
+
// worktree. Lexical on purpose — see escapesRoot().
|
|
239
|
+
if (escapesRoot(primaryRoot, entry) || escapesRoot(worktreePath, entry)) {
|
|
240
|
+
steps.push({
|
|
241
|
+
kind: 'link',
|
|
242
|
+
target: entry,
|
|
243
|
+
status: 'failed',
|
|
244
|
+
detail: 'resolves outside the primary checkout or the worktree',
|
|
245
|
+
});
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
if (!existsSync(source)) {
|
|
249
|
+
steps.push({ kind: 'link', target: entry, status: 'skipped', detail: 'no such path' });
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
// lstat, not existsSync: a broken symlink left by an earlier run still
|
|
253
|
+
// occupies the name, and clobbering it is not ours to decide.
|
|
254
|
+
let occupied = false;
|
|
255
|
+
try {
|
|
256
|
+
lstatSync(dest);
|
|
257
|
+
occupied = true;
|
|
258
|
+
}
|
|
259
|
+
catch {
|
|
260
|
+
occupied = false;
|
|
261
|
+
}
|
|
262
|
+
if (occupied) {
|
|
263
|
+
steps.push({ kind: 'link', target: entry, status: 'skipped', detail: 'already exists' });
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
try {
|
|
267
|
+
mkdirSync(path.dirname(dest), { recursive: true });
|
|
268
|
+
symlinkSync(source, dest, linkType);
|
|
269
|
+
steps.push({ kind: 'link', target: entry, status: 'done' });
|
|
270
|
+
}
|
|
271
|
+
catch (error) {
|
|
272
|
+
steps.push({
|
|
273
|
+
kind: 'link',
|
|
274
|
+
target: entry,
|
|
275
|
+
status: 'failed',
|
|
276
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
return steps;
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Run the configured `setup` command inside the new worktree.
|
|
284
|
+
*
|
|
285
|
+
* `shell: true` on BOTH platforms, unlike the daemon-spawning code this repo
|
|
286
|
+
* models elsewhere. That rule ("shell on Windows, detached on POSIX") is about
|
|
287
|
+
* spawning a known binary with an argv array; `setup` is a user-authored shell
|
|
288
|
+
* command *string* from `moflo.yaml` — `npm ci && npm run build` is a legitimate
|
|
289
|
+
* value, and it needs `cmd.exe` or `/bin/sh` to mean anything. Running it
|
|
290
|
+
* without a shell would exec a file literally named `npm ci && npm run build`.
|
|
291
|
+
* On Windows a shell is required regardless, since `npm` there is `npm.cmd`.
|
|
292
|
+
* Trust boundary: the same as a `package.json` script — the project's own config.
|
|
293
|
+
*
|
|
294
|
+
* `jsonMode` sends the child's stdout to our stderr so a `--json` caller still
|
|
295
|
+
* gets parseable JSON on stdout.
|
|
296
|
+
*/
|
|
297
|
+
function runSetup(worktreePath, command, index, jsonMode) {
|
|
298
|
+
const result = spawnSync(command, {
|
|
299
|
+
cwd: worktreePath,
|
|
300
|
+
shell: true,
|
|
301
|
+
stdio: jsonMode ? ['ignore', 2, 2] : 'inherit',
|
|
302
|
+
env: { ...process.env, MOFLO_WORKTREE_INDEX: String(index) },
|
|
303
|
+
});
|
|
304
|
+
if (result.error) {
|
|
305
|
+
return { kind: 'setup', target: command, status: 'failed', detail: result.error.message };
|
|
306
|
+
}
|
|
307
|
+
if (result.status !== 0) {
|
|
308
|
+
return {
|
|
309
|
+
kind: 'setup',
|
|
310
|
+
target: command,
|
|
311
|
+
status: 'failed',
|
|
312
|
+
detail: `exited with code ${result.status ?? 'null'}`,
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
return { kind: 'setup', target: command, status: 'done' };
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* Provision a freshly created worktree: copy, then link, then setup.
|
|
319
|
+
*
|
|
320
|
+
* Order matters — `setup` (typically `npm ci`) may depend on the `.env` files
|
|
321
|
+
* `copy` brings in, and must not race the `link` that would otherwise supply
|
|
322
|
+
* `node_modules`. A failed step never unwinds the worktree: it is a valid
|
|
323
|
+
* checkout either way, and deleting a tree the user may have started working in
|
|
324
|
+
* is far worse than leaving it under-provisioned.
|
|
325
|
+
*/
|
|
326
|
+
export function provisionWorktree(opts) {
|
|
327
|
+
const { primaryRoot, worktreePath, branch, index, config, jsonMode = false } = opts;
|
|
328
|
+
const steps = [];
|
|
329
|
+
if (config?.copy?.length)
|
|
330
|
+
steps.push(...runCopy(primaryRoot, worktreePath, config.copy));
|
|
331
|
+
if (config?.link?.length)
|
|
332
|
+
steps.push(...runLink(primaryRoot, worktreePath, config.link));
|
|
333
|
+
if (config?.setup)
|
|
334
|
+
steps.push(runSetup(worktreePath, config.setup, index, jsonMode));
|
|
335
|
+
const provisioned = steps.every(step => step.status !== 'failed');
|
|
336
|
+
writeWorktreeState(worktreePath, { branch, index, primaryRoot, provisioned });
|
|
337
|
+
return { provisioned, steps };
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* Did `copy:`/`link:` put this worktree-relative path there?
|
|
341
|
+
*
|
|
342
|
+
* Lives here, next to {@link provisionWorktree}, because answering it needs the
|
|
343
|
+
* same glob translation provisioning used: a raw config entry is not a filename.
|
|
344
|
+
* Comparing `.env.*` literally against the `.env.local` git reports never
|
|
345
|
+
* matches, which would blame provisioning's own files on the user.
|
|
346
|
+
*
|
|
347
|
+
* Every ancestor prefix is tested, so a directory entry (glob or literal) also
|
|
348
|
+
* claims the files inside it — `git status -uall` reports those individually.
|
|
349
|
+
*
|
|
350
|
+
* @param target a path in git's spelling (forward slashes), relative to the worktree
|
|
351
|
+
*/
|
|
352
|
+
export function isProvisionedPath(target, config) {
|
|
353
|
+
const entries = [...(config?.copy ?? []), ...(config?.link ?? [])]
|
|
354
|
+
.map(entry => entry.split(/[\\/]/).filter(Boolean).join('/'))
|
|
355
|
+
.filter(Boolean);
|
|
356
|
+
if (entries.length === 0)
|
|
357
|
+
return false;
|
|
358
|
+
const segments = target.split('/').filter(Boolean);
|
|
359
|
+
for (const entry of entries) {
|
|
360
|
+
const matcher = entry.includes('*') ? globToRegExp(entry) : null;
|
|
361
|
+
for (let depth = 1; depth <= segments.length; depth++) {
|
|
362
|
+
const prefix = segments.slice(0, depth).join('/');
|
|
363
|
+
if (matcher ? matcher.test(prefix) : prefix === entry)
|
|
364
|
+
return true;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
return false;
|
|
368
|
+
}
|
|
369
|
+
/** Write `.moflo/worktree.json` into a worktree. Atomic — the daemon may read it. */
|
|
370
|
+
export function writeWorktreeState(worktreePath, state) {
|
|
371
|
+
const statePath = path.join(worktreePath, WORKTREE_STATE_FILE);
|
|
372
|
+
mkdirSync(path.dirname(statePath), { recursive: true });
|
|
373
|
+
atomicWriteFileSync(statePath, `${JSON.stringify(state, null, 2)}\n`);
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* Read a worktree's moflo state. `null` means moflo did not create this tree —
|
|
377
|
+
* which is exactly how `flo worktree list` reports an externally-created
|
|
378
|
+
* worktree as unprovisioned, so a malformed file is treated the same as a
|
|
379
|
+
* missing one rather than failing the listing.
|
|
380
|
+
*/
|
|
381
|
+
export function readWorktreeState(worktreePath) {
|
|
382
|
+
const statePath = path.join(worktreePath, WORKTREE_STATE_FILE);
|
|
383
|
+
if (!existsSync(statePath))
|
|
384
|
+
return null;
|
|
385
|
+
try {
|
|
386
|
+
const parsed = JSON.parse(readFileSync(statePath, 'utf8'));
|
|
387
|
+
if (typeof parsed.branch !== 'string' || !Number.isInteger(parsed.index))
|
|
388
|
+
return null;
|
|
389
|
+
return {
|
|
390
|
+
branch: parsed.branch,
|
|
391
|
+
index: parsed.index,
|
|
392
|
+
primaryRoot: typeof parsed.primaryRoot === 'string' ? parsed.primaryRoot : '',
|
|
393
|
+
provisioned: parsed.provisioned === true,
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
catch {
|
|
397
|
+
return null;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
//# sourceMappingURL=worktree-provision.js.map
|
package/dist/src/cli/version.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "moflo",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.13.0",
|
|
4
4
|
"description": "MoFlo — AI agent orchestration for Claude Code. A standalone, opinionated toolkit with semantic memory, learned routing, gates, spells, and the /flo issue-execution skill.",
|
|
5
5
|
"main": "dist/src/cli/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -99,7 +99,7 @@
|
|
|
99
99
|
"@typescript-eslint/parser": "^8.65.0",
|
|
100
100
|
"eslint": "^10.8.0",
|
|
101
101
|
"glob": "^11.1.0",
|
|
102
|
-
"moflo": "^4.12.
|
|
102
|
+
"moflo": "^4.12.12",
|
|
103
103
|
"tsx": "^4.21.0",
|
|
104
104
|
"typescript": "^5.9.3",
|
|
105
105
|
"vitest": "^4.0.0"
|