Skip to content

Instantly share code, notes, and snippets.

@liddellj
Last active May 26, 2026 16:27
Show Gist options
  • Select an option

  • Save liddellj/e47df8061f583ffc9e16b56c1578cd00 to your computer and use it in GitHub Desktop.

Select an option

Save liddellj/e47df8061f583ffc9e16b56c1578cd00 to your computer and use it in GitHub Desktop.
wt — git worktree helper

wt — git worktree helper

A shell helper for managing git worktrees with coloured terminal tabs and VS Code workspaces.

Installation

Add to your ~/.zshrc (or ~/.bashrc):

source ~/wt.sh

Then reload your shell: source ~/.zshrc

Usage

wt [--code] <repo-path> <branch> [base-branch]   Create a new worktree
wt ls                                             List all worktrees
wt open [--no-code] [branch]                      Open a worktree in VS Code
wt rm [branch]                                    Remove a worktree and its branch

Create a worktree (branched from develop by default):

wt ~/dev/myrepo feature/my-thing

Create and open in VS Code immediately:

wt --code ~/dev/myrepo feature/my-thing

List, open, and remove accept an optional branch name; if omitted, an interactive selector is shown.

What it does

  • Creates a worktree under ~/dev/wt/<repo>-<branch>
  • Assigns each branch a unique colour + emoji pair (used for the VS Code title bar and terminal tab)
  • Copies any .env / .env.local / .env.e2e.local files from the main repo into the new worktree
  • Writes a .code-workspace file so VS Code opens with a colour-coded title bar
  • Runs npm install / yarn / pnpm install in the background
  • Opens Claude Code in the new worktree directory

Customisation

What Where Default
Worktree root directory wt(), line 249 ~/dev/wt/
Default base branch wt(), line 244 develop
Colour palette (VS Code title bar) _wt_COLOURS, line 31 5 dark colours
Terminal output colours _wt_ANSI_COLOURS, line 35 Bright equivalents of above
Emoji pairs _wt_EMOJIS_A / _wt_EMOJIS_B, lines 32–33 Various

Requirements

  • git with worktree support (2.5+)
  • zsh or bash
  • code CLI in PATH (for VS Code integration) — install via VS Code: Shift+Cmd+P → Shell Command: Install 'code' command
  • gh CLI (optional, for creating this gist)
