Worktrees that work

Worktrees that work

MoFloย now better provisions git worktrees: it creates the tree, then copies in your gitignored config, links or installs your dependencies, and hands each tree a stable index so the dev servers don’t fight over ports. You can drive it from your shell withย flo worktree, or from inside Claude Code withย /flo -wt. Same code underneath.



The problem it solves

You know this one.ย git worktree addย gives you a checkout in about a second, and then nothing runs in it:

$ git worktree add ../app-review feature/some-pr
$ cd ../app-review
$ npm run dev
Error: Cannot find module 'vite'

Three things are missing, and they’re missing for the same reason โ€” they’re gitignored, so a fresh checkout has never seen them:

  • node_modulesย โ€” empty tree, and a few minutes of install before you can do anything.
  • .env,ย .env.localย โ€” the local config your build reads.
  • A free port. Your first tree already ownsย 3000; the second one just fails to bind.

So you do it by hand: copy the env files, symlink or reinstall node_modules, hunt down the port. Every time, in the same order, and you get it slightly wrong when you’re in a hurry. That’s the whole feature โ€” it does those steps for you, from a config block you write once.

What it buys you in practice: worktrees become cheap enough to actually use. Spin one up to review a PR without stashing, run a long test suite in one tree while you keep editing in another, or let an agent work a ticket in an isolated tree while your own checkout stays exactly where you left it.


Two ways to run it

Both entry points call the same provisioning code and read the same config. Pick based on where you already are:

Outside ClaudeInside Claude Code
Commandflo worktree add <branch>/flo -wt <issue>
You getA provisioned worktree. You go work in it.A provisioned worktree and the ticket done in it โ€” branch, commits, PR.
Use it whenYou’re driving. Also scripts, Makefiles, CI.You’re handing off a ticket and want your checkout left alone.

They’re not alternatives to each other so much as the same tool at two altitudes โ€” one shells out to the other.


Option 1 โ€” flo worktree, from your shell

Three subcommands. The alias is wt if you’re typing it a lot.

$ flo worktree add feature/123-thing
Worktree: /home/you/app-worktrees/feature-123-thing
Branch:   feature/123-thing
Index:    0
Provisioning:
  โœ“ copy .env
  โœ“ copy .env.local
  โœ“ link node_modules
  โœ“ setup npm ci
Remove with: flo worktree remove feature/123-thing

$ cd /home/you/app-worktrees/feature-123-thing && npm run dev   # works

Steps are marked โœ“ done, ยท skipped, or โœ— failed, with the reason appended โ€” a missing .env.local is a skip, not an error, because it legitimately doesn’t exist on every machine. --from <ref> picks the base ref if you don’t want the repo default; --no-provision gets you the bare tree.

The tree lands at <repo-parent>/<repo>-worktrees/<branch-slug> by default โ€” a sibling of your checkout, not a subdirectory of it. Nesting it inside the repo means your own tooling finds it: test globs match it, the linter walks it, the file watcher fires on it, and you lose an afternoon working out why the suite suddenly runs everything twice.

Scripting it? --json gives you one object on stdout:

$ flo worktree add feature/123 --json
{"path":"/home/you/app-worktrees/feature-123","branch":"feature/123","index":0,"provisioned":true,"steps":[โ€ฆ]}

Note provisioned โ€” it’s false if any step failed, and the failure detail is in the steps array. The worktree still exists either way; more on that below.

The other two:

