Last active
September 5, 2025 09:17
-
-
Save hannesoid/8a624f61a847e8ec6237ec92de44e786 to your computer and use it in GitHub Desktop.
gws: interactive git worktree switcher for zsh
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
| # Install | |
| # - Add this function to ~/.zprofile (login shells) or ~/.zshrc (interactive shells). | |
| # - Append directly and reload: | |
| # curl -fsSL https://gist.github.com/hannesoid/8a624f61a847e8ec6237ec92de44e786/raw/gws.zsh >> ~/.zprofile && source ~/.zprofile | |
| # - Or save as ~/bin/gws.zsh and source it from your shell init. | |
| # | |
| # gws — Git Worktree Switcher (zsh) | |
| # | |
| # Features | |
| # - No argument: lists all worktrees and lets you pick one. | |
| # - Uses `fzf` if available; falls back to a numbered select menu. | |
| # - With an argument: case-insensitive substring search across worktree dir | |
| # names and branch names; deduplicates to unique worktree paths. | |
| # - If exactly one unique worktree matches, switches to it. | |
| # - If multiple unique worktrees match, shows the same interactive picker. | |
| # - Exact key match (dir name or branch) still jumps directly. | |
| # | |
| # Usage | |
| # gws # interactive list of worktrees | |
| # gws wipeout # case-insensitive substring match (deduped by path) | |
| # gws my-branch # exact match on branch or worktree dir name | |
| # | |
| # Notes | |
| # - Must be run inside a git repo (uses `git worktree list --porcelain`). | |
| # - Optional: install `fzf` to get a better picker; otherwise zsh `select` is used. | |
| gws() { | |
| local target="$1" | |
| local repo_root | |
| repo_root=$(git rev-parse --show-toplevel 2>/dev/null) || { echo "Not in a git repo"; return 1; } | |
| # Build maps and lists from `git worktree list --porcelain` | |
| local -A key_to_path # key (worktree dir name or branch) -> path | |
| local -A path_seen # path -> seen flag | |
| local -a lines paths labels | |
| lines=("${(@f)$(git worktree list --porcelain)}") | |
| local i=1 path branch name | |
| while (( i <= ${#lines} )); do | |
| if [[ ${lines[i]} == worktree\ * ]]; then | |
| path=${lines[i]#worktree } | |
| ((i++)) | |
| branch="" | |
| while (( i <= ${#lines} )) && [[ ${lines[i]} != worktree\ * ]]; do | |
| if [[ ${lines[i]} == branch\ * ]]; then | |
| branch=${lines[i]#branch refs/heads/} | |
| fi | |
| ((i++)) | |
| done | |
| name=${path:t} | |
| key_to_path[$name]="$path" | |
| [[ -n $branch ]] && key_to_path[$branch]="$path" | |
| if [[ -z ${path_seen[$path]} ]]; then | |
| path_seen[$path]=1 | |
| paths+="$path" | |
| if [[ -n $branch ]]; then | |
| labels+="$name [$branch]" | |
| else | |
| labels+="$name" | |
| fi | |
| fi | |
| continue | |
| fi | |
| ((i++)) | |
| done | |
| # helper: interactive selection (fzf if available, else zsh select) | |
| local _select_path | |
| _select_path() { | |
| local -a _labels=("$@") | |
| local choice | |
| if command -v fzf >/dev/null 2>&1; then | |
| choice=$(printf '%s\n' "${_labels[@]}" | fzf --prompt='gws> ' --height=40% --reverse) | |
| [[ -z $choice ]] && return 1 | |
| local idx=1 l | |
| for l in "${_labels[@]}"; do | |
| [[ $l == $choice ]] && { REPLY=$idx; return 0; } | |
| ((idx++)) | |
| done | |
| return 1 | |
| else | |
| echo "Select worktree:" | |
| select choice in "${_labels[@]}"; do | |
| if [[ -n $REPLY && $REPLY -ge 1 && $REPLY -le ${#_labels} ]]; then | |
| return 0 | |
| fi | |
| echo "Invalid selection." | |
| done | |
| fi | |
| } | |
| # No argument: list and pick interactively | |
| if [[ -z "$target" ]]; then | |
| if (( ${#paths} == 0 )); then | |
| echo "No worktrees found" | |
| return 1 | |
| fi | |
| _select_path "${labels[@]}" || return 1 | |
| cd -- "${paths[$REPLY]}" | |
| return | |
| fi | |
| # Exact key match (dir name or branch) | |
| if [[ -n ${key_to_path[$target]} ]]; then | |
| cd -- "${key_to_path[$target]}" | |
| return | |
| fi | |
| # Fuzzy/partial match (case-insensitive): dedupe by path so branch+dirname pointing | |
| # to the same worktree count as one unique choice | |
| local -A seen | |
| local -a matched_paths matched_labels | |
| local k p idx | |
| local _t=${target:l} | |
| for k in ${(k)key_to_path}; do | |
| if [[ ${k:l} == *$_t* ]]; then | |
| path=${key_to_path[$k]} | |
| if [[ -z ${seen[$path]} ]]; then | |
| seen[$path]=1 | |
| matched_paths+="$path" | |
| idx=1 | |
| for p in "${paths[@]}"; do | |
| if [[ $p == $path ]]; then | |
| matched_labels+="${labels[$idx]}" | |
| break | |
| fi | |
| ((idx++)) | |
| done | |
| fi | |
| fi | |
| done | |
| local n=${#matched_paths} | |
| if (( n == 0 )); then | |
| echo "No worktree or branch matching '$target'" | |
| return 1 | |
| fi | |
| if (( n == 1 )); then | |
| cd -- "${matched_paths[1]}" | |
| return | |
| fi | |
| _select_path "${matched_labels[@]}" || return 2 | |
| cd -- "${matched_paths[$REPLY]}" | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment