Scripting a worktree-per-ticket workflow Jump to heading
Worktrees stop being used when the setup cost outweighs the benefit. Creating one takes a second; making it usable takes a branch name, a dependency install, a copy of the ignored local configuration, and remembering where you put it. Do that by hand four times and the technique quietly reverts to stashing. Two short scripts fix it, and the second is where the care goes — teardown is the step that can lose work. This recipe builds both, within Git worktrees and parallel development.
When to use this approach Jump to heading
- You switch between two or three pieces of work in a day.
- Setting up a worktree by hand has already stopped you using one.
- Your project needs more than a checkout to be runnable.
- Branch names follow a convention a script can construct.
- If your project runs with no install step and no local configuration, the plain commands are short enough already.
Step 1 — Decide the layout before writing anything Jump to heading
A predictable layout is what lets everything else be derived rather than typed.
# ~/work/app the main clone, on the default branch
# ~/work/app-PAY-812 a worktree for one ticket
# ~/work/app-review a reusable review worktree # Verification: the derivation is unambiguous from a ticket id alone
ticket=PAY-812
root=$(git rev-parse --show-toplevel)
echo "$(dirname "$root")/$(basename "$root")-$ticket" Step 2 — Write the create script Jump to heading
#!/usr/bin/env sh
# wt-new — create a worktree for a ticket, ready to run.
set -eu
ticket="${1:?usage: wt-new TICKET [slug]}"
slug="${2:-}"
root=$(git rev-parse --show-toplevel)
parent=$(dirname "$root")
dir="$parent/$(basename "$root")-$ticket"
branch="feat/$ticket${slug:+-$slug}"
[ -e "$dir" ] && { echo "wt-new: $dir already exists" >&2; exit 1; }
git -C "$root" fetch --quiet origin
git -C "$root" worktree add -b "$branch" "$dir" origin/HEAD
# Everything version control does not carry:
for f in .env.local .env.development .tool-versions; do
[ -f "$root/$f" ] && cp "$root/$f" "$dir/$f"
done
( cd "$dir" && [ -f package-lock.json ] && npm ci --silent ) || true
printf '%s\n' "$dir" # Verification: the worktree exists, is on the right branch, and runs
dir=$(wt-new PAY-812 refund-window)
git -C "$dir" symbolic-ref --short HEAD
(cd "$dir" && npm test >/dev/null 2>&1 && echo "runnable") Printing the path as the only output is deliberate: it makes the script composable, so cd "$(wt-new PAY-812)" works.
Step 3 — Write the teardown script with guards Jump to heading
Removal is the step that can destroy work, so the script refuses rather than forcing, and says exactly what it found.
#!/usr/bin/env sh
# wt-done — remove a ticket worktree, refusing if anything would be lost.
set -eu
ticket="${1:?usage: wt-done TICKET}"
root=$(git rev-parse --show-toplevel)
dir="$(dirname "$root")/$(basename "$root")-$ticket"
[ -d "$dir" ] || { echo "wt-done: no worktree at $dir" >&2; exit 1; }
branch=$(git -C "$dir" symbolic-ref --quiet --short HEAD || echo '')
dirty=$(git -C "$dir" status --porcelain | wc -l)
[ "$dirty" -eq 0 ] || {
echo "wt-done: $dirty uncommitted change(s) in $dir — commit or stash first:" >&2
git -C "$dir" status --short >&2
exit 1
}
if [ -n "$branch" ] && ! git -C "$root" merge-base --is-ancestor "$branch" origin/HEAD; then
n=$(git -C "$root" rev-list --count "origin/HEAD..$branch")
echo "wt-done: $branch has $n commit(s) not on the default branch." >&2
echo " Push them, or re-run with KEEP_BRANCH=1 to remove only the directory." >&2
[ "${KEEP_BRANCH:-0}" = 1 ] || exit 1
fi
git -C "$root" worktree remove "$dir"
[ "${KEEP_BRANCH:-0}" = 1 ] || git -C "$root" branch -d "$branch" 2>/dev/null || true
echo "removed $dir" # Verification: both guards fire
touch "$dir/scratch.txt" && wt-done PAY-812 || echo "refused on untracked file, as intended"
rm "$dir/scratch.txt" && wt-done PAY-812 SAFETY WARNING — resist adding a
--forceflag to the teardown script. The guards exist because removing a worktree destroys uncommitted and untracked files with no recovery path, and a flag that skips them will be used reflexively the first time the script is inconvenient. If a removal genuinely must proceed, runninggit worktree remove --forceby hand keeps the decision deliberate.
Step 4 — Add a listing command you will actually read Jump to heading
#!/usr/bin/env sh
# wt-ls — what exists, what is dirty, and what is already merged.
set -eu
git worktree list --porcelain | awk '/^worktree /{print $2}' | while read -r wt; do
br=$(git -C "$wt" symbolic-ref --quiet --short HEAD 2>/dev/null || echo '(detached)')
dirty=$(git -C "$wt" status --porcelain 2>/dev/null | wc -l | tr -d ' ')
merged=no
git merge-base --is-ancestor "$br" origin/HEAD 2>/dev/null && merged=yes
printf '%-40s %-28s dirty:%-4s merged:%s\n' "$wt" "$br" "$dirty" "$merged"
done # Verification: the output is short enough to scan
wt-ls Step 5 — Keep the scripts in the repository Jump to heading
Scripts that live in one person’s home directory help one person. Committed, they become part of how the project is worked on.
mkdir -p scripts && cp wt-new wt-done wt-ls scripts/
chmod +x scripts/wt-*
git add scripts/wt-* && git commit -m "chore: add worktree helper scripts" # Verification: they work from a fresh clone
git clone "$(git remote get-url origin)" /tmp/fresh && (cd /tmp/fresh && ./scripts/wt-ls) The provisioning step — which files to copy, which install command to run — is project-specific, which is exactly the argument for keeping it in the project rather than in a personal dotfiles repository. The broader version of that argument is in bootstrapping a developer machine for Git.
Validation checklist Jump to heading
Frequently Asked Questions Jump to heading
Should the script create the branch from the default branch or from my current one? Jump to heading
From the default branch, fetched fresh, unless you are deliberately stacking work. Branching from whatever happens to be checked out is how a ticket ends up containing an unrelated half-finished change, which is discovered at review time.
What about worktrees for reviewing other people’s branches? Jump to heading
Keep one reusable review directory rather than one per pull request, and re-point it with a detached checkout. That keeps the dependency install warm and stops the review worktrees from accumulating — the approach in reviewing a pull request in a second worktree.
Can the teardown run automatically when a pull request merges? Jump to heading
It can, and it should still refuse on a dirty directory. A merge notification triggering a local teardown is convenient, but the guards matter more when the trigger is automatic than when a person typed the command.
Related Jump to heading
- Git Worktrees & Parallel Development — the parent topic and the conventions these scripts assume.
- Cleaning Up Stale Worktrees Safely — the manual version of what
wt-doneautomates. - Worktrees vs Multiple Clones — when to reach for a clone instead.