Browse Source

feat(git-ops): default new-lane to in-repo .claude/worktrees/

Sibling worktrees (<repo>-<slug>) scattered dirs across the parent and
diverged from Claude Code's native worktree location. Default new-lane to
in-repo .claude/worktrees/<slug>, made safe-by-construction:

- new-lane.sh: in-repo default, anchored at the MAIN worktree so it never
  nests inside a lane; ensures .claude/worktrees/ is gitignored first (so
  `git add -A` can't stage its gitlinks — the 2026-04-19 incident); keeps
  --sibling as the structural-isolation opt-out.
- worktree-guard.sh: warn on `git clean -ff` / `--force --force` in a repo
  with .claude/worktrees/. Single `-f` safely skips nested worktrees; only a
  double force deletes them (taking uncommitted lane work). Closes the one
  footgun the sibling layout used to guard against.
- worktree-boundaries.md / git-ops SKILL.md: document the in-repo default,
  the gitignore precondition, and that committed lane work survives directory
  deletion (recover via `git worktree add lane/<slug>`) — only uncommitted
  work is destructible, so land early/often.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
0xDarkMatter 1 week ago
parent
commit
4fd0dfa0f5
4 changed files with 93 additions and 21 deletions
  1. 8 0
      hooks/worktree-guard.sh
  2. 12 1
      rules/worktree-boundaries.md
  3. 14 4
      skills/git-ops/SKILL.md
  4. 59 16
      skills/git-ops/scripts/new-lane.sh

+ 8 - 0
hooks/worktree-guard.sh

@@ -13,6 +13,10 @@
 #   3. git worktree prune  — when the cwd's repo has a .claude/worktrees dir
 #   4. git rm targeting .claude/worktrees paths
 #   5. git add -A / --all / .  — when cwd has a .claude/worktrees dir
+#   6. git clean -ff (double-force) — when cwd has a .claude/worktrees dir.
+#      Single `-f` git clean SKIPS nested worktrees ("Skipping repository …");
+#      only a DOUBLE force (`-ff` / `--force --force`) deletes them — taking any
+#      UNCOMMITTED lane work with it (committed work survives in the object store).
 #
 # On (3) and (5) we use directory existence ([ -d cwd/.claude/worktrees ]) as
 # the cheap proxy. True gitlink detection would need `git status --porcelain`
@@ -66,6 +70,10 @@ elif printf '%s' "$CMD" | grep -qE "\brm\b[^;|&]*$WT"; then
 elif printf '%s' "$CMD" | grep -qE '\bgit\b.*\badd[[:space:]]+([^;|&]*[[:space:]])?(-A|--all|\.)([[:space:]]|$|;)' \
      && [[ -d "$CWD/.claude/worktrees" ]]; then
   VIOLATION="git add -A/. in a repo with .claude/worktrees (may stage worktree gitlinks)"
+elif printf '%s' "$CMD" | grep -qE '\bgit\b[^;|&]*\bclean\b' \
+     && printf '%s' "$CMD" | grep -qE '(-[a-eg-z]*f[a-eg-z]*f|--force[^;|&]*--force)' \
+     && [[ -d "$CWD/.claude/worktrees" ]]; then
+  VIOLATION="git clean -ff (double-force) in a repo with .claude/worktrees (force-removes live lanes + their uncommitted work)"
 fi
 
 [[ -z "$VIOLATION" ]] && exit 0   # clean → silent

+ 12 - 1
rules/worktree-boundaries.md

@@ -11,6 +11,7 @@ Never touch `.claude/worktrees/` in any repo. Never touch git worktrees, submodu
 - Do not `rm -rf .claude/worktrees/` in any repo
 - Do not `git rm` or `git rm --cached` worktree entries
 - Do not stage deletions of worktree dirs via `git add -A` (this is the subtle one — `-A` sweeps up changes you didn't intend)
+- Do not `git clean -ff` / `--force --force` in a repo with `.claude/worktrees/` — a *single* `-f` git clean safely skips nested worktrees ("Skipping repository …"), but a **double** force deletes them, taking any uncommitted lane work with it
 - Do not commit changes that reference `.claude/worktrees/` paths
 - Do not reason about whether a worktree is "orphaned" unless the owning project explicitly asks
 
@@ -69,7 +70,17 @@ Corollaries:
   integration tree. Land parallel branches through [fleet-ops](../skills/fleet-ops/SKILL.md) (the
   manual, test-gated landing queue).
 - **Land early, land often.** Bound divergence by integrating green lanes continuously, not as one
-  big-bang merge at the end — at 10+ lanes that's what bites.
+  big-bang merge at the end — at 10+ lanes that's what bites. It's also your real safety net:
+  **committed lane work lives in the shared object store and survives even deletion of the worktree
+  directory** (recover with `git worktree add <path> lane/<slug>`); only *uncommitted* work is
+  destructible. So commit-often is what makes a lane's work durable.
+- **Lane location:** default **in-repo** at `<main>/.claude/worktrees/<slug>` — tidy (no sibling dirs
+  scattered across the parent), native to Claude Code's own worktrees, and gitignored so `git add -A`
+  can't stage its gitlinks. This is only safe *because* `.claude/worktrees/` is gitignored, so a tool
+  placing a lane there must ensure that ignore first (`new-lane.sh` does). Use a **sibling**
+  (`<repo>-<slug>`, outside the repo — `new-lane.sh --sibling`) only when you need structural
+  isolation from repo-scoped destructive ops (`git clean -ff`, `rm -rf <repo>`) or in a repo that
+  can't gitignore the dir.
 - **Lane naming:** `lane/<slug>` (and the native `claude/<slug>`) so the backlog is legible.
 - **A branch name does not reveal isolation** — `claude/eager-wozniak` looks identical whether it's a
   worktree-isolated background agent or a session in your main checkout. Isolation is a structural

+ 14 - 4
skills/git-ops/SKILL.md

@@ -360,7 +360,7 @@ Worktrees are first-class in this skill. The classification is:
 | Op | Tier | How |
 |----|------|-----|
 | **Survey** | T1 | `bash scripts/worktree-survey.sh` — read-only, reports per-worktree state + drift |
-| **New lane** | T2 (inline) | `bash scripts/new-lane.sh <slug> [base]` — fast scripted provisioning: branch `lane/<slug>` in a **sibling** worktree `<repo>-<slug>` (outside `.claude/`) + carries over gitignored env files. The one-command collision remedy — see "Lane provisioning" below |
+| **New lane** | T2 (inline) | `bash scripts/new-lane.sh [--sibling] <slug> [base]` — fast scripted provisioning: branch `lane/<slug>` **in-repo** at `.claude/worktrees/<slug>` (gitignored; `--sibling` for an outside `<repo>-<slug>` instead) + carries over gitignored env files. The one-command collision remedy — see "Lane provisioning" below |
 | **Create (bespoke)** | T2 | `git worktree add <path> -b <branch>` via agent — for non-standard layouts the script doesn't cover |
 | **Land** | T2 | Rebase worktree branch onto trunk + test + fast-forward. Multi-step procedure — see "Worktree Land Procedure" below |
 | **Prune (clean)** | T2 | `git worktree prune` for ghost entries (registered but FS-missing). Always safe, no data loss possible |
@@ -372,15 +372,25 @@ Worktrees are first-class in this skill. The classification is:
 work — the remedy the peer-writer guards (`session-start-unicode-scan.sh` at boot,
 `pre-write-peer-guard.sh` mid-session) point you to. It:
 
-- creates branch `lane/<slug>` in a **sibling** worktree `<repo>/../<repo>-<slug>` — outside
-  `.claude/` so headless agents can write there (see `rules/worktree-boundaries.md`) — off
-  `[base-branch]` (default: current branch);
+- creates branch `lane/<slug>` **in-repo** at `<main>/.claude/worktrees/<slug>` — the native
+  Claude Code worktree location: tidy (no sibling dirs scattered across the parent) and gitignored
+  so `git add -A` can't stage its gitlinks — off `[base-branch]` (default: current branch);
+- **ensures the gitignore precondition**: if `.claude/worktrees/` isn't gitignored it adds the entry
+  first (the in-repo location is only safe when ignored), so the default is safe in *any* repo;
+- **`--sibling`** places it outside the repo at `<repo>/../<repo>-<slug>` instead — use when you need
+  structural isolation from repo-scoped destructive ops (`git clean -ff`, `rm -rf <repo>`) or in a
+  repo that can't gitignore the dir;
+- anchors at the **main** worktree root, so invoking it from inside a lane won't nest worktrees;
 - **carries over gitignored env files** (`.dev.vars`, `.env*`, `.secrets`) the fresh worktree
   would otherwise lack, so the lane runs immediately;
 - prints the worktree path on stdout (everything else on stderr), so it composes:
   `cd "$(bash scripts/new-lane.sh hotfix main)"`;
 - refuses if the branch or path already exists — never clobbers.
 
+Lane work durability: **committed** lane work lives in the shared object store and survives even
+deletion of the worktree dir (recover via `git worktree add <path> lane/<slug>`); only *uncommitted*
+work is at risk from `git clean -ff` / `rm -rf`. Land early/often — see `rules/worktree-boundaries.md`.
+
 Run it **inline** (deterministic, non-destructive); land the lane back via the Worktree Land
 Procedure below or `fleet-ops`. Reach for it whenever two sessions would otherwise share one checkout.
 

+ 59 - 16
skills/git-ops/scripts/new-lane.sh

@@ -4,43 +4,86 @@
 # guard fires, or any time you want to parallelise without two sessions sharing one checkout.
 # Part of the git-ops skill (Worktree Operations → "Lane provisioning").
 #
-# Usage:   new-lane.sh <slug> [base-branch]
-#   <slug>        short kebab name -> branch lane/<slug>, worktree <repo>/../<repo>-<slug>
+# Usage:   new-lane.sh [--sibling] <slug> [base-branch]
+#   <slug>        short kebab name -> branch lane/<slug>
 #   [base-branch] what to branch from (default: the current branch)
+#   --sibling     place the worktree OUTSIDE the repo at <repo>/../<repo>-<slug>
+#                 (structural isolation; use for a repo that can't gitignore
+#                  .claude/worktrees/, or just before destructive cleanups)
+#
+# Default is IN-REPO: <main>/.claude/worktrees/<slug> — the native Claude Code worktree
+# location: tidy (no sibling dirs littering the parent), and gitignored so `git add -A`
+# can't stage its gitlinks (the precondition is verified/ensured below). Committed lane work
+# lives in the shared object store and survives even directory deletion; only UNCOMMITTED work
+# is at risk from `git clean -ff` / `rm -rf`, so land early/often. See rules/worktree-boundaries.md.
 #
 # Output contract (SKILL-RESOURCE-PROTOCOL): the new worktree's absolute path is the ONLY thing on
 # stdout (so a caller can `cd "$(new-lane.sh foo)"`); all human-facing messages go to stderr.
 # Exit: 0 ok | 2 usage/precondition | 1 conflict (branch/path exists).
 #
 # Examples:
-#   new-lane.sh auth-refactor            # lane/auth-refactor off the current branch
-#   new-lane.sh hotfix main              # lane/hotfix off main
+#   new-lane.sh auth-refactor            # lane/auth-refactor in .claude/worktrees/auth-refactor
+#   new-lane.sh hotfix main              # lane/hotfix off main, in-repo
+#   new-lane.sh --sibling big-migration  # lane/big-migration in ../<repo>-big-migration
 set -euo pipefail
 
-case "${1:-}" in
-   -h|--help|"")
-    sed -n '2,19p' "$0" | sed 's/^# \{0,1\}//' >&2
-    [ -z "${1:-}" ] && exit 2 || exit 0 ;;
-esac
+show_help() { sed -n '2,27p' "$0" | sed 's/^# \{0,1\}//' >&2; }
+
+SIBLING=0
+ARGS=()
+for a in "$@"; do
+  case "$a" in
+    --sibling) SIBLING=1 ;;
+    -h|--help) show_help; exit 0 ;;
+    --) : ;;
+    *) ARGS+=("$a") ;;
+  esac
+done
+set -- ${ARGS[@]+"${ARGS[@]}"}
+
+[ -n "${1:-}" ] || { show_help; exit 2; }
 
 SLUG=$(printf '%s' "$1" | tr '[:upper:] ' '[:lower:]-' | tr -cd 'a-z0-9-')
 [ -n "$SLUG" ] || { echo "new-lane: slug empty after sanitising" >&2; exit 2; }
 
 ROOT=$(git rev-parse --show-toplevel 2>/dev/null) || { echo "new-lane: not inside a git repo" >&2; exit 2; }
