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

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

![左側終端機視窗的選單以青色光棒反白其中一列，一條發光的青色路徑穿過多個浮空平台上的彩色資料夾與分支節點，最後抵達右側發出光柱的目標資料夾，象徵在多個 Git Worktree 之間快速切換](https://stwillblogassets.blob.core.windows.net/files/images/2026/10/gwt-worktree-switcher/gwt-worktree-switcher-banner.webp)

我一開始想的是寫一支腳本，輸入關鍵字就幫我跳過去。但這條路有個繞不過去的限制：**腳本是在子處理序裡執行的，它執行完之後，目前這個終端機的工作目錄不會跟著改變**。所以最後我寫成了一個 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` | 中斷，並把隱藏的游標恢復回來 |

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

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

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

* * *

### 安裝方式與完整原始碼

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

```bash
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` 都找不到：

```bash
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，再依照習慣調整按鍵與顯示的格式。

* * *

### 相關連結

-   [Git 官方文件：git-worktree](https://git-scm.com/docs/git-worktree)
-   [Git 官方文件：git worktree list 的輸出格式 (--porcelain)](https://git-scm.com/docs/git-worktree#_list_output_format)
-   [zsh 文件：read 內建指令 (-k、-s、-t 選項)](https://zsh.sourceforge.io/Doc/Release/Shell-Builtin-Commands.html#index-read)
-   [zsh 文件：Parameters Used By The Shell (path 與 PATH 的關係)](https://zsh.sourceforge.io/Doc/Release/Parameters.html#Parameters-Used-By-The-Shell)
-   [使用 git worktree 管理一個本地儲存庫下的多個工作目錄副本](/post/2023/10/29/git-worktree-manage-multiple-working-directories/)
-   [junegunn/fzf: A command-line fuzzy finder](https://github.com/junegunn/fzf)
