Skip to content
Fran Gonzalez
← Back to blog
(updated Aug 11, 2026)·Clanker·12 min read

Building an fzf Git worktree switcher for Bash and Zsh

I built a shell-native Git worktree selector with safe removal, optional pull-request previews, and behavior shared by Bash and Zsh.

Some matmuls wrote this slop, sorry. My goal with this content is to document some work I (a real human bean) do while poking the Clanker, and try to learn something along the way.

Switching Git worktrees is a navigation problem, but removing one safely and showing its review context turns it into a small interactive application.

The Problem

I keep linked worktrees outside their repositories so several projects can use the same predictable layout. The filesystem organization solved where to put them, but navigation still required remembering paths or repeatedly reading git worktree list.

I wanted one command with a narrow interface:

  • show only the worktree name in the main list;
  • fuzzy-filter the list;
  • press Enter to switch the current shell into a worktree;
  • press Ctrl-X to remove one after confirmation;
  • press ? to reveal details and an open pull or merge request;
  • work from either the main worktree or any linked worktree;
  • behave the same in Bash and Zsh.

A plain executable cannot implement the final cd. Child processes cannot change their parent’s working directory. The outer command therefore had to be a shell function, as described by the Bash shell functions documentation. The provider lookup did not need parent-shell state, so that part could remain an executable script.

That boundary shaped the implementation.

Starting from zmx-select

I started the interaction model from the zmx-select session picker. neurosnap, the maintainer of zmx, documents it in the project’s README. I was already using that function to fuzzy-find terminal sessions, preview their history, and distinguish Enter from Ctrl-N through fzf output.

That picker established the useful core pattern:

command output -> normalized rows -> fzf -> key plus selected row -> shell action

I adapted that pattern for worktrees. Git required richer hidden fields, parent-shell directory changes, safe removal, and provider metadata, but the fzf control flow came from the zmx session picker. Keeping that lineage explicit also made it easier to preserve the picker behavior I already knew.

A two-layer shell protocol

The utility has an inner selector and an outer state-changing function:

wt
└── captures _wt_fzf stdout
    ├── Enter: prints exactly one destination path
    ├── remove: prints no destination
    └── cancel/error: returns a non-zero status

wt calls _wt_fzf through command substitution. This isolates the interactive selection logic while allowing the outer function to run cd in the caller’s shell.

The protocol depends on keeping stdout clean. Only a selected path may go there. Prompts, diagnostics, errors, and git worktree remove output go to stderr. I initially printed a confirmation prompt to stdout, but command substitution captured it and made the prompt invisible. Writing it explicitly to stderr fixed both the display and the data channel:

printf 'Remove %s? [y/N] ' "$wt_path" >&2
read -r reply

Routine diagnostics follow the same rule and remain opt-in:

WT_VERBOSE=1 wt

Parsing Git’s machine-readable output

I use git worktree list --porcelain, not the human-oriented table. Git documents the porcelain format as stable for scripts. Each record starts with an absolute worktree path and may include a branch, bare, or detached state.

A normal record looks like this:

worktree /Users/fran/Documents/Projects/example
HEAD 0123456789abcdef
branch refs/heads/main

The AWK parser converts those records into four tab-separated fields:

branch<TAB>display-name<TAB>absolute-path<TAB>repository-name

The parser flushes when the next worktree line begins and again in END, so the final record is not lost. It also assigns explicit (bare) and (detached) labels when no branch is available.

The repository name comes from the first worktree record, which is the main worktree in Git’s list. This matters when wt is launched from a linked worktree: git rev-parse --show-toplevel would identify the current linked directory instead of the repository’s main worktree.

Keep data rich and presentation small

fzf receives all four fields but displays only field two:

--delimiter=$'\t' --with-nth=2

--with-nth changes the presentation without discarding the original row. The hidden fields remain available after selection and to the preview command.

The key bindings form a small action model:

--expect=ctrl-x
--bind='?:toggle-preview'

With --expect, fzf writes the accepted key as the first output line. Enter produces an empty first line; Ctrl-X produces ctrl-x. The selected row follows. The shell code validates that row before extracting its fields.

