|
# ───────────────────────────────────────────── |
|
# 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 |