$ flo worktree list
  main  [primary]
    /home/you/app
  feature/123-thing  [provisioned #0]
    /home/you/app-worktrees/feature-123-thing
  experiment  [unmanaged]
    /home/you/scratch

$ flo worktree remove feature/123-thing

That last entry is a worktree you made yourself with plain git worktree add. It shows up, it’s tagged unmanaged, and nothing breaks โ€” moflo tracks its own trees with a small .moflo/worktree.json in each one, and treats “no such file” as the answer rather than an error. A tree moflo made but couldn’t fully provision reads unprovisioned #<n>, so you can tell the two apart.

What remove refuses to do

remove won’t delete a tree with uncommitted work unless you pass --force. The part worth knowing is what it doesn’t count as work: its own .moflo/worktree.json, and anything copy: or link: put there. Those are ignored files matched back against the config entries that created them, globs included โ€” so a copy: [".env.*"] correctly claims the .env.local that git status reports.

Skip that and every tree the tool itself produced needs --force to remove. Which teaches you to type --force without reading, and then the guard isn’t guarding anything. A check that always fires is worse than no check.


Option 2 โ€” /flo -wt, from inside Claude Code

/flo is moflo’s ticket runner: hand it an issue number and it does the analysis, branch, implementation, tests, commit and PR. Adding -wt moves all of that into a fresh worktree.

/flo -wt 1481

Concretely, that changes one thing: the branch is created in a new provisioned worktree, and every phase after it โ€” implement, test, simplify, commit, push, PR โ€” runs against that path instead of your checkout. Your working tree keeps whatever half-finished thing you had open. When it’s done you get the worktree path back, so you can go look at it.

The flag is orthogonal to the rest of the skill’s flags: it changes where the work happens, not what runs. /flo -sd -s -wt 42 runs the spec-driven cycle, in a swarm, in a worktree. It’s ignored (with a note) for --epic-branch, where the epic orchestrator owns branch layout, and in research and ticket-only modes, which never touch a branch at all.

It’s a wrapper, not a reimplementation

Under the hood the skill runs the same command you’d run:

cd "<repo-root>" && flo worktree add "feature/1481-worktrees" --json

โ€ฆand reads path out of the JSON. That’s deliberate, and it’s the reason the skill is explicitly told not to hand-roll the path or shell git worktree add directly: path computation and the copy/link/setup steps are platform-sensitive, and they live in tested code. A second, subtly-different implementation inside a Markdown skill file is how you end up shipping a Windows bug nobody can reproduce.


The config both paths read

Provisioning is driven by an optionalย worktree:ย block inย moflo.yaml. Worth being explicit:ย with no block,ย addย provisions nothingย โ€” it’s a plainย git worktree addย with a state file. Nothing gets copied, linked, or executed until you ask for it. Ask Claude to help you set it up.

worktree:
  dir: ../myrepo-worktrees   # default: <repo-parent>/<repo>-worktrees
  copy: [".env", ".env.*"]   # gitignored files copied from the primary checkout
  link: ["node_modules"]     # symlinked (junctioned on Windows) from the primary checkout
  setup: "npm ci"            # run in the new worktree, with MOFLO_WORKTREE_INDEX in its env
KeyUse it whenWatch out for
copyA fresh checkout is missing gitignored config a build needsIt relocates secrets outside the repo and outside its .gitignore. Sources must live inside the primary checkout; ../secrets is rejected.
linknode_modules is large and the project doesn’t use npm workspacesA symlinked root node_modules is fragile under npm/yarn workspaces โ€” use setup: npm ci there instead. An existing destination is never clobbered.
setupInstall or build steps have to run per workspaceA non-zero exit marks the provision failed but leaves the worktree in place.

Steps run copy โ†’ link โ†’ setup, in that order, and the order matters. setup is usually npm ci, which may read the .env files copy brings in, and shouldn’t race the link that’s about to drop node_modules underneath it.

If a step fails, the worktree stays. It’s a valid checkout regardless, and deleting a directory you might already be working in is a much worse failure mode than leaving it half-provisioned. You’ll see it in list as not provisioned, fix the step, and re-run.

Ports

MoFlo can’t rewrite your hardcoded ports โ€” it doesn’t know which of your files means “dev server” and shouldn’t be editing them anyway. What it can give you is the one thing you can’t compute yourself: a small integer, unique across your live worktrees, in MOFLO_WORKTREE_INDEX when setup runs. Offset from it:

worktree:
  setup: "npm ci && node -e \"require('fs').writeFileSync('.env.local','PORT='+(3500+Number(process.env.MOFLO_WORKTREE_INDEX)*20))\""

The index is the smallest free non-negative integer, so removing a tree frees its slot for reuse. That’s not neatness โ€” a monotonic counter would eventually push 3500 + index * 20 past 65535, and you’d hit it weeks later, in someone else’s worktree, where it looks like anything but a counter.


Implementation notes, if you’re going to rely on this

MoFlo installs into projects that build on Windows, macOS, and Linux, so the platform branches all live in one service rather than being sprinkled through the command:

  • Windows gets junctions, not symlinks.ย A directory junction needs no admin rights and no developer mode; aย 'dir'ย symlink needs one of them. Link targets are resolved to absolute paths first on every platform โ€” a relative target silently produces aย brokenย junction on Windows.
  • No shell-outs for file work.ย Copies go throughย fs.cpSync. There’s noย cp,ย ln -s,ย mkdir -p, orย findย anywhere on this path, because none of them are on a stock Windows box.
  • Path comparisons realpath both sides, and case-fold on Windows and macOS. Otherwise macOS hands youย /var/folders/โ€ฆย on one side andย /private/var/folders/โ€ฆย on the other โ€” same directory, compares unequal. We’ve paid for that one already.
  • setupย runs through a shell on both platforms.ย It’s a user-authored commandย string, not an argv array โ€”ย npm ci && npm run buildย is a legitimate value, and without a shell you’d exec a file literally named that. Windows needs a shell regardless, sinceย npmย there isย npm.cmd. Trust boundary is the same as aย package.jsonย script: your own config.
  • Glob support inย copyย is narrowย โ€” oneย *ย in the last segment, soย .env.*ย works. Anything more matches nothing and is reported skipped, rather than silently matching a subset. The alternatives were shelling out toย findย or walking your whole tree for a pattern you assumed was cheap.

On the guards: copy sources must resolve inside the primary checkout, and the destination is always the new worktree. link is checked lexically โ€” on the string you wrote, before resolving symlinks โ€” which looks sloppy until you hit the case it exists for. On a re-add, the worktree’s node_modules is already a symlink into the primary checkout; resolve first and the check calls the destination “outside the worktree” and rejects the exact link provisioning just made. Same failure if your primary checkout uses pnpm or a shared store. “Did the user write a path that climbs out of the tree” is a property of the string, so it’s answered on the string.


One thing you don’t have to configure

MoFlo’s durable learnings already converge across a repo’s worktrees automatically. Linked worktrees share a .git common directory, so each one derives the same store path and meets the others there โ€” a new tree starts knowing what the others figured out. Structural data (code map, guidance chunks, test index) stays local and rebuilds itself, since it’s specific to that branch’s code and sharing it would just serve you stale search results. Details are in the memory-sharing writeup.


Getting started

npm install --save-dev moflo
npx flo init

# add a worktree: block to moflo.yaml, then either:
npx flo worktree add feature/my-branch     # you drive
# or, in Claude Code:
/flo -wt 1481                       # hand off the ticket

Leave a Reply

Your email address will not be published. Required fields are marked *