拆開 fzf 腳本:每支背後真正在跑的 Git 指令
起因
上一篇講的是這些腳本怎麼設計。這篇反過來:把 fzf 那層剝掉,看每支腳本實際在跑哪幾條 git 指令。
理由很實際。腳本不在手邊時(別人的機器、CI、container),你還是得自己打這些指令。而且拆的過程中,我挖出了兩個藏了很久的 bug。
共用底層
每支腳本開頭都是同一組檢查:
git rev-parse --is-inside-work-tree # 不在 repo 裡就結束
git rev-parse --show-toplevel # cd 到 repo 根目錄
需要 cd 到根目錄,是因為 git status --short 和 git diff --name-status 印出的路徑都是相對於 repo 根目錄。在子目錄裡直接拿去當 pathspec 用,就會對不上。
列分支用的是 git branch --format,語法和 git for-each-ref 一模一樣,連條件式都能寫:
git branch -l --format='%(refname:short) [%(objectname:short)] %(if)%(upstream)%(then)-> %(upstream:short)%(else)<no upstream>%(end)'
gitbr:查分支指向哪裡
gitbr 的用途是:不 checkout 也能知道一條分支指向哪個 commit、有沒有追蹤 upstream,並把 short SHA 複製到剪貼簿。核心只有三條:
git branch --show-current # 目前分支名稱
git rev-parse <ref> # ref → SHA
git rev-parse --abbrev-ref <branch>@{upstream} # 追蹤的遠端分支
<branch>@{upstream}(縮寫 @{u})指的是 branch.<name>.merge 設定的那條 remote-tracking branch。如果沒有設定,指令會失敗,所以腳本後面接了 || true。
坑一:annotated tag 拿到的不是 commit SHA
$ git tag -a v1 -m v1 && git tag v1-light
$ git rev-parse HEAD v1 v1-light
c656c28... # HEAD
dbfc2d5... # v1 ← tag object 本身的 SHA
c656c28... # v1-light
Annotated tag 是一個獨立的 object,rev-parse 預設回傳的是它自己的 SHA。所以 gitbr -t 複製到剪貼簿的 SHA,貼到 git log 可以用,但貼到 GitHub 的 commit 頁面就會查無此 commit。修正方式是 peel 到 commit:
git rev-parse "v1^{commit}" # c656c28...
在 for-each-ref 裡對應的寫法是 %(*objectname),星號代表「peel 後的 object」。
坑二:detached HEAD
git branch --show-current 在 detached HEAD 狀態下什麼都不會印。gitbr 不帶參數時會當成「沒選到分支」,直接結束,但 HEAD 明明有 commit。修正方式是名稱為空時直接用 HEAD 本身:
branch=$(git branch --show-current)
branch=${branch:-HEAD}
HEAD@{upstream} 在 detached 狀態下會失敗,剛好落到「no upstream branch」那條路徑。
不用腳本的替代品
git branch -vv # SHA、upstream、ahead/behind
git for-each-ref --format='%(refname:short) %(upstream:track)' refs/heads
git branch --contains <sha> # 哪些分支含這個 commit
git branch --merged main # 已併入 main,可以刪
導覽類
gitsw:從git branch --format='%(refname:short)'選一條分支,接著git switch。gitco:git branch --track <local> <remote>建立追蹤分支,再git switch。直接git switch <name>其實也會自動猜出遠端分支並建立追蹤,但前提是只有一個 remote 有同名分支。gitl1:git log --oneline,-c看<branch>..HEAD,-b看A...B。兩點表示「B 有、A 沒有」;三點是 symmetric difference,也就是「兩邊各自有、對方沒有」的 commit。開 log 前先用git rev-list -n 1 <range>確認範圍不是空的。gitrl:git reflog -n 100選一筆,然後git checkout <sha>。誤 reset 之後救命用的就是這個,HEAD@{1}就是上一個位置。
檢視與提交
gitdf:預設git diff;-c是git diff --cached,比較 index 和 HEAD;-b是git diff BASE TARGET。關掉空白差異時用--ignore-all-space。gitst:用git add/git restore --staged逐檔切換 stage 狀態,接著git commit --fixup <sha>,再git rebase -i --autosquash。gitsh:git stash push -m <msg> -- <paths>只 stash 選中的檔案;還原時刻意拆成apply加詢問後才drop,而不用pop。pop遇到衝突時不會刪掉 entry,拆開寫比較好掌握狀態。
坑三:autosquash 的 base 給錯
gitst 原本是這樣寫的:
git commit --fixup "$commit"
git rebase -i --autosquash "$commit" # ✗
rebase -i <upstream> 只會重排 <upstream> 之後的 commit,目標 commit 本身不在 todo list 裡,fixup! 找不到可以併入的對象。實測結果:
6d770ce fixup! c1
a875c12 c2
0db13c6 c1
fixup! 原封不動地留在歷史裡。正確寫法是往前退一格;如果目標是 root commit,它沒有 parent,就改用 --root:
base="${commit}~1"
git rev-parse -q --verify "$base" > /dev/null || base=--root
git rebase -i --autosquash "$base"
改寫與並行
gitrb:一行搞定:git rebase --onto "$ONTO" "$(git merge-base "$FROM" "$TO")" "$TO"把
FROM和TO分岔點之後、屬於TO的 commit,搬到ONTO上。用merge-base而不是直接填FROM,是為了在FROM已經往前長了之後,仍然切在正確的位置。gitwt:git worktree add <path> <branch>。分支只存在於遠端時,worktree 一樣會自動建立追蹤分支。移除用git worktree remove,殘留的紀錄用git worktree prune清掉。gitsm:先git submodule sync --recursive,讓.git/config裡的 URL 跟上.gitmodules;再git submodule update --init --recursive,checkout 到 superproject 記錄的 commit。只跑update不跑sync,在 submodule 換過 URL 之後會一直 fetch 失敗。
小結
這 11 支腳本加起來,真正用到的 git 指令不到 20 條。fzf 解決的是「選」的問題,指令本身還是得懂。這次能抓出 rev-parse 和 autosquash 兩個 bug,也是因為把指令拆出來一條一條實測,而不是靠記憶。