# ─────────────────────────────────────────────
# wt — git worktree helper
# Usage:
# wt [--code] <repo-path> <branch> [base-branch]
# wt ls
# wt open [--no-code] [branch]
# wt rm [branch]
# ─────────────────────────────────────────────
# ── tab title ────────────────────────────────
export WARP_DISABLE_AUTO_TITLE=true
_wt_set_terminal_title() {
local title="${TAB_TITLE:-Terminal}"
echo -ne "\033]0;${title}\007"
}
if [ -n "$ZSH_VERSION" ]; then
autoload -Uz add-zsh-hook
add-zsh-hook precmd _wt_set_terminal_title
add-zsh-hook chpwd _wt_chpwd
elif [ -n "$BASH_VERSION" ]; then
PROMPT_COMMAND="_wt_set_terminal_title; $PROMPT_COMMAND"
cd() { builtin cd "$@" && _wt_chpwd; }
fi
# ── palette ───────────────────────────────────
# 5 colours × 2 slots = 10 total.
# Assignment fills one slot per colour before reusing any colour.
_wt_COLOUR_NAMES=(green yellow blue magenta cyan )
_wt_COLOURS=( '#1a5c2a' '#7a6200' '#1a3d8c' '#7a1a6b' '#0a6b6b')
_wt_EMOJIS_A=( 🍏 🍋 🌀 🦄 🐬 )
_wt_EMOJIS_B=( 🐸 🍌 🔷 💜 💠 )
# Brighter true-colour ANSI codes for terminal output (VSCode colours are too dark)
_wt_ANSI_COLOURS=(
$'\033[38;2;100;240;120m' # green
$'\033[38;2;255;220;20m' # yellow
$'\033[38;2;120;180;255m' # blue
$'\033[38;2;255;120;240m' # magenta
$'\033[38;2;20;240;240m' # cyan
)
_wt_ANSI_RESET=$'\033[0m'
# ── git ───────────────────────────────────────
_wt_git_common_dir() {
local dir="${1:-.}"
local ref_dir
ref_dir="$(git -C "$dir" rev-parse --git-common-dir 2>/dev/null)" || return 1
[[ "$ref_dir" == /* ]] && echo "$ref_dir" || echo "$(cd "$dir" && pwd)/$ref_dir"
}
# ── colour/emoji lookup ───────────────────────
_wt_colour_idx() {
local colour_name="$1"
local i
for (( i=1; i<=${#_wt_COLOUR_NAMES[@]}; i++ )); do
[[ "${_wt_COLOUR_NAMES[$i]}" == "$colour_name" ]] && echo "$i" && return
done
return 1
}
_wt_get_assignment() {
local branch="$1" assignments_file="$2"
[[ ! -f "$assignments_file" ]] && return
awk -F: -v b="$branch" 'NF>=3 && $1==b {print $2":"$3; exit}' "$assignments_file"
}
_wt_emoji_from_assignment() {
local assignment="$1"
[[ -z "$assignment" ]] && echo "• " && return
local idx
idx="$(_wt_colour_idx "${assignment%:*}")" || { echo "• "; return; }
[[ "${assignment#*:}" == "A" ]] && echo "${_wt_EMOJIS_A[$idx]}" || echo "${_wt_EMOJIS_B[$idx]}"
}
_wt_colour_from_assignment() {
local assignment="$1"
[[ -z "$assignment" ]] && return
local idx
idx="$(_wt_colour_idx "${assignment%:*}")" || return
echo "${_wt_COLOURS[$idx]}"
}
_wt_ansi_from_assignment() {
local assignment="$1"
[[ -z "$assignment" ]] && return
local idx
idx="$(_wt_colour_idx "${assignment%:*}")" || return
printf '%s' "${_wt_ANSI_COLOURS[$idx]}"
}
# ── colour assignment ─────────────────────────
_wt_assign_colour() {
local assignments_file="$1"
local -A slot_a slot_b
if [[ -f "$assignments_file" ]]; then
local _b _c _s
while IFS=: read -r _b _c _s; do
[[ -z "$_c" || -z "$_s" ]] && continue
[[ "$_s" == "A" ]] && slot_a[$_c]=1 || slot_b[$_c]=1
done < "$assignments_file"
fi
local name free=()
for name in "${_wt_COLOUR_NAMES[@]}"; do
[[ -z "${slot_a[$name]}" && -z "${slot_b[$name]}" ]] && free+=("$name")
done
if (( ${#free[@]} > 0 )); then
local colour="${free[$(( RANDOM % ${#free[@]} + 1 ))]}"
local slot; (( RANDOM % 2 == 0 )) && slot="A" || slot="B"
echo "${colour}:${slot}"
return
fi
local partial=()
for name in "${_wt_COLOUR_NAMES[@]}"; do
{ [[ -n "${slot_a[$name]}" && -z "${slot_b[$name]}" ]] || \
[[ -z "${slot_a[$name]}" && -n "${slot_b[$name]}" ]]; } && partial+=("$name")
done
if (( ${#partial[@]} > 0 )); then
local colour="${partial[$(( RANDOM % ${#partial[@]} + 1 ))]}"
local slot; [[ -z "${slot_a[$colour]}" ]] && slot="A" || slot="B"
echo "${colour}:${slot}"
return
fi
# Fallback: all 10 slots taken
local colour="${_wt_COLOUR_NAMES[$(( RANDOM % ${#_wt_COLOUR_NAMES[@]} + 1 ))]}"
local slot; (( RANDOM % 2 == 0 )) && slot="A" || slot="B"
echo "${colour}:${slot}"
}
# ── workspace files ───────────────────────────
_wt_workspace_path() {
local git_common_dir="$1" branch="$2"
echo "${git_common_dir}/wt-workspaces/${branch//\//-}.code-workspace"
}
_wt_write_workspace() {
local worktree_dir="$1" git_common_dir="$2" branch="$3" emoji="$4" colour="$5"
local workspace_file
workspace_file="$(_wt_workspace_path "$git_common_dir" "$branch")"
mkdir -p "$(dirname "$workspace_file")"
cat > "$workspace_file" <<EOF
{
"folders": [{ "path": "${worktree_dir}" }],
"settings": {
"window.title": "${emoji} \${rootName} — \${activeEditorShort}",
"workbench.colorCustomizations": {
"titleBar.activeBackground": "${colour}",
"titleBar.activeForeground": "#ffffff",
"titleBar.inactiveBackground": "${colour}cc"
}
}
}
EOF
}
# ── interactive branch selector ───────────────
_wt_select_branch() {
local prompt_action="$1"
_WT_SELECTED_BRANCH=""
local main_repo
main_repo="$(git worktree list --porcelain | head -1 | sed 's/^worktree //')"
local git_common_dir
git_common_dir="$(_wt_git_common_dir "$main_repo")" || return 1
local assignments_file="${git_common_dir}/wt-assignments"
local branches=()
local line worktree_path branch
while IFS= read -r line; do
if [[ "$line" == worktree* ]]; then
worktree_path="${line#worktree }"
elif [[ "$line" == branch* ]]; then
branch="${line#branch refs/heads/}"
[[ "$worktree_path" == "$main_repo" ]] && continue
branches+=("$branch")
fi
done < <(git worktree list --porcelain)
if (( ${#branches[@]} == 0 )); then
echo "No worktrees found"
return 1
fi
local i _wt_asgn
for (( i=1; i<=${#branches[@]}; i++ )); do
_wt_asgn="$(_wt_get_assignment "${branches[$i]}" "$assignments_file")"
printf " %d) %s %s%s%s\n" "$i" \
"$(_wt_emoji_from_assignment "$_wt_asgn")" \
"$(_wt_ansi_from_assignment "$_wt_asgn")" \
"${branches[$i]}" \
"$_wt_ANSI_RESET"
done
echo ""
printf "Select worktree to %s [1-%d]: " "$prompt_action" "${#branches[@]}"
local key
if [ -n "$ZSH_VERSION" ]; then read -k 1 key; else read -n 1 key; fi
echo ""
if [[ "$key" =~ ^[1-9]$ ]] && (( key <= ${#branches[@]} )); then
_WT_SELECTED_BRANCH="${branches[$key]}"
else
echo "Cancelled"
return 1
fi
}
# ── package manager detection ─────────────────
_wt_detect_pm() {
local dir="$1"
if [[ -f "$dir/pnpm-lock.yaml" ]]; then echo "pnpm"
elif [[ -f "$dir/yarn.lock" ]]; then echo "yarn"
elif [[ -f "$dir/package-lock.json" ]]; then echo "npm"
fi
}
# ── wt (create) ───────────────────────────────
wt() {
case "$1" in
ls) _wt_list; return ;;
rm) shift; _wt_rm "$@"; return ;;
open) shift; _wt_open "$@"; return ;;
esac
local open_vscode=false
while [[ "$1" == --* ]]; do
case "$1" in
--code) open_vscode=true ;;
*) echo "Unknown flag: $1"; return 1 ;;
esac
shift
done
local repo="${1:?Usage: wt [--code] <repo-path> <branch> [base-branch]}"
local branch="${2:?Branch name required}"
local base="${3:-develop}"
repo="$(cd "$repo" && pwd)"
local repo_name worktree_dir
repo_name="$(basename "$repo")"
worktree_dir="${HOME}/dev/wt/${repo_name}-${branch//\//-}"
local git_common_dir
git_common_dir="$(_wt_git_common_dir "$repo")" || { echo "Error: not a git repo"; return 1; }
local assignments_file="${git_common_dir}/wt-assignments"
local assignment emoji colour
assignment="$(_wt_assign_colour "$assignments_file")"
emoji="$(_wt_emoji_from_assignment "$assignment")"
colour="$(_wt_colour_from_assignment "$assignment")"
mkdir -p "${HOME}/dev/wt"
echo "→ Creating worktree: $worktree_dir"
git -C "$repo" worktree add -b "$branch" "$worktree_dir" "$base" || return 1
echo "${branch}:${assignment}" >> "$assignments_file"
while IFS= read -r -d '' env_file; do
local relative="${env_file#$repo/}"
local dest="$worktree_dir/$relative"
if [[ ! -f "$dest" ]]; then
mkdir -p "$(dirname "$dest")"
cp "$env_file" "$dest"
echo "→ Copied $relative"
fi
done < <(find "$repo" -type f \( -name ".env" -o -name ".env.local" -o -name ".env.e2e.local" \) -not -path "*/.git/*" -print0)
_wt_write_workspace "$worktree_dir" "$git_common_dir" "$branch" "$emoji" "$colour"
local pm
pm="$(_wt_detect_pm "$worktree_dir")"
if [[ -n "$pm" ]]; then
echo "→ Running $pm install in background"
(cd "$worktree_dir" && $pm install &>/dev/null &)
fi
export TAB_TITLE="${emoji} ${branch}"
_wt_set_terminal_title
if $open_vscode; then
code "$(_wt_workspace_path "$git_common_dir" "$branch")"
fi
cd "$worktree_dir" && claude
}
# ── wt chpwd hook ─────────────────────────────
_wt_chpwd() {
local branch
branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null)"
[[ -z "$branch" || "$branch" == "HEAD" ]] && return
local main_repo
main_repo="$(git worktree list --porcelain 2>/dev/null | head -1 | sed 's/^worktree //')"
[[ "$(pwd)" == "$main_repo" ]] && return
local git_common_dir
git_common_dir="$(_wt_git_common_dir)" || return
local assignment
assignment="$(_wt_get_assignment "$branch" "${git_common_dir}/wt-assignments")"
export TAB_TITLE="$(_wt_emoji_from_assignment "$assignment") ${branch}"
_wt_set_terminal_title
}
# ── wt ls ─────────────────────────────────────
_wt_list() {
local main_repo
main_repo="$(git worktree list --porcelain | head -1 | sed 's/^worktree //')"
local git_common_dir
git_common_dir="$(_wt_git_common_dir "$main_repo")" || { git worktree list; return; }
local assignments_file="${git_common_dir}/wt-assignments"
local line worktree_path branch assignment
git worktree list --porcelain | while IFS= read -r line; do
if [[ "$line" == worktree* ]]; then
worktree_path="${line#worktree }"
elif [[ "$line" == branch* ]]; then
branch="${line#branch refs/heads/}"
assignment="$(_wt_get_assignment "$branch" "$assignments_file")"
printf "%s %s%-40s%s %s\n" \
"$(_wt_emoji_from_assignment "$assignment")" \
"$(_wt_ansi_from_assignment "$assignment")" \
"$branch" \
"$_wt_ANSI_RESET" \
"${worktree_path/#$HOME/~}"
fi
done
}
# ── wt open ───────────────────────────────────
_wt_open() {
local open_vscode=true
while [[ "$1" == --* ]]; do
case "$1" in
--no-code) open_vscode=false ;;
*) echo "Unknown flag: $1"; return 1 ;;
esac
shift
done
local branch="$1"
if [[ -z "$branch" ]]; then
_wt_select_branch "open" || return 1
branch="$_WT_SELECTED_BRANCH"
fi
local worktree_dir
worktree_dir="$(git worktree list --porcelain | awk \
'/^worktree/{wt=$2} /^branch refs\/heads\/'"${branch//\//\\/}"'$/{print wt}')"
if [[ -z "$worktree_dir" ]]; then
echo "Error: no worktree found for branch: $branch"
return 1
fi
local git_common_dir
git_common_dir="$(_wt_git_common_dir "$worktree_dir")" || { echo "Error: can't find git dir"; return 1; }
local assignments_file="${git_common_dir}/wt-assignments"
local assignment emoji colour
assignment="$(_wt_get_assignment "$branch" "$assignments_file")"
emoji="$(_wt_emoji_from_assignment "$assignment")"
colour="$(_wt_colour_from_assignment "$assignment")"
_wt_write_workspace "$worktree_dir" "$git_common_dir" "$branch" "$emoji" "$colour"
export TAB_TITLE="${emoji} ${branch}"
_wt_set_terminal_title
if $open_vscode; then
code "$(_wt_workspace_path "$git_common_dir" "$branch")"
fi
cd "$worktree_dir" && claude --continue
}
# ── wt rm ─────────────────────────────────────
_wt_rm() {
local branch="$1"
if [[ -z "$branch" ]]; then
_wt_select_branch "remove" || return 1
branch="$_WT_SELECTED_BRANCH"
fi
local main_repo
main_repo="$(git worktree list --porcelain | head -1 | sed 's/^worktree //')"
local target
target="$(git worktree list --porcelain | awk \
'/^worktree/{wt=$2} /^branch refs\/heads\/'"${branch//\//\\/}"'$/{print wt}')"
if [[ -z "$target" ]]; then
echo "Error: no worktree found for branch: $branch"
return 1
fi
if [[ "$target" == "$main_repo" ]]; then
echo "Error: refusing to remove the main worktree"
return 1
fi
local git_common_dir
git_common_dir="$(_wt_git_common_dir "$main_repo")"
[[ -d "$target/node_modules" ]] && { echo "→ Removing node_modules"; rm -rf "$target/node_modules"; }
echo "→ Removing worktree: $target"
git -C "$main_repo" worktree remove --force "$target"
echo "→ Deleting branch: $branch"
git -C "$main_repo" branch -D "$branch"
if [[ -n "$git_common_dir" ]]; then
local assignments_file="${git_common_dir}/wt-assignments"
if [[ -f "$assignments_file" ]]; then
awk -F: -v b="$branch" '$1 != b' "$assignments_file" > "${assignments_file}.tmp" \
&& mv "${assignments_file}.tmp" "$assignments_file"
echo "→ Removed colour assignment"
fi
local workspace_file
workspace_file="$(_wt_workspace_path "$git_common_dir" "$branch")"
if [[ -f "$workspace_file" ]]; then
rm "$workspace_file"
echo "→ Removed workspace file"
fi
fi
echo "✓ Done"
}
# ── tab completion ────────────────────────────
_wt_complete() {
local subcommand="$words[2]"
case "$subcommand" in
rm|open)
local branches
branches=($(git worktree list --porcelain 2>/dev/null \
| grep '^branch' \
| sed 's|^branch refs/heads/||'))
_describe 'branch' branches
;;
*)
local subcommands=('ls' 'rm' 'open')
_describe 'subcommand' subcommands
;;
esac
}
compdef _wt_complete wt
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment