From 37e3f2a9e9869f1d3a4a243b099ae800cf1aa641 Mon Sep 17 00:00:00 2001 From: KailasMahavarkar Date: Tue, 7 Jul 2026 19:23:42 +0530 Subject: [PATCH] docs(worktree-isolation): multi-repo feature-group pattern + adoption reference Extend the worktree-isolation skill from single-repo isolation to binding parallel worktrees of one feature across separate repos (backend / worker / FE). - Multi-Repo Feature Groups: explicit registry over branch-name heuristics, membership lifecycle (add/remove), running the set (port slots, OAuth redirect pinning, config-not-carried-by-worktree, one grouped session). - New references/multi-repo-feature-groups.md: tool-agnostic adoption recipe with the registry schema, port-slot formula, run-script generation, the OAuth-claim mechanism, and reusable bash snippets so anyone can port it. Co-Authored-By: Claude Opus 4.8 (1M context) --- skills/worktree-isolation/SKILL.md | 105 ++++++++++++- .../references/multi-repo-feature-groups.md | 147 ++++++++++++++++++ 2 files changed, 251 insertions(+), 1 deletion(-) create mode 100644 skills/worktree-isolation/references/multi-repo-feature-groups.md diff --git a/skills/worktree-isolation/SKILL.md b/skills/worktree-isolation/SKILL.md index 75473a3..ff39cb3 100644 --- a/skills/worktree-isolation/SKILL.md +++ b/skills/worktree-isolation/SKILL.md @@ -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 @@ -143,6 +143,106 @@ npm test / cargo test / pytest / go test ./... Worktree ready at . Tests: 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** - 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 | @@ -159,6 +259,8 @@ Worktree ready at . Tests: 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 @@ -182,6 +284,7 @@ git worktree remove | "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 diff --git a/skills/worktree-isolation/references/multi-repo-feature-groups.md b/skills/worktree-isolation/references/multi-repo-feature-groups.md new file mode 100644 index 0000000..1cdd227 --- /dev/null +++ b/skills/worktree-isolation/references/multi-repo-feature-groups.md @@ -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: + +``` + +``` + +`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 ` | create a worktree + feature branch per role, bind them, assign the next free slot, generate a run script each | +| `add ` | add member(s) to an existing group, reusing its slot | +| `bind [role]` | fold the current worktree in, inheriting the group's slot | +| `env ` | seed gitignored config (`.env`, secrets) from each repo's main checkout into the worktrees | +| `up` / `down ` | run / stop every member's run script as one stack | +| `st ` | cross-repo status: branch, dirty, ahead/behind, port per member | +| `rm` | unbind the current worktree (keep the checkout) | +| `claim-oauth ` | 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 +```