Skip to content

Running work in worktrees

mini-ork makes most of its changes to a repository, and concurrent agents make most of its mistakes. A worktree per task keeps each change on its own branch and its own directory, so two runs cannot edit the same file at the same time or leave one checkout in a half-merged state.

There are two worktree surfaces, and they are related but distinct:

  • Run workspaces — where a mini-ork run applies its edits.
  • Developer worktrees — where a human (or an agent acting as one) edits and merges the mini-ork repository itself.

Run workspaces

mini-ork run chooses where to apply changes:

mini-ork run code-fix ./kickoff.md --workspace worktree
mini-ork run code-fix ./kickoff.md --workspace in-place
Choice Behaviour
worktree The run gets a fresh mini-ork/<run> worktree; the main checkout is untouched
in-place Edits are applied to the current working tree

The default is policy-driven: worktree when the review policy requires approval before merge, in-place otherwise. Override the default with the flag above or with MO_WORKSPACE_MODE.

Use worktree mode when you are not watching

In-place is fine for a single run on a clean tree. For unattended or concurrent work, worktree mode gives you a directory to inspect and a clean checkout to fall back to.

A worktree run reports the path it used, so you can diff or inspect it before merging. Its verdict refers to the change in that worktree, not to main.

Developer worktrees

mini-ork keeps main clean: implementation work never happens in the primary checkout. The driver is scripts/mini_ork_worktree.py, wrapped by make:

make worktree SLUG=<slug>                 # create worktree + branch wt/<slug>
make worktree SLUG=<slug> OWNS="mini_ork/foo.py tests/bar"
make worktree-merge SLUG=<slug>           # rebase, gate, push HEAD:main
make worktree-clean SLUG=<slug>           # remove worktree + branch
make worktree-list                        # git worktree list

Under the hood:

scripts/mini_ork_worktree.py create <slug> [--owns <path>...] [--branch <name>]
scripts/mini_ork_worktree.py merge  [<slug>]
scripts/mini_ork_worktree.py clean  <slug>
scripts/mini_ork_worktree.py owners [--json]
scripts/mini_ork_worktree.py release <slug>
scripts/mini_ork_worktree.py list

Worktrees live under $MINI_ORK_WORKTREES_DIR (default: a sibling mini-ork-worktrees/ directory). Branches are wt/<slug>.

Ownership claims

--owns <path> claims a file surface in the project's ownership registry:

make worktree SLUG=docs-cli OWNS="docs/cli docs/guides"
  • Claims are path-prefix aware: claiming docs/cli covers everything under it.
  • Creation is refused when a new claim overlaps a live worktree's claim, so two agents cannot race the same file.
  • Claims are released by clean, by release <slug>, or when the worktree directory disappears.

Inspect current claims with:

make worktree-list
python3 scripts/mini_ork_worktree.py owners --json

One command is one critical section

create and merge both take a lock around their check-then-act pairs:

  • create: claim check + claim registration;
  • merge: fetch + rebase + push.

The fetch happens inside the lock, so a rebase never targets a ref cached before a long green gate. The green gate itself runs outside the lock, so a slow test suite does not block other worktree commands. The push is a bounded retry: MO_MERGE_PUSH_ATTEMPTS (default 5).

Merge discipline

This is the part that goes wrong. Follow it literally.

Never stage everything

git add -A        # NO
git add .         # NO

git add -A sweeps other sessions' work into your commit. Stage explicit paths, or git add <dir> for a directory you own end to end.

Assert the diff before committing

git diff --stat

If the diff contains files — especially deletions — that you never touched or authored, your base is stale. Rebase; do not merge.

After a push, refresh local main

git -C <repo> fetch origin main
git -C <repo> merge --ff-only origin/main

Local main is stale until you do. A later worktree created from it starts behind.

If --ff-only refuses, stop

A refused fast-forward means local main and origin/main diverged — someone pushed while you worked. Do not:

  • run a plain git merge origin/main and call it done;
  • git reset --hard;
  • push with --no-verify to get past it.

Reconcile so that every commit on both sides survives. A merge commit keeps all of them; a rebase rewrites them. Never rewrite a commit that is not yours.

On a failed smoke test, fix forward

Create a new worktree from the latest main and fix the defect there. Never revert main, never git reset --hard.

Green gate

merge runs a test command before pushing. By default it is the project suite; scope it per task with MINI_ORK_TEST_CMD:

MINI_ORK_TEST_CMD="python3.11 -m pytest -q tests/unit/test_docs_cli.py" \
  make worktree-merge SLUG=docs-cli

A scoped gate is faster, but it is not CI. Choose the scope so the files you touched are covered, and let CI run the broad suite after the push.

Two globs can double-resolve a file

Under pytest-asyncio, passing the same test file twice in one invocation (including via two overlapping globs where one is a subset of the other) fails at collection with RecursionError. Keep each path in exactly one glob per command.

Worktree lifecycle

flowchart LR
    C[create worktree<br/>claim files] --> E[edit + commit]
    E --> M[merge<br/>rebase origin/main]
    M --> G[green gate]
    G -- pass --> P[push HEAD:main]
    G -- fail --> F[fix in a new worktree]
    P --> R[ff-only refresh main]
    R --> X[clean worktree<br/>release claims]

A finished, verified change is shipped, not parked: commit, merge and push are part of the job, not an optional follow-up.

Cleaning up

make worktree-clean SLUG=<slug>

This removes the worktree directory, deletes the branch and releases the claims. If you need to drop claims without deleting anything:

scripts/mini_ork_worktree.py release <slug>

Next