worktree-manager · diff
git:20261023.c9bcdda to git:20261023.46e7cc7
30 added, 16 removed. Audit A to A.
---
name: worktree-manager
description: Create, list, and tear down git worktrees following Kanopi's branch naming conventions so developers and agents can work multiple tickets in parallel without clobbering each other's working tree. Creates a new branch, or attaches a worktree to a branch that already exists locally or on origin. Handles DDEV isolation automatically (folder-derived project name, no pinned ports) for Drupal and WordPress, and plain worktree setup for Next.js. Invoke when the user wants to start a ticket in a fresh worktree, run more than one Claude Code or Claude Desktop session at once, says "create a worktree", "new worktree for tw1234", "spin up a parallel branch", "work on two tickets at once", "worktree for an existing branch", "check out this branch in a worktree", "pick up a teammate's branch", "resume a branch I already have", "/worktree-manager", or asks how to clean up a finished worktree. Use this skill even if the user only says "I need to work on another ticket without losing my current one."
---
# Worktree Manager
Create and tear down git worktrees that follow Kanopi's branch conventions and stay isolated at the DDEV layer, so multiple tickets — and multiple AI sessions — can run side by side from a single clone.
## Why this exists
A worktree checks out a second branch into its own directory while sharing one `.git`. That is the primitive that lets two Claude Code sessions (or one CLI session and one Claude Desktop "code" session) work different tickets at the same time without `git checkout` thrash or stepping on each other's files. It maps one-to-one onto Kanopi's "one branch per ticket" rule.
## Usage
- - `/worktree-manager create <ticket-id> <short-desc> [--type feature|bug|hotfix] [--base main]` — create a worktree on a **new** branch
+ - `/worktree-manager create <ticket-id> <short-desc> [--type feature|bug|hotfix|devops] [--base <default-branch>]` — create a worktree on a **new** branch
- `/worktree-manager create --branch <existing-branch> [<ticket-id>] [--dir <path>]` — attach a worktree to a branch that **already exists** (locally or on origin)
- `/worktree-manager list` — show active worktrees and their DDEV status
- `/worktree-manager remove <ticket-id|branch|path> [--delete-branch]` — tear down a worktree (destructive; requires confirmation)
- If the user gives a Teamwork URL or task ID, parse the numeric ID from it (e.g. `tw1234`).
+ If the user gives a Teamwork URL or task ID, parse the numeric ID from it: `tw1234` and a Teamwork task URL both yield `1234`. Branch names use the bare number.
+ **Default branch.** Never assume `main` — many Kanopi repos use `master`. Resolve it once and use it wherever this skill says `<default-branch>`:
+
+ ```bash
+ DEFAULT=$(gh repo view --json defaultBranchRef --jq .defaultBranchRef.name 2>/dev/null \
+ || git symbolic-ref refs/remotes/origin/HEAD | sed 's@^refs/remotes/origin/@@')
+ ```
+
`--branch` is mutually exclusive with `<short-desc>`, `--type`, and `--base` — all three exist only to *derive* a new branch name, and there is nothing to derive when the branch is given. If the user supplies both, use `--branch` and say in the report that the others were ignored.
## Naming convention
- Branch (unchanged from Kanopi standards):
+ Branch — Kanopi standard, the bare Teamwork ID with no `tw` prefix:
```
- <type>/tw<ticketID>-<short-desc>
- # feature/tw1234-hero-block
- # bug/tw1235-menu-fix
- # hotfix/tw1236-login-error
+ <type>/<ticketID>-<short-desc>
+ # feature/1234-hero-block
+ # bug/1235-menu-fix
+ # hotfix/1236-login-error
+ # devops/1237-add-ddev
```
+ Types: `feature`, `bug`, `hotfix`, `devops` (infrastructure, tooling, CI/CD, dev environment). `commit-message-generator` reads the `Refs:` footer from this shape, so keep it exact.
+
For a sub-task or dependent feature, use the **parent** task ID, matching the complex-feature parent/child model.
- Worktree directory — a sibling of the main clone, named to mirror the branch:
+ Worktree directory — a sibling of the main clone, named for the ticket. The directory keeps the `tw` prefix: it becomes the DDEV project name and hostname, where `tw` makes the ticket readable at a glance.
```
- <repo>/ # main clone, stays on main
- <repo>-tw1234/ # worktree for feature/tw1234-hero-block
- <repo>-tw1235/ # worktree for bug/tw1235-menu-fix
+ <repo>/ # main clone, stays on the default branch
+ <repo>-tw1234/ # worktree for feature/1234-hero-block
+ <repo>-tw1235/ # worktree for bug/1235-menu-fix
```
Keep worktrees as siblings (not nested inside the main clone) so build tooling, IDE indexers, and DDEV file mounts don't recurse into them.
### Directory name when there is no ticket ID
`--branch` may point at a branch with no ticket ID in it — a colleague's branch, a long-lived `develop`, a branch that predates the convention. Resolve `<worktree-dir>` in this order:
1. `--dir` if the user gave one.
- 2. `<repo>-tw<id>` if a ticket ID was supplied *or* the branch name contains `tw<digits>`.
+ 2. `<repo>-tw<id>` if a ticket ID was supplied, or the branch name carries one — `<type>/<digits>-…`, or the older `<type>/tw<digits>-…`:
+
+ ```bash
+ ID=$(printf '%s' "$BRANCH" | sed -nE 's@^[a-z]+/(tw)?([0-9]+)-.*@\2@p')
+ ```
3. Otherwise `<repo>-<branch-slug>`, where the slug lowercases the branch and collapses every run of non-alphanumeric characters to a single `-`:
```bash
SLUG=$(printf '%s' "$BRANCH" | tr '[:upper:]' '[:lower:]' | tr -c 'a-z0-9' '-' | tr -s '-' | sed 's/^-//; s/-$//')
# update/worktree-manager-existing-branch → update-worktree-manager-existing-branch
# ../<repo>-update-worktree-manager-existing-branch
```
Prefer the ticket-ID form whenever one is available: the DDEV project name is folder-derived, so the directory name is what the developer will see in `ddev list` and type into teardown.
## Environment Detection
### Tier 1 — Portable (Claude Desktop, Codex, any environment)
When `Bash`/git execution is unavailable, output the exact commands for the user to run, then explain how to point their environment at the new directory:
- **Claude Code CLI:** `cd ../<worktree-dir>` then launch a session there.
- **Claude Desktop (code section):** open the worktree directory as the project folder. Run one Desktop project per worktree if working two in parallel.
### Tier 2 — Enhanced (Claude Code with Bash/git)
Run the steps directly, pausing at the confirmation gate for destructive actions.
## Create workflow
1. **Resolve inputs.**
- With `--branch`, that name is `BRANCH` verbatim — do not normalize it, do not add a `<type>/` prefix, do not correct its spelling to match the convention. Otherwise derive `BRANCH=<type>/tw<id>-<short-desc>` from the ticket ID, short description (kebab-case, ≤4 words), and type (default `feature`), branching from `--base` (default `main`).
+ With `--branch`, that name is `BRANCH` verbatim — do not normalize it, do not add a `<type>/` prefix, do not correct its spelling to match the convention. Otherwise derive `BRANCH=<type>/<id>-<short-desc>` from the ticket ID, short description (kebab-case, ≤4 words), and type (default `feature`), branching from `--base` (default: `$DEFAULT`).
Then resolve `<worktree-dir>` per **Directory name when there is no ticket ID** above.
2. **Sync** so a new branch starts current and a remote-only branch is visible locally:
```bash
git fetch origin
```
3. **Determine whether the branch already exists.** Check — do not infer from whether the user passed `--branch`. A user can name an existing branch without the flag, and `--branch` pointed at a typo should fail loudly rather than silently create a branch under a misspelled name:
```bash
if git show-ref --verify --quiet "refs/heads/$BRANCH"; then
echo local
elif git ls-remote --exit-code --heads origin "$BRANCH" >/dev/null 2>&1; then
echo remote-only
else
echo new
fi
```
If the user passed `--branch` and the result is `new`, **stop**. Do not create the branch, do not fall back to the new-branch form, and do not proceed to step 4. `--branch` means "attach to what exists"; a missing branch is almost always a typo or a missing fetch, and creating it silently produces an empty branch off the wrong base. Report it in exactly this form, then offer the new-branch command as the fix and wait:
```
BRANCH NOT FOUND: <branch>
```
This holds no matter how confident the user is that the branch exists. Their certainty is not a substitute for `show-ref`, and "make it work" is not authorization to create a different thing than the one they named.
4. **Check whether the branch is already checked out somewhere.** Git refuses to check out one branch in two worktrees, and the raw error is opaque. Look first:
```bash
git worktree list --porcelain \
| awk -v b="refs/heads/$BRANCH" '$1=="worktree"{p=$2} $1=="branch"&&$2==b{print p}'
```
If that prints a path, **do not create a second worktree**. Report the existing path — the branch is already available there, which is usually the whole answer. The main clone counts: if it is sitting on the requested branch, that is the hit. Only if the user explicitly wants a duplicate checkout is `--force` appropriate, and it should be their call, not the default.
5. **Create the worktree**, using the form that matches step 3:
**New branch** (the common case) — branch off the up-to-date remote base:
```bash
git worktree add ../<worktree-dir> -b "$BRANCH" origin/<base>
```
**Existing local branch** — omit `-b` so git checks the branch out instead of trying to recreate it:
```bash
git worktree add ../<worktree-dir> "$BRANCH"
```
**Existing remote-only branch** — the same form works: git's DWIM creates a local branch tracking the remote when the name matches exactly one remote, which the fetch in step 2 guarantees is current. Be explicit if you'd rather not rely on DWIM:
```bash
git worktree add ../<worktree-dir> -b "$BRANCH" --track "origin/$BRANCH"
```
Do **not** write `git worktree add ../<worktree-dir> "origin/$BRANCH"` — that checks out the remote-tracking ref and leaves the worktree in **detached HEAD**, where commits belong to no branch and a later `git worktree remove` discards them without warning.
6. **Report freshness on an existing branch** (skip for a new branch — it was just cut from a fetched base):
```bash
git -C ../<worktree-dir> status -sb | head -1 # ## branch...origin/branch [behind 3]
```
State the ahead/behind counts. If the branch is behind, **offer** the fast-forward rather than running it:
```bash
git -C ../<worktree-dir> merge --ff-only "origin/$BRANCH"
```
Never silently `pull` or `rebase` a branch the user did not create in this session — local-only commits and in-progress work are exactly what an existing branch tends to carry.
7. **Run platform setup** — see Platform Notes below.
8. **Report** the directory path, branch name, whether the branch was created or attached, freshness if attached, any flags ignored, and (for DDEV projects) the local URL from `ddev describe`, plus the Tier 1 instructions for attaching a CLI or Desktop session.
## Platform Notes
### Drupal & WordPress (DDEV)
DDEV isolation is automatic when the project name is folder-derived — i.e. `name:` is omitted from `.ddev/config.yaml`, so DDEV uses the directory name. Each worktree then gets its own hostname, database, and auto-assigned host ports, and the shared router keeps them from colliding. This is DDEV's documented recommendation for worktrees.
If a project still commits a `name:` (or pins host ports), the second worktree will collide when it runs `ddev start`. Don't try to fix that repo-wide as part of this skill — surface the collision clearly and let the developer resolve it in their own config when they hit it. The quick local fix is a gitignored `config.local.yaml` in the worktree with a unique `name:`; the cleaner long-term fix is dropping the committed `name:` line on that project, but that's the team's call, not this skill's.
Setup inside the new worktree:
```bash
cd ../<worktree-dir>
ddev start
ddev composer install
ddev theme-install # WP/Drupal theme deps + build
ddev db-refresh # each worktree gets its own DB
```
> Cost note: a worktree starts with an empty database, so it needs its own `ddev db-refresh` (or an imported snapshot). `vendor/` and `node_modules/` are directory-local, so they install per worktree — isolation by design, at the cost of disk + setup time.
On push, CircleCI spins up a per-branch Pantheon multidev, so each worktree's branch also gets its own remote preview without extra steps.
### Next.js (headless/decoupled)
No DDEV. Create the worktree, then install and run the dev server on a distinct port:
```bash
cd ../<worktree-dir>
npm install
npm run dev -- -p 3001 # use a different port per parallel worktree
```
If platform is ambiguous (no `.ddev/`, no `next.config.*`), ask which platform is in scope before running setup.
## Remove workflow (destructive — confirm first)
The target may be a ticket ID, a branch name, or a directory path. Resolve it to one worktree before doing anything else, and stop if it matches none or more than one:
```bash
git worktree list --porcelain # match on the worktree path or its branch line
```
- **Report the branch, not the folder name.** Take `BRANCH` from that worktree's own `branch` line (or `git -C ../<worktree-dir> branch --show-current`), never from the directory name or the convention. Nothing enforces that `<repo>-tw1234` still holds tw1234's branch — checkouts get reused, and the ticket the user names may have no worktree at all. If the branch disagrees with the ticket the user asked for, say so in the gate and let them confirm the target. Otherwise the gate describes tearing down one ticket while it actually tears down another.
+ **Report the branch, not the folder name.** Take `BRANCH` from that worktree's own `branch` line (or `git -C ../<worktree-dir> branch --show-current`), never from the directory name or the convention — worktrees made before the `tw` prefix was dropped from branch names are on `<type>/tw<id>-…` branches. Nothing enforces that `<repo>-tw1234` still holds tw1234's branch — checkouts get reused, and the ticket the user names may have no worktree at all. If the branch disagrees with the ticket the user asked for, say so in the gate and let them confirm the target. Otherwise the gate describes tearing down one ticket while it actually tears down another.
**Confirmation gate.** Your response **must start immediately** with the removal header — no preamble, no "I'll go ahead and". List exactly what will be deleted and wait for explicit approval:
```
=== WORKTREE REMOVAL READY FOR APPROVAL ===
Worktree: ../<worktree-dir>
- Branch: <branch> (merged into main: yes/no)
+ Branch: <branch> (merged into <default-branch>: yes/no)
DDEV project: <project-name> (database will be deleted)
Local branch: KEEP / DELETE
Uncommitted: <count> file(s) (list them, or "clean")
```
Then run the steps in this order:
1. **Verify the branch is merged** (or the user confirms abandoning it):
```bash
- git branch --merged main --format='%(refname:short)' | grep -Fx "$BRANCH"
+ git branch --merged "$DEFAULT" --format='%(refname:short)' | grep -Fx "$BRANCH"
```
Use `--format`, not bare `git branch --merged` — the plain output pads names with two spaces and marks the current branch with `* `, so a naive `grep` on the branch name misses it.
2. **Tear down DDEV** for that project (snapshot kept by default for safety):
```bash
ddev delete -Oy <project-name> # omit -O to keep a recovery snapshot
```
Do this before removing the directory — the project name is folder-derived, so it is harder to resolve once the folder is gone.
3. **Remove the worktree and prune:**
```bash
git worktree remove ../<worktree-dir>
git worktree prune
```
**Check the exit code — this command half-succeeds.** It deletes tracked files but refuses to delete ignored ones, so on any repo with `node_modules/`, `vendor/`, `playwright-report/` or a build cache it fails with `error: failed to delete '<path>': Directory not empty` *after* the tracked tree is already gone. `git worktree prune` then clears the registration, so `git worktree list` reports clean while the directory is still on disk — a silent orphan (253M in the case this was found). `git status --porcelain` will not have warned you: it hides ignored files, so the checkout reads as "0 changes" right up until removal fails.
Finishing it means `rm -rf`, so prove the directory is disposable first. Set both paths explicitly — absolute, no trailing slash:
```bash
MAIN=/abs/path/to/<repo> # the main clone
D=/abs/path/to/<worktree-dir> # the orphaned directory
# a. It must not still be a registered worktree, or this is live work.
git -C "$MAIN" worktree list --porcelain | grep -Fqx "worktree $D" && { echo "ABORT: still registered"; exit 1; }
# b. Nothing unique may remain. List files in the orphan that the main clone lacks,
# skipping only directories that a reinstall or rebuild regenerates.
REGEN='-path ./node_modules -o -path ./vendor -o -path ./.git -o -path ./playwright-report -o -path ./.cache'
comm -23 <(cd "$D" && find . \( $REGEN \) -prune -o -type f -print | sort) \
<(cd "$MAIN" && find . \( $REGEN \) -prune -o -type f -print | sort)
# c. Guard the path so a bad variable cannot expand into something catastrophic.
case "$D" in "$(dirname "$MAIN")"/?*) rm -rf -- "$D";; *) echo "ABORT: unexpected path: $D"; exit 1;; esac
```
Expect (b) to report only DDEV's generated `.ddev/traefik/` certs and config for the deleted project (the orphaned `.git` pointer file is skipped with `./.git`). Anything else — an untracked script, a `.env`, an unread report — stops the deletion until the user has seen it. Add a project's other regenerable directories to `REGEN` only when you can name the command that rebuilds them.
4. **Delete the local branch — only if the user opted in.** Default to `KEEP`. Delete only when the user passed `--delete-branch` or approved a gate that said `DELETE`:
```bash
git branch -d "$BRANCH"
```
Use `-D` only with explicit confirmation — it discards unmerged work.
**Default to `KEEP` for any branch this worktree did not create.** Removing a worktree is a statement about a directory; deleting a branch is a statement about the work. For a branch that already existed — a teammate's, a shared long-lived one, one with an open PR — those come apart, and a branch deleted on the developer's behalf is the one loss in this workflow that a re-run cannot undo.
## Quality Gates
Before reporting a create as complete:
1. ✅ Worktree directory exists and is on the expected branch (`git -C ../<worktree-dir> branch --show-current`) — and is **not** in detached HEAD
2. ✅ **New branch:** `git fetch origin` ran, and the branch was cut from `origin/<base>`
3. ✅ **Existing branch:** the branch resolved (local or remote-only, never silently created), was not already checked out in another worktree, has a local tracking branch rather than a detached HEAD, and its ahead/behind state was reported — along with any `--type` / `--base` / `<short-desc>` that were ignored
4. ✅ For DDEV projects: the site responds (`ddev describe`)
Before a remove:
1. ✅ Target resolved to exactly one worktree
2. ✅ The branch actually checked out in that directory was reported, and is the one the user means
3. ✅ User explicitly approved the deletion, after seeing the removal header
4. ✅ Branch is merged, or user confirmed abandoning unmerged work
5. ✅ DDEV project torn down before the directory is removed
After a remove — a half-finished teardown reports success, so confirm each layer:
6. ✅ `git worktree list` no longer lists it **and** the directory is absent from disk (`ls -d <path>`)
7. ✅ `ddev list` no longer shows the project
8. ✅ The local branch was deleted only on explicit opt-in — otherwise it still exists — and any `-BAK` branches and stashes still exist (stashes live in the main clone's `refs/stash` and are shared across worktrees — if they vanished, something deleted more than it should have)
## Notes
- Never create a worktree inside the main clone's working tree.
- Don't reuse a ticket ID across two live worktrees.
- Never leave a worktree in detached HEAD — commits made there are unreachable once the worktree is removed.
- This skill changes local state only; it never force-pushes or deletes remote branches.
## Red flags (self-talk — stop if you catch yourself thinking these)
- "The branch probably exists, I'll just omit `-b`" (CANT-14) — check with `show-ref` first; guessing wrong produces either an opaque error or a branch created under a typo.
- "They said the branch exists but it doesn't — I'll create it so the command succeeds" (CANT-24). A `--branch` that doesn't resolve is a stop, not a fallback; creating it redefines the task as whatever was achievable.
- "`origin/<branch>` is close enough" — it is a detached HEAD, and the difference only surfaces after the developer has committed into it.
- "It's behind origin, I'll just pull to be helpful" — an existing branch is the case most likely to carry local-only work.
- "They're removing the worktree, so they obviously want the branch gone too" (CANT-1). Two different decisions; the second one needs its own approval.