Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 104 additions & 1 deletion skills/worktree-isolation/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
name: worktree-isolation
category: core
description: Use when starting feature work that needs isolation from the current workspace, or before executing implementation plans - ensures an isolated workspace exists via native tools or git worktree fallback.
description: Use when starting feature work that needs isolation from the current workspace, or before executing implementation plans - ensures an isolated workspace exists via native tools or git worktree fallback, and binds multi-repo feature worktrees into an explicit named group.
---

# Git Worktree Isolation
Expand Down Expand Up @@ -143,6 +143,106 @@ npm test / cargo test / pytest / go test ./...
Worktree ready at <path>. Tests: <N> passing, 0 failures.
```

## Multi-Repo Feature Groups

Worktree isolation covers ONE repo. A feature that spans several repos - a
backend, an AI service, and a frontend, each in its own repository - needs one
worktree per repo, all on the same logical feature. Those worktrees are peers,
but git has no concept that binds them: `git worktree list` in the backend repo
cannot see the frontend's worktree.

For a tool-agnostic adoption recipe (registry schema, port-slot formula,
run-script generation, and reusable snippets), see
`references/multi-repo-feature-groups.md`.

**Bind them explicitly. Do not match on branch names.** Real feature branches
drift per repo (`feat/x-integration` in the backend vs `feat/x-attribution-ui`
in the frontend), so auto-matching by branch is unreliable and produces false
positives. Declare membership instead.

### The registry pattern

Keep a small append-only registry mapping each member worktree to a shared group
name and a role:

```
<group> <role> <worktree-toplevel-abspath>
```

- **group** - the feature, identical across all members (e.g. `checkout-redesign`).
- **role** - which repo this tree is (`be`, `ai`, `ui`, ...), derived from the
repo and overridable.
- **toplevel** - absolute path from `git rev-parse --show-toplevel`, so a lookup
succeeds from any subdirectory of the worktree.

A worktree "is in group G" when its toplevel appears under G. Membership is a
single file lookup - no cross-repo git calls, cheap enough to render on a
statusline hot path.

### Binding workflow

1. Create or enter the per-repo worktree (Steps 0-1 above).
2. Append `group + role + toplevel` to the registry.
3. Repeat in each repo's worktree using the SAME group name.

Membership is explicit and evolves. Add a member when the feature reaches a new
repo/surface; remove one (unbind from the registry, then keep or `git worktree
remove` the checkout) when that surface merges or is abandoned. A member is a
worktree holding part of THIS feature on its own branch - one per repo it
touches. The registry stays the single source of truth; never infer membership
from matching branch names.

Surface membership wherever git context is shown (prompt / statusline): the group
plus each member as `role` and only the part of its branch that distinguishes it.
Strip each branch's `type/` prefix and the shared group token, and collapse a
member whose branch is exactly the group name to role-only - so a group on branch
`feat/checkout` with `feat/checkout` (be) and `feat/checkout-mobile` (ui) renders
`⑂checkout[‹be›·ui:mobile]`, current member emphasized. Read each branch straight
from its `.git/HEAD` file (a linked worktree's `.git` is a file pointing at its
real gitdir) - a plain file read, not a `git` subprocess, so it stays cheap. Live
per-sibling dirty/ahead status is the expensive part: add it only behind a short
cache, since it costs one git invocation per member per render.

### Why explicit over heuristic

| approach | binds | cost | verdict |
|---|---|---|---|
| exact branch match | only byte-identical branch names | zero config | misses drifted names |
| branch-token heuristic | fuzzy prefix/suffix strip | zero config | false positives, fragile |
| **explicit registry** | **exactly what you declare** | **one line per member** | **correct, recommended** |

### Running the group (ports + OAuth)

Runtime hazards when several members run at once:

- **Config not carried by the worktree.** Gitignored config (`.env`, local
secrets) is never created in a fresh worktree, but the services read it - so a
new member boots mis-configured until its config is seeded from the repo's
main checkout. Provide an idempotent seed step and run it before starting the
stack.
- **Port collisions.** Give each group a port slot and derive every service's
port from it (e.g. `base + 10*slot`). If a service hardcodes its port in
source instead of reading an env var, the slot must be applied on the launch
command (`--port`), not via an env file - verify which per service before
assuming an env file is enough.
- **OAuth redirect pinning.** OAuth `redirect_uri`s are pre-registered with the
provider at a fixed host:port. A member on any other port gets a redirect
mismatch and the flow dies. That registered port is a singleton - only one
member can hold it. So: generate a run script per worktree that port-swaps the
OAuth/base URLs to the slot's port, warn loudly on the non-registered slots,
and provide a "claim" command that moves a group onto the registered port when
its OAuth flows need testing.

**Working the whole set as one session.** To act on the group as a unit, provide
a launcher that opens in the group's primary worktree and grants read/write
access to the siblings (a multi-directory flag), rather than one session per
worktree. Run it in a subshell so the caller's working directory and environment
are untouched on exit. Keep any "active group" indicator ephemeral to the
session (a status row that is torn down on exit) - never mutate the user's shell
prompt or persist state that outlives the session. If the host runs interactive
pickers without a controlling terminal (many agent hooks do), the selector must
be a shell launcher, not a startup hook.

## Quick Reference

| Situation | Action |
Expand All @@ -159,6 +259,8 @@ Worktree ready at <path>. Tests: <N> passing, 0 failures.
| Permission error on create | Sandbox fallback, work in place |
| Baseline tests fail | Report failures, ask user |
| No package.json/Cargo.toml | Skip dependency install |
| Feature spans multiple repos | One worktree per repo, bind them in a group registry |
| Branch names differ per repo | Bind explicitly - never auto-match by branch |

## Cleanup

Expand All @@ -182,6 +284,7 @@ git worktree remove <path>
| "The directory doesn't need to be ignored" | One `git add .` and the worktree is in your repo. |
| "I'll use `git worktree add` - I have a native tool" | Phantom state the harness can't manage. Use Step 1a. |
| "I'll create a worktree - I'm already in one" | Nested isolation. Step 0 prevents this. |
| "Same feature across repos, I'll match branch names" | Names drift per repo. Bind explicitly in a group registry. |

## Integration

Expand Down
147 changes: 147 additions & 0 deletions skills/worktree-isolation/references/multi-repo-feature-groups.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
# Multi-Repo Feature Groups - adoption reference

A concrete, tool-agnostic recipe for the pattern described in `SKILL.md` under
"Multi-Repo Feature Groups". Read that section first for the why; this file is
the how, generic enough to drop into any stack.

## When you need it

A single feature spans several repositories - for example a backend, a
worker/AI service, and a frontend - each in its own repo with its own branch.
You want to create, run, track, and tear down that whole set as one unit. Git
worktrees isolate one repo; nothing binds worktrees across repos. This recipe
adds that binding with a small registry plus a set of verbs.

## The model

- A **group** is one feature.
- A **member** is one worktree per repo the feature touches, tagged with a
**role** (`be`, `ai`, `ui`, ... - your choice).
- A **registry** file is the single source of truth. Membership is always
explicit; never infer it from matching branch names, which drift per repo.

### Registry schema

One tab-separated line per member:

```
<group> <role> <worktree-abspath> <slot>
```

`slot` is optional (empty for hand-bound groups). The worktree path is the
absolute toplevel (`git rev-parse --show-toplevel`), so lookups work from any
subdirectory.

## Command surface

| verb | does |
|---|---|
| `new <group> <role...>` | create a worktree + feature branch per role, bind them, assign the next free slot, generate a run script each |
| `add <group> <role...>` | add member(s) to an existing group, reusing its slot |
| `bind <group> [role]` | fold the current worktree in, inheriting the group's slot |
| `env <group>` | seed gitignored config (`.env`, secrets) from each repo's main checkout into the worktrees |
| `up` / `down <group>` | run / stop every member's run script as one stack |
| `st <group>` | cross-repo status: branch, dirty, ahead/behind, port per member |
| `rm` | unbind the current worktree (keep the checkout) |
| `claim-oauth <group>` | move a group onto the OAuth-registered port (see below) |
| `go [group]` | open one agent/editor session across the whole set |

## Port slots (running several groups at once)

Give each group an integer `slot`. Derive every service's port from it so groups
never collide:

```
port(role, slot) = basePort[role] + 10 * slot
```

Slot 0 = your canonical ports. `nextFreeSlot = smallest N >= 0 not used by any group`.

## OAuth pinning (the sharp edge)

OAuth `redirect_uri`s are pre-registered with the provider at a fixed host:port.
A service on any other port gets a redirect mismatch and the flow fails. So the
registered port is a **singleton** - only the slot-0 group can complete OAuth.

Two mitigations, both in the generated run script:

1. Port-swap the OAuth/base URLs from the service's config to the slot's port,
so at slot 0 it is a no-op and at slot N it stays internally consistent.
2. A `claim-oauth` verb that moves a group to slot 0 (swapping out whoever held
it) when its OAuth flows need testing.

## Generated run scripts

Generate one run script per member so the whole stack starts with the right port
and config. Two cases:

- **Port is env-driven** (service reads `PORT`/`SERVER_PORT` from env or a
dotenv file): set it via an environment prefix on the launch command.
- **Port is hardcoded in source** (e.g. `uvicorn.run(port=8000)`, a bundler's
`server.port`): pass a launch flag (`--port`) instead - an env file will not
change it. Verify which per service; do not assume an env file is enough.

Point the frontend at the group's backend by exporting its API-base variable to
the group's BE port.

## Config not carried by the worktree

`git worktree add` checks out tracked files only. Gitignored config (`.env`,
local secrets) is never created in a fresh worktree, but services read it - so a
new member boots mis-configured until you seed its config from the repo's main
checkout. Make the seed step idempotent (skip existing files unless forced).

## Grouped session (working the set as one)

Open one agent/editor session in the group's primary worktree and grant it
read/write access to the siblings (a multi-directory flag), rather than one
session per worktree. Run the launcher in a subshell so the caller's working
directory and environment are untouched on exit, and keep any "active group"
indicator ephemeral (a status row torn down on exit) - never mutate the shell
prompt or persist state that outlives the session.

If your host runs interactive pickers without a controlling terminal (many agent
hooks do), the group selector must be a shell launcher, not a startup hook.

## What you fill in to adopt this

Everything above is generic. To port it, supply:

- **role -> repo** map (which main checkout each role's worktree comes from).
- **role -> base port** map, and the canonical OAuth port.
- **base branch** to fork from, and where worktrees live (a workroot).
- the **config filenames** to seed per role (root `.env`, per-app `.env`, ...).
- the **OAuth env var names** whose value is `host:port`.
- the **run command** per role (env-prefix vs `--port` flag).

## Reusable snippets

Read a worktree's branch with no `git` subprocess (cheap enough for a prompt):

```bash
head_branch() { # worktree-abspath
local wt=$1 gd line head
if [ -d "$wt/.git" ]; then gd="$wt/.git"
elif [ -f "$wt/.git" ]; then IFS= read -r line < "$wt/.git"; gd=${line#gitdir: }
case "$gd" in /*) : ;; *) gd="$wt/$gd" ;; esac
else printf '?'; return; fi
IFS= read -r head < "$gd/HEAD" 2>/dev/null || { printf '?'; return; }
case "$head" in
"ref: refs/heads/"*) printf '%s' "${head#ref: refs/heads/}" ;;
?*) printf '%s' "${head:0:7}" ;;
*) printf '?' ;;
esac
}
```

Port-swap OAuth URLs in a generated backend run script (env-prefix example):

```bash
PORT=8090; BASE=8080
g(){ grep -E "^$1=" .env 2>/dev/null | head -1 | cut -d= -f2-; }
export SERVER_PORT="$PORT"
for v in PUBLIC_BASE_URL OAUTH_REDIRECT_URL; do
cur="$(g "$v")"; [ -n "$cur" ] && export "$v=${cur/:$BASE/:$PORT}"
done
exec <your-backend-run-command>
```
Loading