Git 版本控制 › 附錄 A

附錄 A · diff / patch 原理與 Git 的關係

2026-06-18  ·  diff · patch · unified diff · hunk · 快照 vs delta

第 6 章出現了 hunk 這個詞——它其實不是 Git 發明的,而是 Git 從更古老的 Unix diff / patch 工具繼承來的。這篇番外把鏡頭拉遠:diff 怎麼算出兩個檔案的差異patch 怎麼把差異「重放」回去,以及最關鍵的——Git 到底用了它們什麼、又刻意沒用什麼。讀完你會明白一句看似矛盾的話:「Git 存的是快照、不是差異,但你看到的差異是它即時算出來的。」這是理解 Git 心智模型的最後一塊拼圖。

A.1diff 在解決什麼:把差異「算」出來

diff 是 1974 年貝爾實驗室就有的 Unix 工具,要回答一個問題:「把檔案 A 變成檔案 B,最少要做哪些增刪?」它不是逐字元亂比,而是先找出兩邊共有、且順序一致的最長行序列——這就是經典的 LCS(最長共同子序列,Longest Common Subsequence)

GNU diff 與 Git 預設用的是 Myers 演算法(Eugene Myers, 1986),求的是「最短編輯腳本(shortest edit script)」——也就是讓增刪總數最少的那組差異。這解釋了 diff 兩個常被誤會的行為:

現象原因
diff 的單位是「行」,改一行裡一個字,會顯示成「刪這行 + 加這行」預設以整行為比對最小單位(故第 6 章衝突也判到「行 / hunk」這層)
有時對齊得很聰明,有時看起來「錯位」它追求的是編輯數最少,不是「人類覺得最直覺」;同樣的內容可能有多種最短解
Git 還提供 --diff-algorithm=patience / histogram 等替代演算法。它們改變的是「怎麼挑對齊」,讓某些重排場景的 diff 更貼近人眼直覺——但核心目標始終是「用最少增刪描述 A→B」

A.2格式的演進:從 normal 到 unified diff

算出差異後得有個格式把它寫下來。三代格式一路在解決「人看得懂 + 機器套得回去」:

格式指令特點
normaldiff最早、最精簡(3c3 / < / >),沒有上下文,難閱讀也難可靠套用
contextdiff -c首度帶前後幾行 context,patch 才能靠上下文「定位」,容錯套用
unifieddiff -u把兩邊合併成一欄顯示,更緊湊;Git 的預設格式

Git 到處用的就是 unified diff。它的結構只有兩個重點:hunk 標頭 @@ ... @@ 與三種前綴的行。拆給你看:

unified diff 的結構解剖 @@ -1,3 +1,4 @@ 是 hunk 標頭,-1,3 表示舊檔從第1行起共3行,+1,4 表示新檔從第1行起共4行;空白前綴是 context、減號是刪除行、加號是新增行。 @@ -1,3 +1,4 @@  function greet() { -  return "你好"; +  const name = "world"; +  return "Hello";  } hunk 標頭 -1,3 舊檔第1行起3行 / +1,4 新檔第1行起4行 空白前綴 = context 兩邊都有、沒變,用來定位 - 前綴 = 刪除行 只在舊檔(在 LCS 之外) + 前綴 = 新增行 只在新檔(在 LCS 之外) 一個 @@…@@ 加底下幾行,就是一個 hunk;一個檔案的 diff 可有多個 hunk
圖 A-1unified diff 解剖:@@ 標頭給行號範圍,接著 context / - 刪除 / + 新增 三種行。整塊就是一個 hunk

A.3patch:把 diff 當「可重放的指令」

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,其餘照套,讓你手動處理落單的那塊
這正是第 6 章「衝突」的遠房親戚:patch 套不上會產生 .rej;Git 三方合併撞到同一 hunk 會產生 <<< === >>> 衝突。兩者本質都是「自動套用失敗,把選擇權交還給人」——只是發生在不同工作流裡。

A.4Git 用了什麼、又刻意沒用什麼

這是全篇重點。許多其他版本控制(如早期 RCS、SVN 的某些設計)把歷史存成「一個基準檔 + 一串 diff(delta)」:要還原某版,得從基準開始把差異一路累加。Git 偏偏不這樣——第 2 章說過,每顆 commit 指向一棵完整的 tree 快照,內容定址讓沒變的 blob 自動去重,所以「存快照」並不浪費空間。下面切換兩種模型對照:

Git 快照儲存模型 每個版本 V1、V2、V3 都各自指向一份完整快照,未變動的內容透過內容定址自動共用,不需累加差異即可取出任何版本。 V1 快照 完整 tree V2 快照 完整 tree V3 快照 完整 tree 取任何版本 = 直接讀那份快照,O(1),不必累加
圖 A-2Git 模型:每版各存完整快照(未變內容靠內容定址去重),取任一版直接讀取
傳統差異(delta)儲存模型 只有 V1 是完整檔,V2、V3 各自只存相對前一版的差異 Δ,要還原 V3 必須從 V1 開始把所有差異依序累加。 V1 完整檔 基準 base Δ2 差異 只存改了什麼 Δ3 差異 只存改了什麼 取 V3 = V1 + Δ2 + Δ3 依序套用,版本越多越要累加
圖 A-3傳統 delta 模型:存基準 + 一串差異,還原晚期版本要把 diff 一路累加
Git 走快照模型:每顆 commit 都指向完整 tree,取任何版本都是直接讀,不需累加差異。按上面切換看傳統 delta 模型的差別。
一個老實的補充:Git 在 packfile(打包壓縮儲存)裡,其實也會用 delta 把相近物件存成差異來省硬碟。但這是儲存層的壓縮最佳化,對你和對 Git 的指令模型都是透明的——概念上 Git 仍是「一串快照」,且這種內部 delta 跟前面講的 unified diff 文字格式是兩回事。別把「packfile 有 delta」誤讀成「Git 像 SVN 那樣存 diff」。

那麼 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 applyGit 內建的「patch 套用器」,把一份 diff 套到工作目錄

A.5互動:逐 hunk 操作(-p patch 模式)

理解 hunk 之後,最實用的回報就是 -p(patch 模式)——它把一個檔案的改動切成一塊塊 hunk,讓你逐塊決定,而不是只能整檔處理。下面這份 greet.js3 個 hunk。先選一個指令(模式),再對每塊按 y(選它)/ n(跳過)/ q(結束)——走完看結果。重點是體會:同一套 y/n/q 介面,三個指令對「選中的 hunk」做的事完全不同。

三者的共通點是「以 hunk 為選取單位」(全靠前面那台 diff 機器把改動切塊);差別只在選中後送去哪:add -p 送進暫存區(為「只 commit 一部分」鋪路)、restore -p(舊稱 checkout -p,見第 5 章 switch/restore 拆分)丟棄選中改動、stash -p 把選中改動收進 stash。真實的 patch 提示其實還有 s(把大塊再切細)、e(手動編輯這塊)、a/d(整檔全選/全跳)等鍵,這裡用 y/n/q 示意核心流程。

A.6本附錄小結

誰 / 何時貢獻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 工作流
一句話收束:diff/patch 是 Git 的「鏡頭」,不是它的「底片」。底片(儲存)是一張張完整快照;但每當你要「看看改了什麼」「挑幾塊來提交」「把兩條線合起來」,Git 就架起這台從 1970 年代傳承至今的 diff 機器,當場算給你看。理解這個分工,你就能解釋幾乎所有 Git 的差異相關行為。