-REPO=$(basename "$ROOT")
+
+# Anchor at the MAIN worktree root so a lane never nests inside a linked worktree
+# (running from .claude/worktrees/<x> would otherwise create worktrees-in-worktrees).
+COMMON=$(git -C "$ROOT" rev-parse --git-common-dir 2>/dev/null || echo "$ROOT/.git")
+case "$COMMON" in
+  /*|[A-Za-z]:[\\/]*) ;;             # already absolute
+  *) COMMON="$ROOT/$COMMON" ;;       # relative → resolve under ROOT
+esac
+MAIN=$(cd "$(dirname "$COMMON")" 2>/dev/null && pwd) || MAIN="$ROOT"
+
+REPO=$(basename "$MAIN")
 BRANCH="lane/$SLUG"
-BASE="${2:-$(git -C "$ROOT" rev-parse --abbrev-ref HEAD 2>/dev/null)}"
-WT="$(dirname "$ROOT")/${REPO}-${SLUG}"
+BASE="${2:-$(git -C "$MAIN" rev-parse --abbrev-ref HEAD 2>/dev/null)}"
+
+if [ "$SIBLING" -eq 1 ]; then
+  WT="$(dirname "$MAIN")/${REPO}-${SLUG}"
+else
+  # In-repo placement is only safe when the lane dir is gitignored — otherwise
+  # `git add -A` from the main checkout stages worktree gitlinks (the 2026-04-19
+  # incident). Ensure the ignore so the in-repo default is safe in ANY repo, not
+  # just one someone already tidied.
+  if ! git -C "$MAIN" check-ignore -q ".claude/worktrees/.probe" 2>/dev/null; then
+    printf '\n# git worktrees / lanes — never tracked (new-lane.sh)\n.claude/worktrees/\n' >> "$MAIN/.gitignore"
+    echo "new-lane: '.claude/worktrees/' was not gitignored — added it to .gitignore (commit this)" >&2
+  fi
+  WT="$MAIN/.claude/worktrees/$SLUG"
+fi
 
-git -C "$ROOT" show-ref --verify --quiet "refs/heads/$BRANCH" && { echo "new-lane: branch $BRANCH already exists" >&2; exit 1; }
+git -C "$MAIN" show-ref --verify --quiet "refs/heads/$BRANCH" && { echo "new-lane: branch $BRANCH already exists" >&2; exit 1; }
 [ -e "$WT" ] && { echo "new-lane: path $WT already exists" >&2; exit 1; }
 
-git -C "$ROOT" worktree add "$WT" -b "$BRANCH" "$BASE" >&2
+git -C "$MAIN" worktree add "$WT" -b "$BRANCH" "$BASE" >&2
 
 # Carry over gitignored env/secret files the new worktree won't have (Cloudflare/Node/etc.).
 for f in .dev.vars .env .env.local .env.development .secrets; do
-  if [ -e "$ROOT/$f" ] && [ ! -e "$WT/$f" ]; then
-    cp -r "$ROOT/$f" "$WT/$f" 2>/dev/null && echo "new-lane: carried over $f" >&2
+  if [ -e "$MAIN/$f" ] && [ ! -e "$WT/$f" ]; then
+    cp -r "$MAIN/$f" "$WT/$f" 2>/dev/null && echo "new-lane: carried over $f" >&2
   fi
 done