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

我一開始想的是寫一支腳本,輸入關鍵字就幫我跳過去。但這條路有個繞不過去的限制:腳本是在子處理序裡執行的,它執行完之後,目前這個終端機的工作目錄不會跟著改變。所以最後我寫成了一個 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,再依照習慣調整按鍵與顯示的格式。
相關連結