using-git-worktrees
Imported from alexei-led/cc-thingz/src/skills/using-git-worktrees.
Install
npx skills add https://github.com/alexei-led/cc-thingz/tree/master/src/skills/using-git-worktrees
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install alexei-led-cc-thingz@llmmart
git clone https://github.com/alexei-led/cc-thingz.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole alexei-led/cc-thingz collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
Git Worktrees
A worktree gives parallel work its own folder and branch while the main worktree stays clean on the integration branch. Each project gets one sibling root, <project>.worktrees/, with one directory per branch named by its slug (/ → -, so feature/auth → feature-auth). Remove the worktree and branch after the PR merges.
A plain branch switch with no parallel work needs no worktree: check git status --short, then git switch <branch>. Trivial solo one-liners may also stay in the main worktree.
Create
Check git status --short and git worktree list first. Leave uncommitted changes where they are and never stash them silently; pass --allow-dirty only when the user has authorized isolating new work while keeping them.
scripts/setup-worktree.sh <branch> [--base <ref>] [--allow-dirty] [--setup] [--test]
The script runs git worktree add from the main worktree root. It checks out an existing local or origin branch instead of recreating it, and refuses an existing path, a branch checked out in another worktree, or a dirty tree without --allow-dirty. It reports base divergence from local refs without fetching.
--setupdoes a frozen install with the declared package manager and lockfile (uv for Python). Conflicting lockfiles or manager declarations stop it. A setup failure exits non-zero but still prints the path.--testruns the detected baseline tests. Failures only warn.- A skipped setup or test is not a pass.
Clean up one worktree
scripts/cleanup-worktree.sh [branch]
It removes the worktree and deletes the branch only when gh confirms the PR is MERGED. It also refuses when the branch has commits past the merged PR head. Pass --force only after the user confirms the merge without gh, confirms those extra commits are throwaway, or abandons the branch; --force can remove a dirty worktree and force-delete the branch. For bulk or stale cleanup, use cleanup-git. Leave git pull out of cleanup; the user pulls the main worktree once it is clean on the integration branch.
For cases the scripts refuse or don't cover, read workflow.md.
Output
WORKTREE READY | WORKTREE REMOVED | BLOCKED
Branch: <branch>
Path: <project>.worktrees/<slug>
Next: cd <path>, or the script's refusal reason
Report a cleanup as done only when the PR is confirmed MERGED or --force was deliberate.
Files (cc-thingz)
-
.agentbundler
-
targets
-
claude.json 548 B
{ "frontmatterPatch": { "allowed-tools": [ "Read", "AskUserQuestion", "Bash(git status *)", "Bash(git branch *)", "Bash(git worktree *)", "Bash(git rev-parse *)", "Bash(git symbolic-ref *)", "Bash(git fetch *)", "Bash(mkdir *)", "Bash(rmdir *)", "Bash(scripts/setup-worktree.sh *)", "Bash(scripts/cleanup-worktree.sh *)", "Bash(git switch *)", "Bash(git log *)", "Bash(git -C * status *)" ], "context": "fork", "user-invocable": true } }
-
-
-
references
-
workflow.md 1.4 KB
# Worktree Edge Cases Cases the scripts refuse or leave to you. - Path exists: pick another branch name, or remove the leftover directory after the user confirms. Remove only paths under `<project>.worktrees/`. - Branch checked out in another worktree: work there, or pick another branch. If that worktree's directory is gone, `git worktree prune` clears the stale registration. - Base ref not found: fetch or pass `--base <ref>`. - Setup refused (no lockfile, conflicting lockfiles or manager): run the project's documented install command, and ask if none exists. - Cleanup when `gh` is missing or cannot see the PR: ask the user to confirm the merge and check `git -C <worktree> status --short`, since `--force` also discards dirty files; then run `scripts/cleanup-worktree.sh --force <branch>`. - Refused because the branch has commits past the merged PR head, or the PR head is not local: `git fetch` first; if still refused, show `git log <pr-head>..<branch>` and use `--force` only when the user says those commits are throwaway. - `git worktree remove` fails on a merged worktree because it is dirty: show `git -C <worktree> status --short`, and use `--force` only when the user says those changes are throwaway. - `git branch -d` refuses after a squash or rebase merge: that is expected; use `-D` once the PR is confirmed merged. - The shell was inside the removed worktree: `cd` to the main worktree before running further commands.
-
-
scripts
-
cleanup-worktree.sh 3.3 KB
#!/usr/bin/env bash # Remove a worktree and delete its branch after its PR has merged. set -euo pipefail FORCE=0 BRANCH="" usage() { cat <<'EOF' Usage: cleanup-worktree.sh [--force] [branch-name] Default is strict: if gh cannot confirm the PR is MERGED, nothing is touched. Use --force only after confirming the merge yourself or deciding to abandon the branch. --force may remove a dirty worktree and force-delete the branch. EOF } while [ "$#" -gt 0 ]; do case "$1" in --force) FORCE=1 shift ;; -h | --help) usage exit 0 ;; -*) echo "Error: unknown option: $1" >&2 usage >&2 exit 2 ;; *) [ -z "$BRANCH" ] || { echo "Error: branch name already set: $BRANCH" >&2 exit 2 } BRANCH=$1 shift ;; esac done porcelain=$(git worktree list --porcelain 2>/dev/null) || { echo "Error: Not in a git repository" exit 1 } MAIN_WT=$(awk '/^worktree /{print $2; exit}' <<<"$porcelain") if [ -z "$BRANCH" ]; then BRANCH=$(git rev-parse --abbrev-ref HEAD) [ "$BRANCH" = "HEAD" ] && { echo "Error: detached HEAD — pass a branch name explicitly" exit 1 } fi WT=$(awk -v b="refs/heads/$BRANCH" ' /^worktree /{p=$2} $0=="branch "b{print p; exit}' <<<"$porcelain") if [ -z "$WT" ]; then echo "Error: no worktree checked out on branch '$BRANCH'" git worktree list exit 1 fi if [ "$WT" = "$MAIN_WT" ]; then echo "Error: '$BRANCH' is checked out in the MAIN worktree ($MAIN_WT) — refusing to remove it" exit 1 fi GH=0 STATE="" PR_HEAD="" if command -v gh >/dev/null 2>&1; then GH=1 pr_info=$(gh pr view "$BRANCH" --json state,headRefOid --template '{{printf "%s\t%s" .state .headRefOid}}' 2>/dev/null || echo "") if [ -n "$pr_info" ]; then IFS=$'\t' read -r STATE PR_HEAD <<<"$pr_info" fi fi [ "$GH" = 1 ] && pr_desc="PR for '$BRANCH' is ${STATE:-not found}" || pr_desc="gh not installed, PR state for '$BRANCH' unknown" if [ "$STATE" = MERGED ]; then if [ "$FORCE" != 1 ]; then if ! git cat-file -e "${PR_HEAD}^{commit}" 2>/dev/null || ! git merge-base --is-ancestor "$BRANCH" "$PR_HEAD" 2>/dev/null; then echo "Refusing: '$BRANCH' has commits beyond the merged PR, or the PR head is not local (try git fetch)." echo "Nothing was changed. Re-run with --force once confirmed, or to abandon the branch." exit 1 fi fi echo "PR for '$BRANCH' is MERGED — cleaning up." elif [ "$FORCE" = 1 ]; then echo "$pr_desc — proceeding due to --force. This may force-remove dirty files and force-delete the branch." else echo "Refusing: $pr_desc." echo "Nothing was changed. Re-run with --force once the PR has merged or to abandon the branch." exit 1 fi cd "$MAIN_WT" echo "Removing worktree: $WT" if [ "$FORCE" = 1 ]; then git worktree remove --force "$WT" else git worktree remove "$WT" || { echo "Error: 'git worktree remove' failed (likely dirty). Re-run with --force after confirming." exit 1 } fi echo "Deleting branch: $BRANCH" git branch -d "$BRANCH" 2>/dev/null || { echo " -d refused after confirmed merge/force — deleting with -D." git branch -D "$BRANCH" } git fetch --prune --quiet || true ROOT=$(dirname "$WT") rmdir "$ROOT" 2>/dev/null && echo "Removed empty worktree root: $ROOT" || true echo "" echo "Done. Worktree removed and branch handled for '$BRANCH'." echo "Main worktree: $MAIN_WT (pull it yourself once it is on the integration branch and clean)." -
setup-worktree.sh 8.2 KB
#!/usr/bin/env bash # Create a git worktree under <project>.worktrees/<branch-slug>. set -euo pipefail ALLOW_DIRTY=false RUN_SETUP=false RUN_TESTS=false BASE_REF="" BRANCH_NAME="" usage() { cat <<'EOF' Usage: setup-worktree.sh [--base <ref>] [--allow-dirty] [--setup] [--test] <branch-name> Creates <project>.worktrees/<branch-slug> from the main worktree root. Refuses dirty current state unless --allow-dirty is passed after user approval. Existing local and origin branches are checked out instead of recreated. Options: --base <ref> Base ref for new branches (default: remote default, local main/master/etc, then HEAD) --allow-dirty Proceed even when the current worktree has uncommitted changes --setup Run detected dependency setup after creation --test Run detected baseline test command after creation EOF } while [ "$#" -gt 0 ]; do case "$1" in --base) shift [ "$#" -gt 0 ] || { echo "Error: --base requires a ref" >&2 exit 2 } BASE_REF=$1 shift ;; --base=*) BASE_REF=${1#--base=} shift ;; --allow-dirty) ALLOW_DIRTY=true shift ;; --setup) RUN_SETUP=true shift ;; --test) RUN_TESTS=true shift ;; -h | --help) usage exit 0 ;; -*) echo "Error: unknown option: $1" >&2 usage >&2 exit 2 ;; *) [ -z "$BRANCH_NAME" ] || { echo "Error: branch name already set: $BRANCH_NAME" >&2 exit 2 } BRANCH_NAME=$1 shift ;; esac done [ -n "$BRANCH_NAME" ] || { usage >&2 exit 2 } git check-ref-format --branch "$BRANCH_NAME" >/dev/null 2>&1 || { echo "Error: invalid branch name: $BRANCH_NAME" >&2 exit 2 } REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) || { echo "Error: not in a git repository" >&2 exit 1 } if [ -n "$(git -C "$REPO_ROOT" status --porcelain)" ] && ! $ALLOW_DIRTY; then echo "Error: current worktree is dirty; commit, stash, or re-run with --allow-dirty after approval" >&2 exit 1 fi PORCELAIN=$(git -C "$REPO_ROOT" worktree list --porcelain) MAIN_WT=$(awk '/^worktree /{sub(/^worktree /, ""); print; exit}' <<<"$PORCELAIN") PROJECT=$(basename "$MAIN_WT") ROOT="$(dirname "$MAIN_WT")/$PROJECT.worktrees" SLUG=$(printf '%s' "$BRANCH_NAME" | tr '/' '-') WORKTREE_PATH="$ROOT/$SLUG" if [ -e "$WORKTREE_PATH" ]; then echo "Error: worktree path already exists: $WORKTREE_PATH" >&2 exit 1 fi if grep -Fxq "branch refs/heads/$BRANCH_NAME" <<<"$PORCELAIN"; then echo "Error: branch is already checked out in another worktree: $BRANCH_NAME" >&2 exit 1 fi has_ref() { git -C "$REPO_ROOT" rev-parse --verify --quiet "$1^{commit}" >/dev/null } detect_base() { local remote_head branch candidate remote_head=$(git -C "$REPO_ROOT" symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null || true) if [ -n "$remote_head" ]; then branch=${remote_head#origin/} if has_ref "$branch"; then printf '%s\n' "$branch" return 0 fi if has_ref "$remote_head"; then printf '%s\n' "$remote_head" return 0 fi fi for candidate in main master trunk develop dev; do if has_ref "$candidate"; then printf '%s\n' "$candidate" return 0 fi done printf 'HEAD\n' } if [ -z "$BASE_REF" ]; then BASE_REF=$(detect_base) fi has_ref "$BASE_REF" || { echo "Error: base ref not found: $BASE_REF" >&2 exit 1 } UPSTREAM=$(git -C "$REPO_ROOT" rev-parse --abbrev-ref --symbolic-full-name "$BASE_REF@{upstream}" 2>/dev/null || true) if [ -n "$UPSTREAM" ]; then DIVERGENCE=$(git -C "$REPO_ROOT" rev-list --left-right --count "$BASE_REF...$UPSTREAM") read -r AHEAD BEHIND <<<"$DIVERGENCE" echo "Base $BASE_REF vs $UPSTREAM: ahead $AHEAD, behind $BEHIND (local tracking data; not fetched)." >&2 fi mkdir -p "$ROOT" if git -C "$REPO_ROOT" show-ref --verify --quiet "refs/heads/$BRANCH_NAME"; then echo "Checking out existing branch at $WORKTREE_PATH..." >&2 git -C "$REPO_ROOT" worktree add "$WORKTREE_PATH" "$BRANCH_NAME" elif git -C "$REPO_ROOT" show-ref --verify --quiet "refs/remotes/origin/$BRANCH_NAME"; then echo "Checking out origin/$BRANCH_NAME at $WORKTREE_PATH..." >&2 git -C "$REPO_ROOT" worktree add --track -b "$BRANCH_NAME" "$WORKTREE_PATH" "origin/$BRANCH_NAME" else echo "Creating worktree at $WORKTREE_PATH from $BASE_REF..." >&2 git -C "$REPO_ROOT" worktree add "$WORKTREE_PATH" -b "$BRANCH_NAME" "$BASE_REF" fi detect_node_manager() { local declared manager="" candidate count=0 declared=$(node -e 'const p=require(process.cwd()+"/package.json"); process.stdout.write(p.packageManager || "")') || return 1 for candidate in npm pnpm yarn bun; do case "$candidate" in npm) [ -f package-lock.json ] || [ -f npm-shrinkwrap.json ] || continue ;; pnpm) [ -f pnpm-lock.yaml ] || continue ;; yarn) [ -f yarn.lock ] || continue ;; bun) [ -f bun.lock ] || [ -f bun.lockb ] || continue ;; esac manager=$candidate count=$((count + 1)) done if [ "$count" -gt 1 ]; then echo "Error: conflicting Node lockfiles; choose the project package manager manually." >&2 return 1 fi if [ -n "$declared" ]; then candidate=${declared%%@*} case "$candidate" in npm | pnpm | yarn | bun) ;; *) echo "Error: unsupported packageManager: $declared" >&2 return 1 ;; esac if [ -n "$manager" ] && [ "$manager" != "$candidate" ]; then echo "Error: packageManager conflicts with lockfile." >&2 return 1 fi manager=$candidate fi if [ -z "$manager" ]; then echo "Error: no packageManager or lockfile; dependency commands need manual selection." >&2 return 1 fi printf '%s\n' "$manager" } run_node_setup() { local manager version manager=$(detect_node_manager) || return 1 if [ ! -f package-lock.json ] && [ ! -f npm-shrinkwrap.json ] && [ ! -f pnpm-lock.yaml ] && [ ! -f yarn.lock ] && [ ! -f bun.lock ] && [ ! -f bun.lockb ]; then echo "Error: frozen setup requires a committed lockfile." >&2 return 1 fi case "$manager" in npm) npm ci ;; pnpm) pnpm install --frozen-lockfile ;; bun) bun install --frozen-lockfile ;; yarn) version=$(yarn --version) || return 1 if [[ "$version" == 1.* ]]; then yarn install --frozen-lockfile; else yarn install --immutable; fi ;; esac } run_setup() { cd "$WORKTREE_PATH" if [ -f package.json ]; then run_node_setup elif [ -f go.mod ]; then go mod download elif [ -f pyproject.toml ]; then uv sync --locked elif [ -f requirements.txt ]; then uv venv && uv pip sync --python .venv/bin/python requirements.txt elif [ -f Cargo.toml ]; then cargo build elif [ -x ./gradlew ]; then ./gradlew testClasses elif [ -f build.gradle ] || [ -f build.gradle.kts ]; then gradle testClasses elif [ -x ./mvnw ]; then ./mvnw -q -DskipTests compile elif [ -f pom.xml ]; then mvn -q -DskipTests compile else echo "No known dependency setup detected; skipped." >&2 fi } run_tests() { cd "$WORKTREE_PATH" if [ -f Makefile ]; then make test elif [ -f package.json ]; then local manager manager=$(detect_node_manager) || return 1 "$manager" run test elif [ -f go.mod ]; then go test ./... elif [ -f pyproject.toml ]; then uv run --locked --extra test python -m pytest elif [ -f requirements.txt ]; then uv run --no-project --python .venv/bin/python python -m pytest elif [ -f Cargo.toml ]; then cargo test elif [ -x ./gradlew ]; then ./gradlew test elif [ -f build.gradle ] || [ -f build.gradle.kts ]; then gradle test elif [ -x ./mvnw ]; then ./mvnw -q test elif [ -f pom.xml ]; then mvn -q test else echo "No known baseline test command detected; skipped." >&2 fi } SETUP_FAILED=false if $RUN_SETUP; then echo "Running dependency setup..." >&2 run_setup || { SETUP_FAILED=true echo "warning: dependency setup failed" >&2 } fi if $RUN_TESTS; then echo "Running baseline tests..." >&2 run_tests || echo "warning: baseline tests failed" >&2 fi cat <<EOF WORKTREE READY ============== Branch: $BRANCH_NAME Path: $WORKTREE_PATH Base: $BASE_REF Next: - cd "$WORKTREE_PATH" - clean up after PR merge with: scripts/cleanup-worktree.sh "$BRANCH_NAME" EOF # Dependency setup failure leaves the worktree unusable for further work # (missing deps break everything run in it), so the script still reports # READY with the path an agent needs to fix it manually, but exits non-zero # to signal the operation needs attention. A failing baseline test suite is # just information (pre-existing/flaky failures are common) and does not # block using the worktree, so it only warns and exits 0. if $SETUP_FAILED; then exit 1 fi
-
-
SKILL.md 2.8 KB
--- description: Creates and removes isolated git worktrees for parallel development. Use when starting feature work needing isolation, working on multiple branches simultaneously, or removing one specific worktree and its branch after its PR merges. NOT for simple branch switching, sweeping multiple stale worktrees or merged branches at once (use cleanup-git), or git hook/config setup (use configuring-git-hygiene). name: using-git-worktrees --- # Git Worktrees A worktree gives parallel work its own folder and branch while the main worktree stays clean on the integration branch. Each project gets one sibling root, `<project>.worktrees/`, with one directory per branch named by its slug (`/` → `-`, so `feature/auth` → `feature-auth`). Remove the worktree and branch after the PR merges. A plain branch switch with no parallel work needs no worktree: check `git status --short`, then `git switch <branch>`. Trivial solo one-liners may also stay in the main worktree. ## Create Check `git status --short` and `git worktree list` first. Leave uncommitted changes where they are and never stash them silently; pass `--allow-dirty` only when the user has authorized isolating new work while keeping them. ```bash scripts/setup-worktree.sh <branch> [--base <ref>] [--allow-dirty] [--setup] [--test] ``` The script runs `git worktree add` from the main worktree root. It checks out an existing local or `origin` branch instead of recreating it, and refuses an existing path, a branch checked out in another worktree, or a dirty tree without `--allow-dirty`. It reports base divergence from local refs without fetching. - `--setup` does a frozen install with the declared package manager and lockfile (uv for Python). Conflicting lockfiles or manager declarations stop it. A setup failure exits non-zero but still prints the path. - `--test` runs the detected baseline tests. Failures only warn. - A skipped setup or test is not a pass. ## Clean up one worktree ```bash scripts/cleanup-worktree.sh [branch] ``` It removes the worktree and deletes the branch only when `gh` confirms the PR is `MERGED`. It also refuses when the branch has commits past the merged PR head. Pass `--force` only after the user confirms the merge without `gh`, confirms those extra commits are throwaway, or abandons the branch; `--force` can remove a dirty worktree and force-delete the branch. For bulk or stale cleanup, use cleanup-git. Leave `git pull` out of cleanup; the user pulls the main worktree once it is clean on the integration branch. For cases the scripts refuse or don't cover, read [workflow.md](references/workflow.md). ## Output ```text WORKTREE READY | WORKTREE REMOVED | BLOCKED Branch: <branch> Path: <project>.worktrees/<slug> Next: cd <path>, or the script's refusal reason ``` Report a cleanup as done only when the PR is confirmed `MERGED` or `--force` was deliberate.
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.