↓快轉到主要內容
  1. 教學文章/

Git log Pickaxe:用 -S / -G 追查程式碼變更來源

·7 分鐘· loading · loading · ·
Git Git Log Pickaxe Debugging Code Archaeology Developer-Tools
每日拍拍
作者
每日拍拍
科學家 X 科技宅宅
目錄
版本控制: Git - 本文屬於一個選集。
§ 22: 本文

一. 前言:函式消失了,還能問它的歷史嗎?
#

拍拍醬接手一個專案,舊文件寫著 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、哪些路徑、哪些比較,以及結果沒有保證什麼。

下次看到一個已消失的函式名,先不用問「誰刪的」。問「哪個差異改變了它,還有哪些配套證據?」通常更快接近答案。🔎

延伸閱讀
#

版本控制: Git - 本文屬於一個選集。
§ 22: 本文

相關文章

Git bundle 離線交接:完整封裝、增量更新與還原驗收
·8 分鐘· loading · loading
Git Git Bundle Offline Backup Prerequisites Developer-Tools
Git attributes 實戰:LF/CRLF、Binary 與可審核正規化
·7 分鐘· loading · loading
Git Gitattributes LF CRLF Binary Renormalize Developer-Tools
Git Partial Clone 實戰:blob:none、按需下載與離線驗證
·8 分鐘· loading · loading
Git Partial Clone Blob:none Promisor Remote Lazy Fetch Offline Developer-Tools
Git maintenance 實戰:用 Commit-Graph、MIDX 與排程加速大型 Repo
·10 分鐘· loading · loading
Git Git Maintenance Commit-Graph Multi-Pack-Index Performance Developer-Tools
Git sparse-checkout 實戰:大型 Monorepo 的部分取出與安全切換
·7 分鐘· loading · loading
Git Sparse-Checkout Monorepo Cone Mode Partial Clone Worktree Developer-Tools
Git Changelog 自動化:從 Commit、PR 到 Release Notes
·5 分鐘· loading · loading
Git GitHub Changelog Release Notes Automation GitHub Actions Developer-Tools