用 zsh 寫一個 TUI 小工具 gwt:在命令列快速切換 Git Worktree 資料夾

最近我的 Git Worktree 越開越多,同一個儲存庫底下常常同時掛著好幾個工作目錄,每個目錄各自對應一個分支。Worktree 本身很好用,但資料夾一多,要在命令列切換過去就變成一件很煩的事:先 git worktree list 看一下清單,再用滑鼠把路徑複製起來,最後貼到 cd 後面。路徑又通常很長,偶爾還會貼錯。

左側終端機視窗的選單以青色光棒反白其中一列,一條發光的青色路徑穿過多個浮空平台上的彩色資料夾與分支節點,最後抵達右側發出光柱的目標資料夾,象徵在多個 Git Worktree 之間快速切換

我一開始想的是寫一支腳本,輸入關鍵字就幫我跳過去。但這條路有個繞不過去的限制:腳本是在子處理序裡執行的,它執行完之後,目前這個終端機的工作目錄不會跟著改變。所以最後我寫成了一個 zsh 函式 gwt,用終端機介面(TUI)列出所有 Worktree,用方向鍵或數字鍵選好,按下 Enter 就直接切過去。

這篇文章就來分享這個函式的完整原始碼、操作方式、幾個值得記錄的設計細節,以及我實際測試時確認到的邊界行為與限制,讓你可以直接拿去改成自己順手的版本。


先搞懂:為什麼 gwt 必須是函式

在動手之前,先把「腳本、函式、別名」三者的差異講清楚,因為這直接決定了 gwt 的寫法:

形式 執行位置 能不能改變目前終端機的工作目錄 適合用途
獨立腳本 (gwt.sh) 子處理序 不行,cd 只影響子處理序自己 不需要改變目前 shell 狀態的工具
source gwt.sh 目前的 shell 可以,但每次都要打 source 臨時載入設定
shell 函式 目前的 shell 可以 需要 cd、修改環境變數的工具
alias 目前的 shell 可以,但沒辦法寫流程判斷與互動邏輯 縮短固定的指令

既然要真的切換目錄,又需要解析資料、處理按鍵,函式就是最直接的選擇。代價是它只能寫在 shell 的設定檔裡,由 shell 自己載入,這也是為什麼下面的安裝步驟是把程式碼貼進 ~/.zshrc。


gwt 的操作方式

先看操作,再看原始碼。在任何一個 Git 儲存庫或 Worktree 裡面輸入 gwt,就會列出這個儲存庫所有的 Worktree:

請選擇 Worktree (↑/↓ 移動, Enter 確認, 按 1-5 直接跳轉, q/Esc 取消):
 > [1] …/shop  [main] (89ae97a) (目前位置)
   [2] …/shop-feature-login  [feature/login] (89ae97a)
   [3] …/shop-feature-search-api  [feature/search-api] (89ae97a)
   [4] …/shop-fix-cart-bug  [fix/cart-bug] (89ae97a)
   [5] …/shop-hotfix  [detached] (89ae97a)

實際畫面中,被選中的那一列會以青色光棒反白,只反白到路徑為止,後面的分支名稱與 commit 簡短雜湊維持原本的顏色,方便一眼辨識;目前所在的 Worktree 則會用黃色標註「目前位置」。可以用的按鍵如下:

按鍵 行為
↑ / ↓ 上下移動,到頭尾會循環
k / j 跟 Vim 一樣的上下移動
1 到 9 直接跳到對應編號並切換目錄,不需要再按 Enter
Enter 切換到目前反白的 Worktree
q / Esc 取消,維持原本的工作目錄
Ctrl+C 中斷,並把隱藏的游標恢復回來

如果你已經大概知道要去哪裡,也可以直接帶一個關鍵字進去篩選。關鍵字會比對「路徑加上分支名稱」這段文字,只要包含就算符合:

# 只列出路徑或分支名稱包含 feature 的 Worktree
gwt feature

# 只剩一筆符合時,不會顯示選單,直接 cd 過去
gwt cart

安裝方式與完整原始碼

把下面整段函式貼進 ~/.zshrc,或是另存成 ~/.zsh/gwt.zsh,再在 ~/.zshrc 裡加一行 source ~/.zsh/gwt.zsh。我自己偏好後者,因為之後要改版時只需要動一個檔案。存檔之後重新開一個終端機,或執行 source ~/.zshrc 就會生效。

