Created
April 15, 2026 17:38
-
-
Save kimdhamilton/72f0c598bc34ed8b8674ff81a00cb0a7 to your computer and use it in GitHub Desktop.
Worktree Helpers
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # ═══════════════════════════════════════════════════════════════════════════════ | |
| # 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