一. 前言:函式消失了,還能問它的歷史嗎? #
拍拍醬接手一個專案,舊文件寫著 retry_budget(),現在的程式碼卻完全找不到。
「是不是刪錯了?」先別急。它可能改名、搬家,也可能被另一套策略取代。
搜尋目前檔案只能告訴你「現在有沒有」;拍拍君今天要問的是:哪一次提交,改變了這段文字?
Git 的 Pickaxe 搜尋可以拿字串或正規表示式,篩出相關的歷史變更。
但 -S 不是 -G 的縮寫;選錯工具,就會漏掉「名字沒變,呼叫方式卻改了」的提交。
本篇用五個小版本實測,不改寫歷史、不搬運 commit,也不靠作者名字猜原因。
若你的問題是「哪一版開始測試失敗」,請看 Git bisect;文字線索不等於行為證據。
二. 先選問題:你到底想搜尋什麼? #
| 問題 | 適合的入口 | 不是它的保證 |
|---|---|---|
| 現在某個版本還有這段文字嗎? | git grep |
不遍歷所有歷史 |
| Commit message 提到這個詞嗎? | git log --grep |
不搜尋程式碼 patch |
| 字串在某檔案的出現次數變了嗎? | git log -S |
不抓所有含字串的行變更 |
| 新增/刪除的行符合 regex 嗎? | git log -G |
不理解函式或 AST 語意 |
| 哪個版本先出現可重現的錯誤? | git bisect |
需要可判斷的 good/bad |
拍拍君的順序通常是:先用 Pickaxe 找候選,讀完整提交,再跑相關測試。
找得到文字變化,只表示這次 diff 符合條件,不代表你已經找到 bug 根因。
三. 安裝與準備:建立自己的小型考古現場 #
Pickaxe 是 Git 內建功能,不需另裝 Python 套件。
以下使用 Bash/Git Bash,同一個 shell 依序執行;只有建置 fixture 會寫入新 repo,搜尋指令不會 checkout 舊版本。
先確認 Git,建立拋棄式目錄,避免把示範 commit 寫進工作專案:
set -eu
git --version
lab_dir=$(mktemp -d)
git init -b main "$lab_dir/repo"
cd "$lab_dir/repo"
git config user.name '拍拍君'
git config user.email 'pypy@example.invalid'
git config commit.gpgsign false
git config tag.gpgsign false
mkdir src docs
printf '# Pypy archaeology\n' > README.md
git add README.md
git commit -m 'Start archaeology lab'
git tag lab-base
本文實測 Git 2.50.1。lab-* 是本篇專用的輕量 tag,用來固定搜尋範圍,不是 release 教學。
真實專案有自己的 Git 設定;本篇的小 repo 不依賴外部 diff driver 或 textconv。
四. 造出兩個「看起來都改到同一個字」的 Commit #
第一個版本加入函式與呼叫處,retry_budget 在同一個檔案出現兩次:
cat > src/retry.py <<'PY'
def retry_budget():
return 2
def send():
return retry_budget()
PY
printf 'Initial policy: two retries.\n' > docs/policy.md
git add src/retry.py docs/policy.md
git commit -m 'Add retry policy'
git tag lab-add
第二個版本只修改呼叫的運算式,並同步解釋政策:
cat > src/retry.py <<'PY'
def retry_budget():
return 2
def send():
return retry_budget() + 1
PY
printf 'Policy: add one attempt at the call site.\n' > docs/policy.md
git add src/retry.py docs/policy.md
git commit -m 'Adjust caller and document policy'
git tag lab-adjust
名字仍然出現兩次,但 send() 的結果已經從 2 變成 3。
這就是拿來區分 -S 與 -G 的關鍵:文字次數沒變,含有那個詞的變更行卻變了。
五. -S:比較次數,不是找所有提到它的 Patch
#
先用同一個範圍搜尋:
git --no-pager log --reverse --format='%s' \
-S'retry_budget' lab-base..lab-adjust -- src/
預期只有:
Add retry policy
lab-base..lab-adjust 選右端可達、但左端不可達的 commit;它不是只比較兩端快照的一次 diff。
在加入函式那次,某個檔案的次數從 0 變成 2,所以命中。
第二次改動是 2 → 2,因此不命中;不是 Git 沒看到加號,也不是搜尋壞掉。
這個次數比較以檔案前後版本為單位,不是把整個 repo 的數字加總。跨檔案搬動可能仍會命中。
若想追較精確的程式碼片段,可以改用新運算式:
git --no-pager log --format='%s' \
-S'return retry_budget() + 1' lab-add..lab-adjust -- src/
這次會找到 Adjust caller and document policy,因為這個完整字串由 0 次變成 1 次。
-S 預設把參數當字面字串;括號和加號在這裡不需要為 regex 另外跳脫。
六. -G:看新增/刪除的行,有沒有符合 Regex
#
改用 -G 重做第一個搜尋:
git --no-pager log --reverse --format='%s' \
-G'retry_budget' lab-base..lab-adjust -- src/
預期兩個提交都出現:
Add retry policy
Adjust caller and document policy
要看證據,就加 -p,不要只讀 subject:
git --no-pager log -p -G'retry_budget' lab-add..lab-adjust -- src/
其中會看到這一對行:
- return retry_budget()
+ return retry_budget() + 1
兩行都符合 pattern,所以即使次數不變,-G 仍然命中。
反過來說,關鍵字只出現在沒有變動的上下文行,不足以讓這個 commit 命中 -G。
需要括號字面值時,可用 POSIX extended regex,例如 -G'retry_budget\('。
-S'retry_budget\(' --pickaxe-regex 也能吃 regex,但仍比較出現次數;加這個選項不會讓它變成 -G。
七. --pickaxe-all:找線索之後,別把配套修改裁掉
#
剛才的呼叫調整,同時改了 docs/policy.md,但文件裡沒有那個 Python 運算式。
比較這兩個查詢,這次刻意不限制路徑:
git --no-pager log -p -S'return retry_budget() + 1' \
lab-add..lab-adjust
git --no-pager log -p --pickaxe-all -S'return retry_budget() + 1' \
lab-add..lab-adjust
第一個只保留符合 Pickaxe 的檔案變更;第二個讓命中提交的其他檔案變更也留下來,包含政策文件。
但 --pickaxe-all 不會取消你自己設定的 pathspec。
如果尾端仍寫 -- src/,文件已被路徑範圍排除,就不會神奇地重新出現。
實務上可以先用狹窄路徑找出 OID,再用 git show <OID> 審查整個提交;不要把搜尋過濾器一路帶到最後,卻聲稱看過全部修改。
八. 搬家與消失:補完後面兩個版本 #
先只改名,不改內容:
git mv src/retry.py src/policy.py
git commit -m 'Move retry module'
git tag lab-move
再把舊符號換成新名字:
cat > src/policy.py <<'PY'
def attempt_limit():
return 2
def send():
return attempt_limit() + 1
PY
git add src/policy.py
git commit -m 'Replace retry_budget with attempt_limit'
git tag lab-delete
現在 HEAD 已經沒有舊名字。下列 git grep 的失敗是預期結果,不應中斷實驗:
if git grep -n -F 'retry_budget' HEAD -- src/; then
printf 'Unexpected old symbol\n' >&2
exit 1
else
grep_rc=$?
test "$grep_rc" -eq 1
printf 'Expected: old symbol is absent at HEAD\n'
fi
git --no-pager log --reverse --format='%s' -M \
-S'retry_budget' lab-base..lab-delete -- src/
Pickaxe 仍會列出加入與移除舊名的提交,因為它讀的是歷史差異,不只讀 HEAD 的檔案。
在這個精確改名的例子,-M 配對前後檔案後,純搬家那次是 2 → 2,不會被 -S 選出。
這不表示檔案沒搬過,只表示「出現次數改變」不是找純改名的問題。
九. 路徑不是永久身分:--follow 要單獨想清楚
#
想從現在的檔名往回看,可以先不加 Pickaxe,檢查完整路徑歷史:
git --no-pager log --follow -M --name-status \
--format='%h %s' main -- src/policy.py
git --no-pager log --follow -M -p -S'retry_budget' \
main -- src/policy.py
第一條用來確認搬家,第二條才篩文字次數變化;兩條查詢回答的問題不同。
--follow 一次只支援一個檔案,不是「替整個目錄建立永久 ID」。Git 的 rename detection 也依賴內容相似度,不是儲存了一張永遠正確的搬家證書。
大量重寫、複製成兩份、合併分支或路徑篩選,都會讓歷史判讀變複雜。
若結果怪怪的,先拿掉 Pickaxe 看 --name-status,再同時搜尋已知的新舊路徑,必要時擴大到整個模組。
本篇只驗證線性歷史與精確改名,不保證一個 --follow 能重建所有跨分支來源。
十. 固定版本範圍,才知道「沒找到」到底代表什麼 #
預設 git log 從 HEAD 開始;別的分支上有一段文字,不代表目前分支搜尋一定看得到。
在這個實驗裡,可以明確查 main,或查所有本機 refs:
git --no-pager log --format='%h %s' -S'retry_budget' main -- src/
git --no-pager log --format='%h %s' -S'retry_budget' --all -- src/
git rev-parse --is-shallow-repository
--all 不是自動搜尋 GitHub 上所有 branch;它使用本機的 refs。Remote-tracking refs 過期,結果也可能過期。
Shallow clone 缺早期 commit;Partial clone 則可能在讀歷史 blob 時觸發下載,細節見 Partial Clone 與離線驗證。
不要為了找字就立刻改動真實 repo 的 remote 或抓遍所有 refs;先確認你需要的分支、取得權限與網路條件。
大型專案可先限制版本/目錄,再逐步放寬;過早加上 --since 或 -n 1,會讓「最早出現」的推論失去依據。
Merge 也要特別處理:預設 log 不會像一般提交那樣逐一展示所有 merge-parent diff。
只關心主線整合結果時,可另外評估 --first-parent --diff-merges=first-parent;它會改變遍歷與比較視角,不是無條件更完整。
十一. 找到刪除提交之後,怎麼把「為什麼」補回來? #
在實驗中,我們已經用 tag 知道候選是 lab-delete。真實專案要把搜尋得到的完整 OID 記下來。
candidate_oid=$(git rev-parse 'lab-delete^{commit}')
git --no-pager show --format=fuller --stat "$candidate_oid"
git --no-pager show "$candidate_oid" -- src/policy.py
git show "${candidate_oid}^:src/policy.py"
git show "${candidate_oid}:src/policy.py"
test -z "$(git status --porcelain)"
^ 在這裡指唯一 parent;若候選是 merge,必須明確決定 ^1 或 ^2,而且舊版本的檔名也可能不同。
這些指令直接讀 objects,不必先 checkout 舊 commit。搜尋完成後,工作目錄仍應乾淨。
接下來整理三欄證據:刪掉/加上什麼、同批文件或測試怎麼變、commit message/PR 有沒有說明動機。
我們的 fixture 能證明舊名被新名取代,卻沒有神奇證明「這樣設計比較好」。名字與 diff 只是起點。
如果 PR 或 issue 沒留下原因,就誠實記成「動機未確認」;不要從作者、日期或短短 subject 編出故事。
要寫面向使用者的版本摘要,另看 Changelog 自動化;那是從變更證據整理資訊,不是這篇的文字搜尋任務。
結語:先把問題問準,再拿起考古工具 #
-S 找次數變化,-G 找符合 regex 的新增/刪除行;--pickaxe-all 補提交脈絡,但不能越過你設定的路徑邊界。
拍拍君最在意的不是查詢有多短,而是你能不能說清楚:查了哪些 refs、哪些路徑、哪些比較,以及結果沒有保證什麼。
下次看到一個已消失的函式名,先不用問「誰刪的」。問「哪個差異改變了它,還有哪些配套證據?」通常更快接近答案。🔎