Skip to content
Back to skills

Git Worktree

BSecurity

This skill should be used when managing Git worktrees for isolated parallel development. It handles creating, listing, switching, and cleaning up worktrees with a simple interactive interface.

  • 16 stars
  • 0 votes
  • 0 copies
  • 3 views
  • Added May 27, 2026
data-aigoshellbashnoderailsdockerterraformgitperformance

Works with

  • claude code
  • terminal
  • cli

Security analysis

B77/100
  • highPerforms destructive filesystem operations
  • mediumInstalls packages at runtime which could introduce malicious dependencies

Pro scans all 4 files and shows the line behind each finding

Scanned September 30, 2026

npx -y skills add jikig-ai/soleur --skill git-worktree --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Git Worktree?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Git Worktree
[![Security: B — Skills Directory](https://www.skillsdirectory.com/api/skills/jikig-ai-git-worktree/badge)](https://www.skillsdirectory.com/skills/jikig-ai-git-worktree)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: git-worktree
description: "This skill should be used when managing Git worktrees for isolated parallel development. It handles creating, listing, switching, and cleaning up worktrees with a simple interactive interface."
---

<!-- grok-harness-invoke:start -->
**Grok Build (`plugins/soleur/lib/harness.ts` `invokeSkill()`):** Read this SKILL.md in this process and run it to completion. A one-segment `soleur:<name>` in this document names a SKILL — on Grok Build, Read `plugins/soleur/skills/<name>/SKILL.md` in this process; it is not a nested tool_use. A multi-segment id such as `soleur:<domain>:<name>` names an AGENT: spawn it, never Read it, and on Grok Build spawn_subagent takes the id with its colons replaced by hyphens (`agentIdToGrokSubagentType`). **Claude Code:** Skill tool for a skill (`soleur:<name>`), Task tool with `subagent_type` for an agent. Forbidden is executing a subset, not the Read.
<!-- grok-harness-invoke:end -->

# Git Worktree Manager

This skill provides a unified interface for managing Git worktrees across your development workflow. Whether you're reviewing PRs in isolation or working on features in parallel, this skill handles all the complexity.

## What This Skill Does

- **Create worktrees** from main branch with clear branch names
- **List worktrees** with current status
- **Switch between worktrees** for parallel work
- **Clean up completed worktrees** automatically
- **Interactive confirmations** at each step
- **Automatic .gitignore management** for worktree directory
- **Automatic .env file copying** from main repo to new worktrees
- **Write guard enforcement** via PreToolUse hook (`.claude/hooks/worktree-write-guard.sh`) -- blocks Write/Edit to main checkout when worktrees exist

## CRITICAL: Always Use the Manager Script

**NEVER call `git worktree add` directly.** Always use the `worktree-manager.sh` script.

The script handles critical setup that raw git commands don't:

1. Copies `.env`, `.env.local`, `.env.test`, etc. from main repo
2. Ensures `.worktrees` is in `.gitignore`
3. Creates consistent directory structure
4. Detects bare repos (`core.bare = true`) and derives `GIT_ROOT` via `--absolute-git-dir` instead of `--show-toplevel`
5. Sources the shared `plugins/soleur/scripts/resolve-git-root.sh` helper -- all scripts that need `GIT_ROOT` should source this helper instead of inlining their own detection logic

**After creating a worktree**, run `npm install` if the project has a `package.json` — worktrees do not share `node_modules/` with the main working tree, and build commands (`npx @11ty/eleventy`, etc.) will silently hang instead of erroring.

```bash
# ✅ CORRECT - Always use the script
bash "${CLAUDE_PLUGIN_ROOT}/skills/git-worktree/scripts/worktree-manager.sh" create feature-name

# ❌ WRONG - Never do this directly
git worktree add .worktrees/feature-name -b feature-name main
```

## When to Use This Skill

Use this skill in these scenarios:

1. **Code Review (`soleur:review`)**: If NOT already on the target branch (PR branch or requested branch), offer worktree for isolated review
2. **Feature Work (`soleur:work`)**: Always ask if user wants parallel worktree or live branch work
3. **Parallel Development**: When working on multiple features simultaneously
4. **Cleanup**: After completing work in a worktree

## How to Use

### In Claude Code Workflows

The skill is automatically called from the `soleur:review` and `soleur:work` skills:

```
# For review: offers worktree if not on PR branch
# For work: always asks - new branch or worktree?
```

### Manual Usage

You can also invoke the skill directly from bash:

```bash
# Create a new worktree (copies .env files automatically)
bash "${CLAUDE_PLUGIN_ROOT}/skills/git-worktree/scripts/worktree-manager.sh" create feature-login

# List all worktrees
bash "${CLAUDE_PLUGIN_ROOT}/skills/git-worktree/scripts/worktree-manager.sh" list

# Switch to a worktree
bash "${CLAUDE_PLUGIN_ROOT}/skills/git-worktree/scripts/worktree-manager.sh" switch feature-login

# Copy .env files to an existing worktree (if they weren't copied)
bash "${CLAUDE_PLUGIN_ROOT}/skills/git-worktree/scripts/worktree-manager.sh" copy-env feature-login

# Clean up completed worktrees
bash "${CLAUDE_PLUGIN_ROOT}/skills/git-worktree/scripts/worktree-manager.sh" cleanup
```

## Commands

### `create <branch-name> [from-branch]`

Creates a new worktree with the given branch name.

**Options:**

- `branch-name` (required): The name for the new branch. The worktree DIRECTORY is the slug of it — every `/` becomes `-`, so `ci/rule-metrics` creates branch `ci/rule-metrics` in `.worktrees/ci-rule-metrics`. Identical for any name without a slash. Read the path the script prints; do not construct `.worktrees/<branch-name>` yourself. `create` refuses if the target directory already holds a different branch (`SOLEUR_WORKTREE_SLUG_COLLISION`) — the transform is many-to-one, so `ci/foo` and `ci-foo` would otherwise share one directory.
- `from-branch` (optional): Base branch to create from (defaults to `main`)

**Example:**

```bash
bash "${CLAUDE_PLUGIN_ROOT}/skills/git-worktree/scripts/worktree-manager.sh" create feature-login
```

**What happens:**

1. Checks if worktree already exists
2. Fetches `refs/remotes/origin/<from-branch>` (the local `<from-branch>` ref is NOT touched — this lets `create` succeed even when a sibling worktree has `<from-branch>` checked out; see #3741)
3. Creates the new worktree from `origin/<from-branch>` with `--no-track` (preserves the pre-fix upstream-unset state so downstream `git push -u origin <branch>` flows are unchanged)
4. **Copies all .env files from main repo** (.env, .env.local, .env.test, etc.)
5. Shows path for cd-ing to the worktree

**Opt-in: also update local `<from-branch>`**

Pass `--update-local-main` (as a global flag, before `create`) to additionally fast-forward the local `<from-branch>` ref. Default behavior leaves the local ref untouched.

```bash
bash "${CLAUDE_PLUGIN_ROOT}/skills/git-worktree/scripts/worktree-manager.sh" --update-local-main create feature-login
```

**Dependency install (bounded, skippable):**

After copying `.env` files, `create`/`feature` install dependencies at the root and for each `apps/*/` package (bun/npm/yarn by lockfile). Two guards keep this from hanging a pipeline on a restricted host:

- **Opt-out:** `--no-install` (global flag) or `SOLEUR_WORKTREE_SKIP_INSTALL=1` skips every install arm and prints `SOLEUR_WORKTREE_INSTALL_SKIPPED reason=opt-out`. The worktree is still created and usable; run installs inside it later.
- **Reachability preflight + timeout:** each arm probes its resolved registry endpoint (`curl`, bounded, on the scheme the registry actually uses — `http://` private registries included) and skips fast with `SOLEUR_WORKTREE_INSTALL_SKIPPED reason=registry-unreachable host=<host> endpoint=<endpoint>` when unreachable; a stalled install is killed after `SOLEUR_WORKTREE_INSTALL_TIMEOUT_SECS` (default 300) and reported as `reason=timeout`. Probe bounds: `SOLEUR_WORKTREE_REGISTRY_PROBE_SECS` (connect, default 5) / `SOLEUR_WORKTREE_REGISTRY_PROBE_MAX_SECS` (total, default 8).
- **Other non-success arms also mark stdout:** `reason=failed rc=<n>` (ordinary nonzero install), `reason=tool-missing runtime=<rt>` (lockfile present but package manager absent), `reason=no-lockfile` (package.json with no recognized lockfile). When the host lacks `timeout`/`gtimeout`, installs run unbounded and `SOLEUR_WORKTREE_INSTALL_UNBOUNDED` is printed once.

All skips and failures warn-and-continue: worktree creation exits 0 and the worktree stays on disk.

### `list` or `ls`

Lists all available worktrees with their branches and current status.

**Example:**

```bash
bash "${CLAUDE_PLUGIN_ROOT}/skills/git-worktree/scripts/worktree-manager.sh" list
```

**Output shows:**

- Worktree name
- Branch name
- Which is current (marked with ✓)
- Main repo status

### `switch <name>` or `go <name>`

Switches to an existing worktree and cd's into it.

**Example:**

```bash
bash "${CLAUDE_PLUGIN_ROOT}/skills/git-worktree/scripts/worktree-manager.sh" switch feature-login
```

**Optional:**

- If name not provided, lists available worktrees and prompts for selection

### `cleanup` or `clean`

Interactively cleans up inactive worktrees with confirmation.

**Example:**

```bash
bash "${CLAUDE_PLUGIN_ROOT}/skills/git-worktree/scripts/worktree-manager.sh" cleanup
```

**What happens:**

1. Lists all inactive worktrees
2. Asks for confirmation
3. Removes selected worktrees
4. Cleans up empty directories

### `sync-bare-files` or `sync`

Syncs stale on-disk files from git HEAD in a bare repo. Only needed when the repo uses `core.bare=true` — on-disk files at the bare root become stale after merges since git never updates them. Auto-called after `cleanup-merged` cleans branches in bare repo context.

**Example:**

```bash
bash "${CLAUDE_PLUGIN_ROOT}/skills/git-worktree/scripts/worktree-manager.sh" sync-bare-files
```

**What it syncs: EVERY tracked file at HEAD — there is no allowlist.**

The implementation builds a throwaway index from `HEAD` and runs
`git checkout-index -a -f` against the bare root, so every path git tracks is
materialized. That pass is **additive** — it never removes anything — so a
separate prune step afterwards deletes on-disk leftovers for paths that were
tracked once and are absent from HEAD. The two together are what make the bare
root equal HEAD exactly. It then re-applies execute bits for the scripts and
hooks the plugin loader and SessionStart hooks exec.

> **Corrected 2026-08-10 (#7409).** This section previously enumerated a
> seven-entry list (`AGENTS.md`, `plugins/soleur/hooks/*`, `.claude/hooks/*.sh`,
> `resolve-git-root.sh`, …) and told readers that "any file Claude Code executes
> at runtime from the bare repo root **must be added to the sync list**". Both
> halves were wrong: the enumeration described a whitelist the code has not
> had, and the instruction sent readers to register files in a list that does
> not exist. Worth stating precisely rather than deleting, because the
> difference is load-bearing — when #7409 moved the session-state lock/lease
> library to `plugins/soleur/scripts/lib/`, a genuine whitelist would have left
> the bare root's repointed `.claude/hooks/*` sources pointing at a file the
> mirror never copied, and they degrade **silently** (`|| true`). The full
> mirror is what makes that a non-event.

**Still important:** run `sync-bare-files` after any merge that moves or adds a
file executed from the bare root. The mirror is complete, but it is not
automatic — on-disk files at a `core.bare=true` root are never updated by git
itself, so a stale copy survives until this runs.

## Workflow Examples

### Code Review with Worktree

```bash
# Claude Code recognizes you're not on the PR branch
# Offers: "Use worktree for isolated review? (y/n)"

# You respond: yes
# Script runs (copies .env files automatically):
bash "${CLAUDE_PLUGIN_ROOT}/skills/git-worktree/scripts/worktree-manager.sh" create pr-123-feature-name

# You're now in isolated worktree for review with all env vars
cd .worktrees/pr-123-feature-name

# After review, return to main:
cd ../..
bash "${CLAUDE_PLUGIN_ROOT}/skills/git-worktree/scripts/worktree-manager.sh" cleanup
```

### Parallel Feature Development

```bash
# For first feature (copies .env files):
bash "${CLAUDE_PLUGIN_ROOT}/skills/git-worktree/scripts/worktree-manager.sh" create feature-login

# Later, start second feature (also copies .env files):
bash "${CLAUDE_PLUGIN_ROOT}/skills/git-worktree/scripts/worktree-manager.sh" create feature-notifications

# List what you have:
bash "${CLAUDE_PLUGIN_ROOT}/skills/git-worktree/scripts/worktree-manager.sh" list

# Switch between them as needed:
bash "${CLAUDE_PLUGIN_ROOT}/skills/git-worktree/scripts/worktree-manager.sh" switch feature-login

# Return to main and cleanup when done:
cd .
bash "${CLAUDE_PLUGIN_ROOT}/skills/git-worktree/scripts/worktree-manager.sh" cleanup
```

## Key Design Principles

### KISS (Keep It Simple, Stupid)

- **One manager script** handles all worktree operations
- **Simple commands** with sensible defaults
- **Interactive prompts** prevent accidental operations
- **Clear naming** using branch names directly

### Opinionated Defaults

- Worktrees always created from **main** (unless specified)
- Worktrees stored in **.worktrees/** directory
- Branch name becomes worktree name
- **.gitignore** automatically managed

### Safety First

- **Confirms before creating** worktrees
- **Confirms before cleanup** to prevent accidental removal
- **Won't remove current worktree**
- **Clear error messages** for issues

## Integration with Workflows

### `soleur:review`

Instead of always creating a worktree:

```
1. Check current branch
2. If ALREADY on target branch (PR branch or requested branch) → stay there, no worktree needed
3. If DIFFERENT branch than the review target → offer worktree:
   "Use worktree for isolated review? (y/n)"
   - yes → call git-worktree skill
   - no → proceed with PR diff on current branch
```

### `soleur:work`

Always offer choice:

```
1. Ask: "How do you want to work?
   1. New branch on current worktree (live work)
   2. Worktree (parallel work)"

2. If choice 1 → create new branch normally
3. If choice 2 → call git-worktree skill to create from main
```

## Troubleshooting

### "Worktree already exists"

If you see this, the script will ask if you want to switch to it instead.

### "Cannot remove worktree: it is the current worktree"

Switch out of the worktree first (to main repo), then cleanup:

Navigate to the repository root directory, then run:

```bash
bash "${CLAUDE_PLUGIN_ROOT}/skills/git-worktree/scripts/worktree-manager.sh" cleanup
```

### Lost in a worktree?

See where you are:

```bash
bash "${CLAUDE_PLUGIN_ROOT}/skills/git-worktree/scripts/worktree-manager.sh" list
```

### .env files missing in worktree?

If a worktree was created without .env files (e.g., via raw `git worktree add`), copy them:

```bash
bash "${CLAUDE_PLUGIN_ROOT}/skills/git-worktree/scripts/worktree-manager.sh" copy-env feature-name
```

Navigate back to the repository root directory.

### node_modules missing in worktree?

A worktree created without an install step (raw `git worktree add`, a
harness-created agent worktree, or a `create` whose dep install warned and
continued) carries no `node_modules`. Since #8580 the pre-commit lint hook
resolves its pinned binary from a sibling checkout's `node_modules`
automatically — accepted only when the sibling's package manifests report
CLI+engine versions equal to this checkout's pins (verified by file reads, so
an unchecked binary never runs) — so docs commits work without any install.
Everything else (vitest, tsx-driven suites, `npm run` scripts) still needs
the real install inside the worktree — run both from the worktree root:

```bash
npm ci --ignore-scripts
npm ci --ignore-scripts --prefix apps/web-platform   # needed by the webplat arm of scripts/test-all.sh
```

## Sharp Edges

- **A `cd <abs path under the bare root>` from inside a worktree SUCCEEDS, and every command after it reads `main`.** The bare checkout carries the same tree (`apps/web-platform`, `plugins/soleur`, …) as every worktree, so a path written from memory resolves there without error and the suite you run reports on a branch you are not on — measured on #8418: `cd /data/…/soleur/apps/web-platform 2>/dev/null || cd <worktree>/apps/web-platform` took the FIRST arm and `vitest` printed 57/57 about `main`. The only tell is the harness's `# Environment update` notice. Anchor every `cd` on `$PWD` or `git rev-parse --show-toplevel`, never on a remembered absolute; `hr-when-in-a-worktree-never-read-from-bare` has no hook, so the discipline is the guard. **Why:** #8418.
- **A git hook running in a LINKED WORKTREE exports `GIT_DIR` and `GIT_INDEX_FILE` as ABSOLUTE
  paths, and they override a subprocess's `cwd` and `git -C`.** A plain clone exports no
  `GIT_DIR` at all, and only a RELATIVE `GIT_INDEX_FILE` (`.git/index`) which resolves
  against the subprocess's own cwd and is harmless — so this reproduces only here; anyone
  diagnosing it in a fresh clone will measure nothing and wrongly conclude the report is stale.
  Consequence: a test fixture's `git init` initialises nothing and its commits land on the
  worktree's live branch, moving the tip. Test-side convention and the `rc=97` tripwire that now
  refuses such a run: `plugins/soleur/AGENTS.md` §Test Fixture Conventions (#7833).

- **`.git` is a FILE in a worktree, so every `.git/<state>` probe silently answers "no".** A rebase-resolution loop gated on `[ -d .git/rebase-merge ]` exits immediately reporting success while the rebase is still mid-flight — measured, it claimed completion with **six commits unapplied** and a conflict staged. Resolve the real path with `git rev-parse --git-path rebase-merge` (it returns the worktree's own `…/worktrees/<name>/rebase-merge`). Same for `MERGE_HEAD`, `CHERRY_PICK_HEAD`, `HEAD`, and anything else under the git dir. Nothing was lost — the reflog held every commit — but the loop reported the opposite of the truth.
- **A test can sandbox correctly and still commit into YOUR worktree, because the escape is the `cd` INTO the sandbox, not the sandbox.** `( cd "$X" && … )` is safe — `&&` short-circuits. `( cd "$X"` followed by a newline is not: on a failed `cd` the subshell keeps the INHERITED cwd and every command below runs against whatever worktree `test-all.sh` was invoked from. It is reachable whenever setup can fail silently, and `git worktree add … >/dev/null 2>&1` (which swallows "branch already exists" from a crashed prior run) is the usual way it does. Measured 2026-08-20: `lease-protects-active.test.sh` — which sandboxes properly with `mktemp -d` and its own bare repo — committed `victim change`/`victim2`/`v9`/`v12` onto a live feature branch, moved the ref off six review commits, then checked that worktree out to `main` and pulled; a fifth unguarded site ends in `git push origin main`. Recovery order matters: the commits survive as OBJECTS, so `git push origin <sha>:refs/heads/<branch>` FIRST (durability before local surgery), then `git update-ref <ref> <good> <bad>` as a compare-and-swap, then restore the checkout. Guard every non-`&&` `( cd "$X"` with `|| { echo "FATAL: cd to sandbox failed; refusing to write git objects in $(pwd)" >&2; exit 90; }`, and verify by asserting `git rev-parse HEAD` is UNCHANGED across a full run — not by the guard's presence, which passed a first, incomplete fix. **Why:** #7546. See `knowledge-base/project/learnings/2026-08-20-every-guard-i-fixed-was-narrower-than-the-claim-it-carried.md`.
- If `worktree-manager.sh` reports success but `cd` to the worktree path fails or `git branch --show-current` returns an unexpected branch, the worktree was not properly created. Fall back to `git worktree add` directly: `git worktree add .worktrees/<name> -b <name> main`. The script includes post-creation verification (#1806) but edge cases on bare repos may still produce partial directories. Tracked in #1854.
- The `draft-pr` subcommand uses `SCRIPT_DIR` for path resolution -- invoke it from inside the worktree, not from the bare repo root.
- **`fatal: this operation must be run in a work tree` in an intact worktree means `extensions.worktreeConfig` is set on the SHARED bare-repo config while `config.worktree` files do not set `bare = false` — it takes every worktree down at once, not just yours.** Enabling that extension makes git read `.git/worktrees/<name>/config.worktree` per worktree; when those do not set `bare = false`, nothing overrides `core.bare = true` from the common config, so `git rev-parse --is-inside-work-tree` returns `false` everywhere despite valid `.git`/`gitdir`/`commondir` pointers. Repair BOTH halves: `git config -f <bare>/.git/config --unset-all extensions.worktreeConfig`, **and** write `[core]\n\tbare = false` into each `config.worktree` — the second is what leaves the fleet immune to a re-add. Verify with a loop over `git worktree list` before continuing. **Why:** 2026-08-09 (#7332) — all 14 worktrees wedged mid-rebase; the key is already classed as harmful by #4826, but that heal (`apps/web-platform/server/worktree-config-seed.ts`) is scoped to Concierge workspace provisioning and never runs against an operator's local bare repo. See `knowledge-base/project/learnings/2026-08-09-one-shared-config-key-took-all-fourteen-worktrees-down-mid-rebase.md`.

  **This is now largely a fallback — `worktree-manager.sh` performs both halves itself (#7394, [ADR-173](../../../../knowledge-base/engineering/architecture/decisions/ADR-173-bare-config-polarity-for-linked-worktrees.md)).** `ensure_bare_config` removes the extension on every `create` / `create-for-feature` / `cleanup-merged`, `create` seeds `core.bare = false` into each new worktree's `config.worktree`, and a detection-time self-heal repairs the CURRENT worktree the moment git reports it as bare — so an already-wedged worktree recovers on first use rather than waiting for a session-start gate. The root cause of #7332 was that `ensure_bare_config` was **dead code** on the operator's bare-repo-in-`.git` layout: its guard branched on the gitdir's SHAPE (`.git` is a directory) which is true of that layout too. Reach for the manual repair only if `SOLEUR_GIT_BARE_SELFHEAL … branch=failed` or a `worktree wedge: could not …` line shows the automated path could not write. **Do not gate on `SOLEUR_GIT_BARE_POISON`** — it is emitted on the SUCCESS path too (`branch=clean` fires on every healthy run), so treating its presence as a failure signal sends you into config surgery on a healthy repo. **Correction to the text above:** the per-worktree files are typically **absent**, not 0 bytes — measured on git 2.53.0, `git worktree add` writes no `config.worktree` at all, so test for non-existence rather than for an empty file.
- When creating worktrees manually (not via the script), always use absolute paths. Relative paths resolve from CWD, not from `GIT_DIR`, creating nested worktrees that are difficult to clean up. The script handles this correctly but manual `git worktree add` commands are susceptible.
- **A manually-added worktree holds NO LEASE and is reapable by any sibling's `cleanup-merged` — and RECOVERING an existing branch is exactly the case that forces you into the manual path.** `worktree-manager.sh create` runs `git worktree add -b "$branch"`, which fails when the branch already exists, so recovering a branch whose worktree was removed (or whose PR is mid-flight) cannot go through the script — and the lease wiring lives only in the script's `create`. After any manual `git worktree add`, acquire the lease explicitly:

  ```bash
  # Run this from INSIDE the new worktree. The lease key is the worktree
  # DIRECTORY name, not the branch. Since #8400 cleanup-merged probes TWO keys per
  # candidate — `is_lease_active "$(_safe_worktree_name "$branch")"` first, then
  # `is_lease_active "$(basename "$worktree_path")"` — and holding EITHER is a hold.
  # The directory basename is still the key to use here, because it is the one that
  # survives `switch`'s legacy-nested fallback (which leases under `foo` for a branch
  # `ci/foo`, where the safe name is `ci-foo`). For a worktree created flat by the
  # script the two keys are the same slug. The branch-keyed probe is the one that runs
  # for a merged branch whose worktree is ALREADY GONE — the cohort that used to
  # short-circuit every guard in the loop. The acquire side keys on
  # the same slug (every `/` becomes `-`). Passing a branch name with a slash
  # writes nothing at all — the validator rejects `/` — and says so only in a
  # per-PID log file the agent never reads, so the worktree runs unleased and
  # reapable with no visible signal. `basename "$PWD"` is also exactly what the
  # matching `release_lease` calls in one-shot and work use.
  SS_LIB="${CLAUDE_PLUGIN_ROOT}/scripts/lib/session-state.sh"
  WT_KEY="$(basename "$PWD")"
  if [[ -r "$SS_LIB" ]]; then
    source "$SS_LIB" && acquire_lease "$WT_KEY" "<skill>" <minutes>
    # rc=0 is NOT proof the lease exists: acquire_lease returns 0 and writes
    # nothing when the layer is disabled. Assert the FILE, and reuse the marker
    # the script already emits — it is mirrored and paged, so a new name here
    # would be observable to nobody.
    if [[ ! -f "$(git rev-parse --git-common-dir)/soleur-session-state/leases/$WT_KEY.lease" ]]; then
      echo "SOLEUR_WORKTREE_LEASE_ACQUIRE_FAILED key=$WT_KEY site=manual reason=file-absent"
    fi
  else
    # NEVER silent (#7409). Unlike release_lease and with_lock — advisory
    # operations that degrade open quietly — this call IS the acquisition of
    # the protection itself. There is nothing else to do when the library is
    # absent, but degrading open without saying so manufactures exactly the
    # exposure the lease exists to prevent: an unleased worktree that a
    # sibling `cleanup-merged` is now free to reap.
    echo "SOLEUR_SESSION_STATE_UNAVAILABLE path=$SS_LIB reason=worktree-UNLEASED-and-reapable"
  fi
  ```

  Note the default grant is 240 minutes: a long review-and-fix pass outlives it, so re-acquire rather than assuming the initial grant still covers you. Recovery when it is reaped: the commit objects survive unreferenced, so `git branch <name> <sha>` pins them before gc can prune, then re-push. Tell for the whole class — a test run whose failures read `fatal: Unable to read current working directory` or `getcwd: cannot access parent directories` is reporting its own environment being deleted, not defects. **Why:** 2026-08-10 (#7278) — reaped twice in one session, once on lease expiry and once with no lease at all; 2 of 3 full-suite "failures" were the deletion landing mid-run.
- When lefthook hangs in a worktree (>60s), kill it (`pkill -f "lefthook run"`), verify checks manually, then commit with `LEFTHOOK=0 git commit`. This is a known lefthook/worktree interaction bug. (ex-`cq-when-lefthook-hangs-in-a-worktree-60s`; also guarded by `.claude/hooks/lib/incidents.sh` detect_bypass)
- **`pkill -f <shared-script-name>` is NEVER correctly scoped in this repo — it kills the identical process in every sibling worktree.** Parallel worktrees are the documented workflow, so the same [scripts/test-all.sh](../../../../scripts/test-all.sh) (or lefthook, or any shared script) runs under a byte-identical command line in each one; a `-f` pattern match cannot distinguish yours. Kill by **PID** from the process listing, or resolve `readlink /proc/<pid>/cwd` and match the worktree before killing anything. The same `/proc` resolution is the only sound liveness test — a bare `pgrep -f "<script>"` matches its OWN command line, so it always finds ≥1 process and can never report "not running". **The read-only direction of that self-match is the more dangerous one and recurred on #7109: a polling loop whose condition was `! ps -ef | grep "<script>" | grep -v grep` reported `FINISHED` while a 40-minute suite was still 3,000 lines from its terminal marker** — `grep -v grep` does not filter the poller's own `/bin/bash -c` wrapper (it is not `grep`), so the check oscillates between matching itself and matching nothing, and "nothing" reads as done. Resolve liveness by **PID** (`[[ -d /proc/$PID ]]`) plus `readlink /proc/$PID/cwd` to confirm the process is yours, and confirm completion from the runner's own terminal marker (`=== N/M suites passed ===`) — a killed run and a finished run are indistinguishable from the process table. **Why:** #7086 — `pkill -f "bash scripts/test-all.sh"` terminated a parallel session's suite 4,274 lines in (its infra/terraform results had already landed and survived; only the `test-all` rc was lost), and a `pgrep` self-match was twice read as "my run is alive" while the run had been dead for hours. **The cwd filter above is necessary and NOT sufficient, and #5454 paid for the difference:** a scan that resolved `/proc/<pid>/cwd` and killed only pids whose cwd was the current worktree still killed the SCANNING shell (exit 144) — the scanner's own cwd *is* that worktree and its command line matches its own predicate, so filtering by worktree selects it too. Neither `grep -v grep` nor a `[t]est` bracket trick excludes it, because the matching process is the `/bin/bash -c` wrapper, not `grep`. Compute self + ancestry first and skip those pids explicitly: `p=$$; while [ -n "$p" ] && [ "$p" != 1 ]; do ANC="$ANC $p"; p=$(awk '{print $4}' /proc/$p/stat 2>/dev/null); done`, then `case " $ANC " in *" $pid "*) continue ;; esac` inside the kill loop. That form killed both target processes and spared every sibling.
- Never pass `-c user.email=<fake>` / `-c user.name=<fake>` to `git commit` to bypass author-identity errors — fix the worktree's local git config instead (`worktree-manager.sh create` auto-runs `ensure_worktree_identity`). (ex-`hr-never-fake-git-author`; PR #2815 forced a destructive force-push after 4 commits were authored as `test@test` and blocked CLA; `knowledge-base/project/learnings/2026-04-24-fake-git-author-bare-repo-bot-override.md`)
- After `git worktree add` on bare repos, verify both `rev-parse --show-toplevel` (directory validity) and `git worktree list --porcelain` (registration). See learning: `knowledge-base/project/learnings/2026-04-10-worktree-registration-verification-insufficient.md`.
- In bare repos with multiple worktrees, `git fetch origin branch:branch` fails when the target branch is checked out in any worktree -- git rejects the refspec update. The fallback `git fetch origin branch` only updates `origin/branch`, NOT the local ref. Use `git update-ref refs/heads/branch origin/branch` to force-sync when the fetch refspec is rejected. As of #3741 (2026-05-14), `worktree-manager.sh create` bypasses this failure mode by default — new worktrees are based on `refs/remotes/origin/<from>` directly. The refspec-fetch path only runs when `--update-local-main` is passed.
- After creating a worktree via the script, always verify it exists in `git worktree list` before attempting to `cd` into it -- the script may report success for names that silently fail (e.g., excessively long names). A 2026-04-18 recurrence under a normal short name (#2611) confirms the silent-failure mode is not limited to edge-case names; see `knowledge-base/project/learnings/2026-04-18-worktree-manager-silent-registration-failure.md`.
- In bare repos, `git branch --show-current` from the bare root returns `main` (or empty), not the worktree's branch. Always ensure CWD is inside the target worktree before running branch-detecting git commands.
- **Never re-add a raw `git config` write to the shared config in `worktree-manager.sh`** — every shared-config mutation must route through `atomic_git_config`, which resolves the common-dir config via `rev-parse --path-format=absolute --git-common-dir` and survives a masked `config.lock` (ADR-081). A raw `git config --local`/`--file` write EEXISTs on the sandbox's char-device lock and wedges creation (RC=255).
- **A bare-vs-non-bare guard must detect layout via a pure filesystem fact, not mask-degradable `git rev-parse` (#5934).** Under the sandbox's char-device config mask, `git rev-parse --show-toplevel` returns empty (`GIT_ROOT=""`) and `--is-bare-repository` degrades to `true` because both must read the masked `.git/config` — so a genuinely NON-bare clone gets wrongly routed into bare-repo config surgery and wedges on the doomed `mv … .git/config` (EBUSY). Detect non-bare via `git_dir` being a `.git` **directory** (with a `$PWD/.git` fallback when `GIT_ROOT` resolves empty), which never reads the masked config; skip the surgery on non-bare and fail LOUD only on genuinely-bare-under-mask. Pin bare-vs-non-bare + the exact masked node via live probes (`--is-bare-repository`, `stat .git`, `stat .git/config*`) BEFORE scoping any fix. See `knowledge-base/project/learnings/2026-07-07-telemetry-blind-giveup-and-mask-degraded-nonbare-guard.md`.
- **An orphan directory the reaper could not remove is a PARTIALLY-deleted hollow shell, and it will recur every run until the residue is cleared (#7102).** `rm -rf` deletes everything it can reach before failing, so a surviving orphan is not the intact worktree it looks like — treat it as debris, never as recoverable work. The usual cause is root-owned residue: the local Supabase stack runs as root in a container and creates `apps/web-platform/supabase/snippets` via a bind-mount, leaving `root:root 755` directories inside an otherwise user-owned worktree that an unprivileged `rm` cannot unlink. Any worktree that ever started that stack leaves this behind, so the class is not specific to one branch. `cleanup_orphan_worktree_dirs` now reports it honestly instead of counting it as cleaned. It emits ONE aggregate sentinel on **stdout** (the stream agents grep) per invocation — note the field set, because greppping for a `dir=` key that does not exist is exactly the failure this whole change is about:

```text
SOLEUR_ORPHAN_UNREMOVABLE count=<n> cleaned=<n> errno=<LABEL> names=<basename,basename> reason=rm-partial hint="…"
```

`count=` is how many could not be removed, `cleaned=` how many actually were (carried here so the success counter is readable at the default `verbose=false`, where the success summary is suppressed), and `names=` is sanitized basenames only. A human-readable failure summary naming each full path goes to stderr and prints even at `verbose=false`. A separate `SOLEUR_ORPHAN_REGISTRY_UNAVAILABLE` fires when `git worktree list` fails — the reaper then refuses to reap anything rather than treat an empty registry as "everything is an orphan". To clear it, stop the local Supabase stack (`supabase stop`) so the bind-mount is released, then re-run `cleanup-merged`. **Do not reach for a containerized `rm -rf` as a privileged workaround.** `guardrails:block-rm-rf-worktrees` still matches most docker-wrapped forms (the `.worktrees/` path survives in the command line), but it is defeated by a **remapped mount** — `docker run -v <abs>/.worktrees/foo:/target alpine rm -rf /target` never names `.worktrees/` after the `rm -rf`, so it is allowed. Measured; matcher gap tracked in #7113 (which also covers `rm -rf -- <path>`, where the `--` separator defeats the same matcher). A safely-designed privileged fallback is tracked in #7112; the producer-side fix that would stop the residue being created at all is #7114.

- **A slash-bearing branch produces a HYPHENATED directory; the branch keeps its slashes; the directory basename is the lease key (#7408).** `create ci/rule-metrics` makes `.worktrees/ci-rule-metrics` — two levels below the repo root, never three — while `git branch --show-current` inside it still reports `ci/rule-metrics`. `switch` accepts either form. This matters because three consumers assume the flat layout: `cleanup_orphan_worktree_dirs` globs exactly one level, `cleanup_merged_worktrees` reads the lease back as `$safe_branch`, with `basename "$worktree_path"` as a fallback (#8400), and [scripts/test-all.sh](../../../../scripts/test-all.sh) documents `cd .worktrees/<name> && bash ../../scripts/test-all.sh`. Before this fix the producer used the raw refname for both the path and the lease key, so a slash branch nested three levels, failed `_validate_worktree_name` and ran **unleased**, and its unregistered intermediate (`.worktrees/ci`) matched no guard in the reaper and was `rm -rf`'d with the live worktree inside it. `tr '/' '-'` is identity for every non-slash name, so nothing about existing worktrees changed.
  - **Migrating a worktree that is ALREADY nested** (created by a pre-fix version). The reaper now *skips* it and emits `SOLEUR_ORPHAN_SKIP_DESCENDANT dir=… reason=holds-live-worktree` rather than deleting it. `switch <name>` still reaches it (it falls back to the raw name and warns), so you can get in and commit; it stays invisible to `list` and `copy-env` until moved. Commit any uncommitted work first, then:

    ```bash
    ROOT="$(cd "$(git rev-parse --path-format=absolute --git-common-dir)/.." && pwd)"

    # 1. Find nested entries. A flat worktree has no second `/` after .worktrees/.
    git worktree list --porcelain | grep -E '^worktree .*/\.worktrees/[^/]+/'

    # 2. The destination MUST NOT already exist. `git worktree move` into an
    #    existing directory moves the worktree INSIDE it, producing a NEW
    #    3-level nesting — and `git worktree list` then prints a plausible path,
    #    so the migration silently reproduces the defect it is undoing.
    test -e "$ROOT/.worktrees/ci-rule-metrics" && echo "ABORT: destination exists"

    # 3. Absolute paths: relative ones resolve against your cwd, and running
    #    this from inside a worktree (the normal position) fails with a
    #    misleading "No such file or directory".
    git worktree move "$ROOT/.worktrees/ci/rule-metrics" "$ROOT/.worktrees/ci-rule-metrics"

    rmdir "$ROOT/.worktrees/ci"   # only succeeds if now empty
    git worktree list             # confirm the new path is registered
    ```

    Use `git worktree move`, never `mv` — the latter leaves the worktree's `gitdir` file pointing at the old path and the worktree reads as broken. (In this bare repo that admin file is `<bare-root>/worktrees/<name>/gitdir`, not `.git/worktrees/<name>/gitdir`.) `git worktree move` also refuses a **locked** worktree; unlock it first rather than reaching for `-f -f`. After the move the lease key changes with the basename, so re-run `switch` to re-acquire.
- **Identity authority is inverted between environments (ADR-099, #6184).** On the non-bare Concierge agent workspace the LOCAL identity is the host-seeded workspace **owner** (authoritative); on the bare CLI dev repo the operator's **global** is the human, and the bare root frequently carries an inherited `github-actions[bot]` LOCAL that worktrees inherit (the #2815 CLA-reject bug). `ensure_worktree_identity` discriminates on **bot-shape** (`_identity_is_bot`: a `[bot]` marker in name/email), NOT on presence: it respects a present NON-bot local, overrides a bot-shaped local from a human `--global`, and REFUSES to ever write a bot-shaped `--global` (`reason=bot-global-refused`) so it can never misattribute a commit. Do NOT re-introduce a blanket "force global over local" (wrong on Concierge) OR a blanket "respect any present local" (wrong on the bare-dev bot-local) — neither is correct alone.
- **Every reap archive write persists via the checkout's commit path, or is not made (#9127, ADR-258).** `cleanup-merged` archives the reaped feature's spec dir, brainstorms and plans through `reap_archive_persist` (the single chokepoint in `worktree-manager.sh` — a fourth site added without it is the defect class reborn). The rule per artifact: **untracked** → plain `mv` (unchanged; an untracked file cannot resurrect); **tracked + committable** (a non-`main`/`master`, non-detached, non-merging branch of a non-bare checkout) → `git mv`, then ONE pathspec-scoped `chore(archive-kb): persist reap archive for <slug>` commit per reaped branch — the pathspec (under `GIT_LITERAL_PATHSPECS=1`) is what keeps the session's unrelated staged work out of it, and the commit re-verifies `HEAD` still equals the probed branch (drift degrades to STAGED); **tracked + non-committable** (`main`/`master`, detached HEAD, unborn HEAD, mid-merge, or the bare root) → **no move at all** — commits to `main` are hook-prohibited and an unpushed local commit would break `pull --ff-only` permanently, so there is no legal commit path and a move would only produce the unpersisted mutation that `reset --hard` (SOLEUR-GUARD-MAINRESET) or `sync_bare_files`' checkout-index then reverts asymmetrically into a live+archive twin. Three stdout markers report the decision: `SOLEUR_REAP_ARCHIVE_COMMITTED slug=<s>` (the scoped commit landed), `SOLEUR_REAP_ARCHIVE_STAGED slug=<s>` (the commit failed or HEAD drifted — the staged rename payload is left for the session's own commits to carry; never `LEFTHOOK=0`), `SOLEUR_REAP_ARCHIVE_DEFERRED slug=<s> reason=<main-checkout|detached|unborn|merge-in-progress|bare|git-mv-failed|outside-git-root|unsafe-destination> path=<rel>` (no move was made; `slug=` is always the reaped branch's safe_branch across all sites, so every marker for one reap joins on it). IMPORTANT for the bare dev topology: a worktree of the bare repo resolves `IS_BARE=true` — `reason=bare` is the PERMANENT steady state there, not a transient waiting for "the next worktree session". The sanctioned unstick for a deferred artifact is a manual `git mv` + commit on a NON-bare feature-branch checkout; `archive-kb.sh` on `main`/detached would re-create the same staged-but-unpersisted rename by hand. A failed `git mv` never falls back to plain `mv` — that would re-create the unpersisted mutation this rule exists to prevent.

## Technical Details

### Directory Structure

```
.worktrees/
├── feature-login/          # Worktree 1
│   ├── .git
│   ├── app/
│   └── ...
├── feature-notifications/  # Worktree 2
│   ├── .git
│   ├── app/
│   └── ...
└── ...

.gitignore (updated to include .worktrees)
```

### How It Works

- Uses `git worktree add` for isolated environments
- Each worktree has its own branch
- Changes in one worktree don't affect others
- Share git history with main repo
- Can push from any worktree

### Performance

- Worktrees are lightweight (just file system links)
- No repository duplication
- Shared git objects for efficiency
- Much faster than cloning or stashing/switching

Files in this skill

  • SKILL.md13.8 KB
  • scripts/worktree-manager.sh55.2 KB
  • test/create-from-origin-main.test.sh4.6 KB
  • test/lease-protects-active.test.sh5 KB

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.