gwt() {
  if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
    echo "當前目錄不在 Git 儲存庫中。"
    return 1
  fi

  local wt_paths=()
  local wt_suffixes=()
  local wt_path="" head="" branch=""
  local current_top
  current_top=$(git rev-parse --show-toplevel 2>/dev/null)

  # 解析 porcelain 格式 (避開 Zsh 保留字 path)
  while IFS= read -r line; do
    case "$line" in
      worktree\ *) wt_path="${line#worktree }" ;;
      HEAD\ *)     head="${line#HEAD }"; head="${head:0:7}" ;;
      branch\ *)   branch="${line#branch refs/heads/}" ;;
      detached)    branch="detached" ;;
      "")
        if [[ -n "$wt_path" ]]; then
          if [[ -z "$1" || "$wt_path $branch" == *"$1"* ]]; then
            local marker=""
            [[ "$wt_path" == "$current_top" ]] && marker=" \033[33m(目前位置)\033[0m"
            wt_paths+=("$wt_path")
            wt_suffixes+=("\033[36m[$branch]\033[0m ($head)$marker")
          fi
        fi
        wt_path=""; head=""; branch=""
        ;;
    esac
  done < <(git worktree list --porcelain; echo "")

  local count=${#wt_paths[@]}
  if (( count == 0 )); then
    echo "找不到符合的 worktree。"
    return 1
  elif (( count == 1 )); then
    cd "${wt_paths[1]}" || return 1
    return 0
  fi

  local selected=1
  local key rest

  # 隱藏游標,並確保按 Ctrl+C 中斷時能恢復游標
  printf "\033[?25l"
  trap 'printf "\033[?25h"; unfunction _gwt_draw 2>/dev/null; trap - INT TERM; return 130' INT TERM

  printf "請選擇 Worktree (\033[36m↑/↓\033[0m 移動, \033[36mEnter\033[0m 確認, 按 \033[36m1-%d\033[0m 直接跳轉, \033[36mq/Esc\033[0m 取消):\n" "$count"

  # 繪製選單函式
  _gwt_draw() {
    local i
    for (( i = 1; i <= count; i++ )); do
      printf "\r\033[2K"
      if (( i == selected )); then
        # 光棒只反白到路徑為止,後方分支與狀態維持原色
        printf "\033[1;30;46m > [%d] %s \033[0m %b\n" "$i" "${wt_paths[$i]}" "${wt_suffixes[$i]}"
      else
        printf "   [%d] %s  %b\n" "$i" "${wt_paths[$i]}" "${wt_suffixes[$i]}"
      fi
    done
  }

  _gwt_draw

  while true; do
    read -r -k 1 -s key

    if [[ "$key" == $'\x1b' ]]; then
      read -r -k 2 -s -t 0.05 rest
      if [[ "$rest" == "[A" || "$rest" == "OA" ]]; then
        (( selected-- ))
        (( selected < 1 )) && selected=$count
      elif [[ "$rest" == "[B" || "$rest" == "OB" ]]; then
        (( selected++ ))
        (( selected > count )) && selected=1
      elif [[ -z "$rest" ]]; then
        printf "\033[?25h"
        unfunction _gwt_draw 2>/dev/null
        trap - INT TERM
        return 0
      fi
    elif [[ "$key" == "k" ]]; then
      (( selected-- ))
      (( selected < 1 )) && selected=$count
    elif [[ "$key" == "j" ]]; then
      (( selected++ ))
      (( selected > count )) && selected=1
    elif [[ "$key" == $'\n' || "$key" == $'\r' ]]; then
      printf "\033[?25h"
      unfunction _gwt_draw 2>/dev/null
      trap - INT TERM
      cd "${wt_paths[$selected]}" || return 1
      return 0
    elif [[ "$key" =~ ^[1-9]$ ]]; then
      local num=$(( key ))
      if (( num >= 1 && num <= count )); then
        selected=$num
        printf "\033[%dA" "$count"
        _gwt_draw
        printf "\033[?25h"
        unfunction _gwt_draw 2>/dev/null
        trap - INT TERM
        cd "${wt_paths[$selected]}" || return 1
        return 0
      fi
    elif [[ "$key" == "q" || "$key" == "Q" ]]; then
      printf "\033[?25h"
      unfunction _gwt_draw 2>/dev/null
      trap - INT TERM
      return 0
    fi

    printf "\033[%dA" "$count"
    _gwt_draw
  done
}