The preview starts hidden at the bottom:

--preview-window='down:7:hidden:nowrap'

This keeps the default list concise. Pressing ? reveals the repository, path, branch, worktree name, and provider review URL. nowrap keeps paths and URLs on one logical line.

fzf field placeholders pass the hidden columns to the helper:

--preview="$HOME/.local/bin/wt-preview {4} {3} {1} {2}"

The arrangement lets the selector carry structured data without presenting a wide table.

Safe removal rather than forced cleanup

Ctrl-X performs several checks before removal:

  1. Resolve the current worktree with git rev-parse --show-toplevel.
  2. Refuse to remove it while the shell is inside it.
  3. Ask for an explicit y or Y confirmation.
  4. Run git worktree remove without --force.
  5. Propagate failure instead of pretending removal succeeded.

Leaving out --force is deliberate. Git can refuse to remove a dirty or otherwise unsafe worktree, preserving its own safety checks. A successful removal returns no destination, so the outer function stays in the current directory.

Provider-specific previews

The preview helper first prints local metadata. It only attempts a review lookup when the worktree has a branch and directory.

It reads the repository remote and routes to exactly one provider:

  • GitHub remotes use gh pr view.
  • GitLab remotes use glab mr view.
  • detached, bare, unknown, or unsupported remotes stop after local metadata.

For GitHub, gh pr view <branch> returns selected JSON fields and a jq expression keeps only an open pull request. For GitLab, the helper parses the text fields and keeps only an opened merge request. Missing CLIs, missing remotes, closed reviews, and provider errors do not break worktree navigation; they simply omit the optional review section.

The host check prevents a GitHub repository from invoking glab or a GitLab repository from invoking gh. It also avoids querying both services for every preview.

Full shell module

This file is sourced by both Bash and Zsh. It must remain a function because wt changes the caller’s directory.

# ~/.config/shell/common.d/70-worktrees.sh
# fzf worktree switcher.

# Set WT_VERBOSE=1 for selector/action diagnostics.
_wt_log() {
  if [[ "${WT_VERBOSE:-0}" == 1 ]]; then
    printf 'wt: %s\n' "$*" >&2
  fi
}

# Inner selector: runs in a subshell and writes only the destination path to stdout.
_wt_fzf() {
  local porcelain repo_path repo_name wt_list output key selected
  local wt_name row_rest wt_path branch_label current_root reply rc

  if ! command -v fzf >/dev/null 2>&1; then
    printf 'wt: fzf is required but was not found in PATH\n' >&2
    return 127
  fi

  porcelain=$(git worktree list --porcelain 2>/dev/null)
  if [[ -z "$porcelain" ]]; then
    printf 'wt: no worktrees found; run this inside a Git repository\n' >&2
    return 1
  fi

  # The first porcelain record is the repository's main worktree.
  repo_path=$(printf '%s\n' "$porcelain" | awk '/^worktree / { print substr($0, 10); exit }')
  if [[ -z "$repo_path" ]]; then
    printf 'wt: could not determine the repository name\n' >&2
    return 1
  fi
  repo_name=${repo_path##*/}

  # Parse into four tab-delimited columns: branch, worktree name, path, repo.
  # Flush each record when the next worktree starts; END handles the final one.
  wt_list=$(printf '%s\n' "$porcelain" | awk -v repo_name="$repo_name" '
    /^worktree / { if (NR > 1) flush(); wt=substr($0, 10); branch=""; bare=0; detached=0; next }
    /^bare/      { bare=1; next }
    /^branch /   { branch=$2; sub("refs/heads/", "", branch); next }
    /^detached/  { detached=1; next }
    /^$/         { next }
    END          { flush() }
    function flush() {
      if (wt == "") return
      name=wt
      sub(/^.*\//, "", name)
      if (bare)          printf "(bare)\t%s\t%s\t%s\n", name, wt, repo_name
      else if (detached) printf "(detached)\t%s\t%s\t%s\n", name, wt, repo_name
      else               printf "%s\t%s\t%s\t%s\n", branch, name, wt, repo_name
      wt=""
    }
  ')
  if [[ -z "$wt_list" ]]; then
    printf 'wt: no worktrees found; run this inside a Git repository\n' >&2
    return 1
  fi

  _wt_log 'choose a worktree (Enter=switch, Ctrl-X=remove, Esc=cancel)'
  output=$(printf '%s\n' "$wt_list" | fzf \
    --expect=ctrl-x \
    --delimiter=$'\t' --with-nth=2 \
    --height='~50%' --reverse \
    --header='Enter: switch  |  Ctrl-X: remove  |  ?: details  |  Esc: cancel' \
    --preview="$HOME/.local/bin/wt-preview {4} {3} {1} {2}" \
    --preview-window='down:7:hidden:nowrap' \
    --bind='?:toggle-preview' \
    --prompt='wt> ')
  rc=$?
  if (( rc != 0 )); then
    if (( rc == 130 )); then
      _wt_log 'selection cancelled'
    else
      printf 'wt: fzf exited with status %d\n' "$rc" >&2
    fi
    return "$rc"
  fi

  # --expect puts the action on line 1 and the selected row on line 2.
  key=${output%%$'\n'*}
  selected=${output#*$'\n'}
  if [[ -z "$selected" || "$selected" != *$'\t'* ]]; then
    printf 'wt: fzf returned an invalid selection\n' >&2
    return 1
  fi

  branch_label=${selected%%$'\t'*}
  branch_label=${branch_label#  }
  row_rest=${selected#*$'\t'}
  if [[ "$row_rest" != *$'\t'* ]]; then
    printf 'wt: fzf returned an invalid worktree row\n' >&2
    return 1
  fi
  wt_name=${row_rest%%$'\t'*}
  row_rest=${row_rest#*$'\t'}
  if [[ "$row_rest" != *$'\t'* ]]; then
    printf 'wt: fzf returned an invalid worktree path\n' >&2
    return 1
  fi
  wt_path=${row_rest%%$'\t'*}
  _wt_log "selected $branch_label ($wt_name) -> $wt_path"

  if [[ "$key" == 'ctrl-x' ]]; then
    current_root=$(git rev-parse --show-toplevel 2>/dev/null) || current_root=
    if [[ -n "$current_root" && "$wt_path" == "$current_root" ]]; then
      printf 'wt: refusing to remove the current worktree: %s\n' "$wt_path" >&2
      printf 'wt: switch to another worktree first\n' >&2
      return 1
    fi

    _wt_log "removal requested for $wt_path"
    printf 'Remove %s? [y/N] ' "$wt_path" >&2
    if ! read -r reply; then
      printf '\nwt: confirmation input failed; removal cancelled\n' >&2
      return 130
    fi
    case "$reply" in
      y|Y) ;;
      *)
        _wt_log 'removal cancelled'
        return 130
        ;;
    esac

    _wt_log "removing $wt_path"
    if ! git worktree remove "$wt_path" >&2; then
      printf 'wt: failed to remove %s\n' "$wt_path" >&2
      return 1
    fi
    _wt_log "removed $wt_path"
    return 0
  fi

  _wt_log "switching to $wt_path"
  printf '%s\n' "$wt_path"
}

# Outer selector: changes the caller's directory.
wt() {
  local target rc

  _wt_log 'opening worktree selector'
  target=$(_wt_fzf)
  rc=$?
  if (( rc != 0 )); then
    return "$rc"
  fi

  # A successful remove has no destination to cd into.
  if [[ -z "$target" ]]; then
    _wt_log "worktree operation complete; staying in $PWD"
    return 0
  fi
  if [[ ! -d "$target" ]]; then
    printf 'wt: selected path does not exist: %s\n' "$target" >&2
    return 1
  fi
  if [[ "$PWD" == "$target" ]]; then
    _wt_log "already in $PWD"
    return 0
  fi

  _wt_log "changing directory to $target"
  if ! cd "$target"; then
    printf 'wt: could not change directory to %s\n' "$target" >&2
    return 1
  fi
  printf '→ %s\n' "$PWD"
}

Full preview helper

The preview is a standalone Bash executable because it does not modify parent-shell state.

# ~/.local/bin/wt-preview
#!/usr/bin/env bash

repo_name=${1:-unknown}
worktree_path=${2:-}
branch=${3:-}
worktree_name=${4:-}

printf 'Repository: %s\n' "$repo_name"
printf 'Path: %s\n' "$worktree_path"
printf 'Branch: %s\n' "$branch"
printf 'Worktree: %s\n' "$worktree_name"

# Detached and bare worktrees do not have a branch-based PR/MR to inspect.
case "$branch" in
  ''|'(bare)'|'(detached)') exit 0 ;;
esac
[[ -d "$worktree_path" ]] || exit 0

remote=$(git -C "$worktree_path" remote get-url origin 2>/dev/null) || remote=
if [[ -z "$remote" ]]; then
  remote_name=$(git -C "$worktree_path" remote | head -n 1)
  if [[ -n "$remote_name" ]]; then
    remote=$(git -C "$worktree_path" remote get-url "$remote_name" 2>/dev/null) || remote=
  fi
fi

# Run exactly one provider CLI, based on the repository's remote host.
case "$remote" in
  *github.com:*|*github.com/*)
    command -v gh >/dev/null 2>&1 || exit 0
    cd "$worktree_path" 2>/dev/null || exit 0
    pr=$(gh pr view "$branch" \
      --json number,url,state \
      --jq 'select(.state == "OPEN") | [(.number | tostring), .url] | @tsv' \
      2>/dev/null) || pr=
    if [[ -n "$pr" ]]; then
      IFS=$'\t' read -r pr_number pr_url <<< "$pr"
      printf '\nPull request: #%s\nURL: %s\n' "$pr_number" "$pr_url"
    fi
    ;;
  *gitlab.com:*|*gitlab.com/*)
    command -v glab >/dev/null 2>&1 || exit 0
    cd "$worktree_path" 2>/dev/null || exit 0
    mr_output=$(glab mr view "$branch" --output text 2>/dev/null) || mr_output=
    if [[ -n "$mr_output" ]]; then
      mr=$(printf '%s\n' "$mr_output" | awk -F '\t' '
        $1 == "state:"  { state=$2 }
        $1 == "number:" { number=$2 }
        $1 == "url:"    { url=$2 }
        END {
          if ((state == "opened" || state == "open") && number != "" && url != "")
            printf "%s\t%s\n", number, url
        }
      ')
      if [[ -n "$mr" ]]; then
        IFS=$'\t' read -r mr_number mr_url <<< "$mr"
        printf '\nMerge request: !%s\nURL: %s\n' "$mr_number" "$mr_url"
      fi
    fi
    ;;
esac

Make the helper executable and source the module from the shared shell configuration:

chmod +x "$HOME/.local/bin/wt-preview"
. "$HOME/.config/shell/common.d/70-worktrees.sh"

The normal interface is then:

wt
WT_VERBOSE=1 wt

Validation and tradeoffs

I tested switching and removal in isolated repositories, including invocation from linked worktrees. I also tested provider routing with fake GitHub and GitLab remotes to confirm that each repository invokes only its matching CLI. Bash syntax, Zsh syntax, ShellCheck, and whitespace checks covered the two files.

The design still has boundaries:

  • worktree display names use path basenames, so two unusual paths with the same basename look identical until the preview opens;
  • provider detection currently recognizes github.com and gitlab.com, not arbitrary self-hosted instances;
  • PR and MR metadata depends on authenticated provider CLIs;
  • the GitLab text parser follows the current glab mr view --output text field format;
  • the utility removes worktrees but does not delete their local branches.

Those constraints keep the default path small. The selector remains useful without either provider CLI, and Git stays responsible for worktree state and removal safety.

The main implementation lesson was defining a reliable boundary between interactive UI, machine-readable data, parent-shell state, and optional network context. Once stdout became a strict path protocol and startup behavior stayed inside a sourced function, the rest of the utility became easier to test.

References

This post was written with AI assistance.