2026-06-18 · diff · patch · unified diff · hunk · 快照 vs delta
diff / patch 工具繼承來的。這篇番外把鏡頭拉遠:diff 怎麼算出兩個檔案的差異、patch 怎麼把差異「重放」回去,以及最關鍵的——Git 到底用了它們什麼、又刻意沒用什麼。讀完你會明白一句看似矛盾的話:「Git 存的是快照、不是差異,但你看到的差異是它即時算出來的。」這是理解 Git 心智模型的最後一塊拼圖。
diff 是 1974 年貝爾實驗室就有的 Unix 工具,要回答一個問題:「把檔案 A 變成檔案 B,最少要做哪些增刪?」它不是逐字元亂比,而是先找出兩邊共有、且順序一致的最長行序列——這就是經典的 LCS(最長共同子序列,Longest Common Subsequence)。
-)。+)。GNU diff 與 Git 預設用的是 Myers 演算法(Eugene Myers, 1986),求的是「最短編輯腳本(shortest edit script)」——也就是讓增刪總數最少的那組差異。這解釋了 diff 兩個常被誤會的行為:
| 現象 | 原因 |
|---|---|
| diff 的單位是「行」,改一行裡一個字,會顯示成「刪這行 + 加這行」 | 預設以整行為比對最小單位(故第 6 章衝突也判到「行 / hunk」這層) |
| 有時對齊得很聰明,有時看起來「錯位」 | 它追求的是編輯數最少,不是「人類覺得最直覺」;同樣的內容可能有多種最短解 |
--diff-algorithm=patience / histogram 等替代演算法。它們改變的是「怎麼挑對齊」,讓某些重排場景的 diff 更貼近人眼直覺——但核心目標始終是「用最少增刪描述 A→B」。
算出差異後得有個格式把它寫下來。三代格式一路在解決「人看得懂 + 機器套得回去」:
| 格式 | 指令 | 特點 |
|---|---|---|
| normal | diff | 最早、最精簡(3c3 / < / >),沒有上下文,難閱讀也難可靠套用 |
| context | diff -c | 首度帶前後幾行 context,patch 才能靠上下文「定位」,容錯套用 |
| unified | diff -u | 把兩邊合併成一欄顯示,更緊湊;Git 的預設格式 |
Git 到處用的就是 unified diff。它的結構只有兩個重點:hunk 標頭 @@ ... @@ 與三種前綴的行。拆給你看:
diff 只是「描述差異」;真正把差異套用到檔案上的,是 Larry Wall 1985 年寫的 patch 程式——hunk 這個稱呼就是隨它流行起來的(用過的人都看過那句 Hunk #3 FAILED at 42.)。patch 把一份 diff 當成一串指令,逐個 hunk 套到目標檔。
它聰明的地方在於不靠絕對行號硬套,而是用 hunk 裡的 context 行去「比對定位」。這帶來兩個容錯能力:
| 機制 | 解決的問題 |
|---|---|
| offset(位移) | 目標檔在別處多/少了幾行,使行號對不上——patch 靠 context 找到真正位置,自動偏移套用 |
| fuzz(模糊) | 連 context 都對不太上時,可放寬比對(忽略部分上下文行)勉強套上,但風險較高 |
| .rej(reject) | 某個 hunk 怎麼都套不上 → patch 把它單獨吐成 檔名.rej,其餘照套,讓你手動處理落單的那塊 |
.rej;Git 三方合併撞到同一 hunk 會產生 <<< === >>> 衝突。兩者本質都是「自動套用失敗,把選擇權交還給人」——只是發生在不同工作流裡。
這是全篇重點。許多其他版本控制(如早期 RCS、SVN 的某些設計)把歷史存成「一個基準檔 + 一串 diff(delta)」:要還原某版,得從基準開始把差異一路累加。Git 偏偏不這樣——第 2 章說過,每顆 commit 指向一棵完整的 tree 快照,內容定址讓沒變的 blob 自動去重,所以「存快照」並不浪費空間。下面切換兩種模型對照:
那麼 diff/patch 那套機器,Git 用在哪?答案是——用在「呈現」與「操作」,不是用在「儲存」。你看到的每個 diff,都是 Git 拿兩份快照當下算出來的視圖:
| Git 功能 | 底層其實是 diff/patch 的應用 |
|---|---|
git diff / show / log -p | 即時對兩份快照算 unified diff 給你看(沒有「存好的 diff」可讀) |
git add -p / checkout -p / stash -p | 把 diff 切成 hunk,讓你逐塊挑要不要——hunk 概念的直接體現 |
| 三方合併(第 5、6 章) | 本質是把「base→ours」與「base→theirs」兩組 diff 合起來;兩邊動到同一 hunk = 衝突 |
git blame | 靠逐版 diff 追每一行最後是誰、哪顆 commit 改的 |
git format-patch + git am | 把 commit 匯出成 email 風格的 patch 檔、再於對方端套回——這正是 Linux kernel 用 mailing list 協作的方式,直接繼承 patch 文化 |
git apply | Git 內建的「patch 套用器」,把一份 diff 套到工作目錄 |
-p patch 模式)理解 hunk 之後,最實用的回報就是 -p(patch 模式)——它把一個檔案的改動切成一塊塊 hunk,讓你逐塊決定,而不是只能整檔處理。下面這份 greet.js 有 3 個 hunk。先選一個指令(模式),再對每塊按 y(選它)/ n(跳過)/ q(結束)——走完看結果。重點是體會:同一套 y/n/q 介面,三個指令對「選中的 hunk」做的事完全不同。
add -p 送進暫存區(為「只 commit 一部分」鋪路)、restore -p(舊稱 checkout -p,見第 5 章 switch/restore 拆分)丟棄選中改動、stash -p 把選中改動收進 stash。真實的 patch 提示其實還有 s(把大塊再切細)、e(手動編輯這塊)、a/d(整檔全選/全跳)等鍵,這裡用 y/n/q 示意核心流程。
| 誰 / 何時 | 貢獻 | Git 如何承接 |
|---|---|---|
diff(1974) | 用 LCS / 最短編輯算出「兩檔差異」的概念 | 沿用,並提供 Myers / patience / histogram 等演算法 |
patch(1985) | hunk 稱呼、靠 context 容錯套用、.rej 落單機制 | git apply / am 繼承;衝突模型與此同源 |
unified diff(diff -u) | @@ -a,b +c,d @@ + context/-/+ 緊湊格式 | Git 預設顯示格式 |
| Git 自己 | — | 存快照不存 diff;diff 是即時算的視圖;把 hunk 做成 add -p 互動;承接 email patch 工作流 |