幾個值得記錄的設計細節

1. 解析 git worktree list --porcelain,而不是人類看的格式

git worktree list 預設輸出是給人看的對齊格式,欄位之間用空白分隔,路徑只要含有空白就很難切。加上 --porcelain 之後,每個 Worktree 會輸出成一組 worktree、HEAD、branch 開頭的行,Worktree 與 Worktree 之間以一個空行分隔,格式穩定,適合程式解析:

worktree /path/to/shop
HEAD 89ae97a…
branch refs/heads/main

worktree /path/to/shop-hotfix
HEAD 89ae97a…
detached

程式用 case 逐行比對前綴,遇到空行就代表一組資料結束,這時才把它加進陣列。要注意最後一組資料後面不一定有空行,所以我在 git worktree list --porcelain 後面補了一個 echo "",確保最後一個 Worktree 也會被處理到。處於 detached HEAD 的 Worktree 沒有 branch 行,只有一行 detached,函式把它顯示成 [detached]。

2. 變數千萬不要叫 path

原始碼的註解寫了「避開 Zsh 保留字 path」,這是個很容易踩到、我也實測重現過的坑。在 zsh 裡,小寫的 path 陣列跟大寫的 PATH 環境變數是綁在一起的,在函式裡面宣告 local path="",等於把函式內的 PATH 清空,接下來連 ls 都找不到:

zsh -f -c 'f(){ local path=""; ls /tmp >/dev/null; }; f'
f: command not found: ls

改成 wt_path 之後,同樣的流程就能正常執行。如果你是從 bash 改寫過來的,這一點特別容易漏掉,因為在 bash 裡 path 只是個普通變數名稱。

3. 用跳脫序列在原地重繪選單

選單不是每次按鍵就重新印一份,而是用 ANSI 跳脫序列把游標移回選單第一行,再整個重畫,畫面看起來就像是原地更新:

跳脫序列 作用 在 gwt 中的用途
\033[?25l / \033[?25h 隱藏/顯示游標 選單顯示期間隱藏游標,避免閃爍
\033[%dA 游標往上移 N 行 按鍵後往上移 count 行,回到選單第一行重畫
\r\033[2K 游標回到行首並清除整行 每一行重畫之前先清乾淨,避免殘留舊文字
\033[1;30;46m 粗體、黑字、青色底 被選中那一列的光棒

光棒的顏色只套用到路徑那一段,在 printf 裡用 \033[0m 重設之後,才用 %b 印出分支與狀態。這裡一定要用 %b 而不是 %s,否則存在陣列裡的 \033[36m 會被當成一般文字原樣印出來。

4. 區分單獨的 Esc 與方向鍵

方向鍵送進終端機的其實是一串字元:↑ 是 Esc [ A,↓ 是 Esc [ B,部分終端機在應用程式游標模式下則會送 Esc O A。所以函式讀到 Esc 之後,會再用 read -t 0.05 等 50 毫秒,看後面有沒有緊接著出現兩個字元:有,就判斷是方向鍵;等不到,代表使用者只按了單獨一個 Esc,就當作取消。50 毫秒是函式裡寫死的數字,如果你的終端機環境會讓方向鍵的後續字元延遲到達,可以自行調高。

5. 用 trap 確保 Ctrl+C 之後游標會回來

選單顯示期間游標是隱藏的,萬一使用者按 Ctrl+C 中斷,游標就會一直消失,整個終端機看起來像壞掉。所以進入選單之前先設定 trap,攔截 INT 與 TERM 兩個訊號,在處理常式裡把游標顯示回來、清掉暫時的 _gwt_draw 函式、移除 trap,再回傳 130 (慣例上代表被 Ctrl+C 中斷)。每一個正常離開的分支也都做了同樣的清理,所以不管從哪條路離開,游標與 trap 都會恢復原狀。


實測:正向與邊界情境

我在整理這篇文章時,另外建了一個測試儲存庫,裡面有 main、兩個 feature/* 分支、一個 fix/cart-bug 分支,以及一個 detached HEAD 的 Worktree,共 5 個。因為是互動式介面,我用 pseudo-terminal 送出按鍵,再檢查按鍵結束後 $PWD 的值,結果如下:

操作 預期 實測結果
↓ ↓ Enter 切到第 3 個 工作目錄變成 shop-feature-search-api
在第 1 個按 ↑ Enter 循環到最後一個 工作目錄變成 shop-hotfix
j j Enter 切到第 3 個 工作目錄變成 shop-feature-search-api
4 不用 Enter,直接切到第 4 個 工作目錄變成 shop-fix-cart-bug
q 或單獨的 Esc 取消,維持原目錄 工作目錄仍是 shop
5 個 Worktree 時按 9 超出範圍,忽略這次按鍵 選單繼續等待輸入,沒有切換
gwt cart 只有一筆符合,直接切換 沒有顯示選單,直接進入 shop-fix-cart-bug
gwt nomatch 顯示找不到並回傳 1 輸出「找不到符合的 worktree。」,目錄不變
在非 Git 資料夾執行 顯示錯誤並回傳 1 輸出「當前目錄不在 Git 儲存庫中。」
顯示選單時按 Ctrl+C 中斷並恢復游標 輸出裡出現 \033[?25h (顯示游標),目錄不變

這裡有一個我自己一開始沒想到的小細節:我拿 gwt fix 當作「只會符合 fix/cart-bug」的測試,結果竟然跳出了選單。原因是關鍵字比對的是路徑加分支的整串文字,shop-hotfix 這個路徑裡也包含 fix,所以有兩筆符合。這不是 bug,而是比對範圍本來就包含路徑,只是關鍵字太短的時候要有心理準備。


目前已知的限制

這個函式我自己用起來很順手,但實測下來有幾個限制,使用前最好先知道:

  • 只能在 zsh 使用:函式用到 read -k (讀取單一按鍵) 與從 1 開始的陣列索引,這兩者都是 zsh 的行為。我把函式拿到 bash 5.3 載入再執行,結果直接報錯:read: -k: invalid option。如果要在 bash 使用,需要把 read -k 1 改成 read -n 1、陣列索引改成從 0 開始,並重新處理 local 與函式的用法。
  • 數字鍵只支援 1 到 9:因為是讀取單一按鍵,Worktree 超過 9 個時,第 10 個以後的項目沒辦法用數字直接跳轉,只能用方向鍵或 j/k。更要注意的是,提示文字是用總數產生的,我另外開了 12 個 Worktree 測試,標題確實顯示「按 1-12 直接跳轉」,但實際上按下 1 會立刻跳到第 1 個,沒有機會輸入 10、11 或 12。Worktree 很多的時候,搭配關鍵字篩選會更實用。
  • 選單不會在離開後清除:選好之後,選單內容會留在終端機的捲動緩衝區,不會自動擦掉。這不影響功能,只是畫面上會多留幾行紀錄。
  • 篩選是簡單的子字串比對:區分大小寫,也不支援模糊比對。如果你需要更強的搜尋,可以考慮改成把清單交給 fzf 處理。

總結

Worktree 的數量一多,真正耗時間的不是 Git 指令本身,而是「找到路徑、複製、貼上、cd」這一連串小動作。gwt 把這些動作收斂成一個指令加幾個按鍵,用起來有幾個重點:

  • 會改變目前工作目錄的工具,一定要寫成函式:獨立腳本跑在子處理序,cd 之後不會影響到原本的終端機。
  • 解析 Git 輸出時,使用 --porcelain:格式穩定,路徑含有空白也不會切錯。
  • zsh 裡不要用 path 當變數名稱:它跟 PATH 是綁在一起的,會讓函式內找不到任何指令。
  • 隱藏游標就要搭配 trap:才能保證任何離開方式之後,終端機都會恢復正常。
  • 先知道限制再使用:只支援 zsh、數字鍵只能跳轉 1 到 9,Worktree 很多時多搭配關鍵字篩選。

如果你也常常在多個 Worktree 之間切換,可以把這支函式放進自己的 dotfiles,再依照習慣調整按鍵與顯示的格式。


相關連結

留言評論