Skip to content

Instantly share code, notes, and snippets.

@kimdhamilton
Created April 15, 2026 17:38
Show Gist options
  • Select an option

  • Save kimdhamilton/72f0c598bc34ed8b8674ff81a00cb0a7 to your computer and use it in GitHub Desktop.

Select an option

Save kimdhamilton/72f0c598bc34ed8b8674ff81a00cb0a7 to your computer and use it in GitHub Desktop.
Worktree Helpers
# ═══════════════════════════════════════════════════════════════════════════════
# wt — git worktree helpers
#
# About: creates/manages git worktrees as peers of your main repo.
# For example: running the following in your repo base dir (call it "foo")
#
# wt -b bar
#
# Creates a worktree located at
#
# ../foo-bar/
#
# And the "-b" option indicates to also create a git branch "bar". Details below.
#
# Customize: the REPLACE block inside `wt` runs repo-specific dep install.
# Edit it for your repo, or ask Claude/Copilot to adapt it for you.
#
# Install: (Note this assumes zsh, but you can ask your AI to adapt for you)
# 1. Save directly in ~/.zshrc, or save as wt.zsh and source it from ~/.zshrc.
# 2. Run `source ~/.zshrc` in your terminal, or open a new one.
#
# Usage:
# wt -b fix/1099 create branch fix/1099 off origin's default, cd in
# wt -bi fix/1099 also run repo-specific dep install (opt in; slow)
# wt feature-x check out feature-x into ../<repo>-feature-x, cd in
# wt-ls list worktrees, annotated with open PR # + title
# wt-rm fix/1099 remove worktree, keep branch
# wt-rm -d fix/1099 also delete branch (refuses if unmerged)
# wt-rm -D experiment force-delete branch regardless
#
# Why peer worktrees: `../<repo>-<name>/` sits as a sibling of the main
# repo, so editor file trees and file watchers stay isolated. Semantic
# branch names survive (no auto-generated).
# ═══════════════════════════════════════════════════════════════════════════════
wt() {
if ! git rev-parse --git-dir >/dev/null 2>&1; then
echo "Error: not inside a git repository" >&2
return 1
fi
local create_branch=false install_deps=false
while [[ "$1" == -* ]]; do
case "$1" in
-b) create_branch=true ;;
-i) install_deps=true ;;
-bi|-ib) create_branch=true; install_deps=true ;;
--) shift; break ;;
*) echo "Unknown flag: $1" >&2; return 1 ;;
esac
shift
done
if [[ -z "$1" ]]; then
echo "Usage: wt [-b] [-i] branch-name"
echo " wt branch Check out existing branch into peer worktree"
echo " wt -b branch Create new branch and check out into peer worktree"
echo " wt -i branch Also run repo-specific dep install"
return 1
fi
local main_wt repo slug worktree default_branch
main_wt=$(git worktree list --porcelain | head -1 | sed 's/^worktree //')
repo=$(basename "$main_wt")
# `fix/foo` → flat peer dir, not nested.
slug="${1//\//-}"
worktree="$(dirname "$main_wt")/${repo}-${slug}"
default_branch=$(git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null \
| sed 's@^refs/remotes/origin/@@')
default_branch="${default_branch:-main}"
if $create_branch; then
git fetch origin "$default_branch"
git worktree add "$worktree" -b "$1" "origin/$default_branch"
git -C "$worktree" branch --unset-upstream
else
git fetch origin "$1" 2>/dev/null || true
git worktree add "$worktree" "$1"
fi || return 1
# ── REPLACE FOR YOUR REPO ───────────────────────────────────────────────────
# Dep install, gated by -i because npm install is slow and often unnecessary.
# Edit for your repo, or ask Claude/Copilot to adapt it against your setup.
if $install_deps; then
if [[ -f "$worktree/backend/pyproject.toml" ]]; then
(cd "$worktree/backend" \
&& poetry config virtualenvs.in-project true --local \
&& poetry install --quiet) || { echo "Error: poetry install failed" >&2; return 1; }
fi
if [[ -f "$worktree/frontend/package.json" ]]; then
(cd "$worktree/frontend" && npm install --silent) \
|| { echo "Error: frontend npm install failed" >&2; return 1; }
fi
fi
# ── END REPO-SPECIFIC BLOCK ─────────────────────────────────────────────────
echo "Worktree ready at $worktree"
cd "$worktree"
}
# ── wt-rm [-d|-D] <branch> ────────────────────────────────────────────────────
# Removes the peer worktree for <branch>. Keeps the branch by default (may
# still be under review). With -d, also deletes it (refuses if unmerged, per
# `git branch -d`). With -D, force-deletes regardless.
wt-rm() {
if ! git rev-parse --git-dir >/dev/null 2>&1; then
echo "Error: not inside a git repository" >&2
return 1
fi
local delete_branch=false force_delete=false
while [[ "$1" == -* ]]; do
case "$1" in
-d) delete_branch=true ;;
-D) delete_branch=true; force_delete=true ;;
--) shift; break ;;
*) echo "Unknown flag: $1" >&2; return 1 ;;
esac
shift
done
if [[ -z "$1" ]]; then
echo "Usage: wt-rm [-d|-D] branch-name" >&2
echo " wt-rm branch Remove worktree, keep branch"
echo " wt-rm -d branch Also delete branch (refuses if unmerged)"
echo " wt-rm -D branch Also delete branch, forced"
return 1
fi
local branch="$1"
local main_wt repo slug target
local -a force_flag=()
main_wt=$(git worktree list --porcelain | head -1 | sed 's/^worktree //')
repo=$(basename "$main_wt")
slug="${branch//\//-}"
target="$(dirname "$main_wt")/${repo}-${slug}"
# Skip worktree-remove if the dir is already gone, so `wt-rm -D` can
# recover a branch left orphaned by an earlier `-d` that refused the delete.
if [[ -d "$target" ]]; then
# Pre-flight dirty/untracked checks for a friendlier error than git's.
if ! git -C "$target" diff --quiet || ! git -C "$target" diff --cached --quiet; then
echo "Error: $target has uncommitted changes." >&2
echo " Commit or stash them, or run: git worktree remove --force \"$target\"" >&2
return 1
fi
if [[ -n "$(git -C "$target" ls-files --others --exclude-standard)" ]]; then
echo "Warning: $target has untracked files. They will be lost on removal."
echo -n "Continue? [y/N] "
read -r answer
[[ "$answer" =~ ^[Yy]$ ]] || return 1
# User confirmed: pass --force to override git's untracked-file guard.
force_flag=(--force)
fi
git worktree remove "${force_flag[@]}" "$target" || return 1
fi
# Delete after worktree removal so git doesn't refuse on "checked out elsewhere".
if $delete_branch; then
local branch_flag="-d"
$force_delete && branch_flag="-D"
git branch "$branch_flag" "$branch"
fi
}
# ── wt-ls ─────────────────────────────────────────────────────────────────────
# Lists worktrees with open PR # and title. Falls back to plain
# `git worktree list` if gh is missing or fails.
wt-ls() {
if ! command -v gh >/dev/null 2>&1; then
git worktree list
return
fi
# Tab-separated output avoids needing jq / JSON parsing downstream.
local prs
prs=$(gh pr list --limit 200 \
--json number,title,headRefName \
--template '{{range .}}{{.headRefName}} {{.number}} {{.title}}{{"\n"}}{{end}}' \
2>/dev/null) || { git worktree list; return; }
# Build branch→PR map, then emit "<path> <branch> [PR #N: title]" per worktree.
# ENVIRON preserves embedded newlines on BSD awk. The env prefix must go
# on `awk`, not `git` — each pipeline side gets its own environment.
git worktree list --porcelain | WT_PRS="$prs" awk '
BEGIN {
n = split(ENVIRON["WT_PRS"], lines, "\n")
for (i = 1; i <= n; i++) {
if (lines[i] == "") continue
split(lines[i], f, "\t")
map[f[1]] = "PR #" f[2] ": " f[3]
}
}
/^worktree / { path = substr($0, 10) }
/^branch / { br = substr($0, 8); sub("^refs/heads/", "", br) }
/^$/ {
if (path != "") {
line = path
if (br != "") line = line " " br
if (br != "" && br in map) line = line " [" map[br] "]"
print line
path = ""; br = ""
}
}'
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment