Git 版本控制 › 第 6 章

合併衝突與解決

2026-06-18  ·  conflict · hunk · 衝突標記 · 解衝突流程 · mergetool

第 5 章的三方合併,大多數時候 Git 會悄悄地把兩邊改動自動併好,你甚至不會察覺。但有一格它不敢替你決定——兩條分支改到同一段、又改得不一樣。這時 Git 會停下來,把那段標記出來,請你親自裁決。這就是合併衝突(merge conflict)。本章拆解三件事:衝突到底以什麼為單位判定(答案不是「整個檔案」)、Git 寫進檔案的 <<<<<<< ======= >>>>>>> 標記怎麼讀、以及解衝突的完整流程。看懂後你會發現,衝突不是錯誤,而是 Git 誠實地把選擇權交還給你。

6.1衝突為何發生:判斷單位是「段」,不是「檔」

最常見的誤解是「兩個人改了同一個檔案就會衝突」。錯。Git 比對的單位細到檔案裡的一個個區塊(hunk,可粗略想成連續的幾行),而不是整個檔案。(hunk 不是 Git 原創、其來歷與運作見 附錄 A · diff/patch 原理)回到第 5 章三方合併的邏輯:Git 拿共同祖先當基準,逐段問「相對基準,這段誰動過?」——

下圖把這兩種情況並排。同一個檔案有三段,左例兩邊動的是不同段、右例動的是同一段——結果天差地遠:

同檔不同段自動合併,同一段兩邊不同則衝突 左例:main 改第一段、feature 改第三段,屬不同 hunk,Git 自動合併。右例:main 與 feature 都改第二段且改得不一樣,Git 判為衝突。 ① 同檔不同段 → 自動合併 ✓ main 段1* 段2 段3 feature 段1 段2 段3* 合併結果 段1* 段2 段3* 兩邊改動都收下 ② 同一段兩邊不同 → 衝突 ⚠️ main 段1 段2 = X 段3 feature 段1 段2 = Y 段3 ⚠️ 衝突 X?還是 Y? Git 不敢決定 關鍵差別:衝突的判定單位是「檔案內的同一段(hunk)」——只要兩邊動的不是同一段,Git 就自己合好。 (* 號代表相對共同祖先「這一段被改過」)
圖 6-1左:main 改段 1、feature 改段 3,不同段 → 自動收下兩邊。右:兩邊都改段 2 且不一樣 → 衝突

不過「不同段」這件事 Git 判得比你想的保守。以下幾種邊角,即使「邏輯上」不該算衝突,文字層面仍會被判為衝突:

情況會不會衝突為什麼
兩邊改的行緊鄰(上下文重疊)⚠️ 多半會Git 比對是看「連同上下文的整塊」,改動貼太近時兩塊重疊,寧可保守判為衝突讓你確認。
兩邊改同一行的不同部分⚠️ 會判定到「行」這層;即使一個人改行首、一個人改行尾,文字層面仍是同一行被兩種版本覆蓋。
一邊改內容、另一邊刪掉整檔(modify/delete)⚠️ 會Git 無法判斷「該保留你的修改,還是尊重對方的刪除」,標為特殊的 modify/delete 衝突,要你選留檔或刪檔。
所以「Git 自動合好」靠的是兩邊動的區塊彼此錯開。實務上把改動拆小、頻繁合併(別讓分支放太久才併),能大幅降低撞到同一段的機率——衝突的多寡,某種程度反映的是協作節奏,而非工具好壞。
延伸問答

上面提到「一邊改內容、另一邊刪掉整個檔案」(modify/delete)。這種衝突跟一般的內容衝突,解法有什麼不同?

差很多,根源在於衝突發生的層級不同。一般衝突發生在檔案內容(同一個 hunk/行),Git 在問你「這段內容該長怎樣」;modify/delete 發生在檔案存在與否,Git 在問的是「這個檔到底還要不要」。

一般(內容)衝突modify/delete 衝突
衝突層級檔案內容(hunk/行)檔案存在與否(整檔)
<<< === >>> 標記,兩版並排寫進檔裡讓你拼沒有(見下方說明)
工作目錄狀態檔案裡是夾著標記的混合體檔案原樣留著修改方那一版,無標記
解法本質編輯內容、挑/重寫,再 git add檔案層級二選一:留檔 或 刪檔

為什麼沒有衝突標記?一般衝突時 Git 兩邊都有「這幾行的內容」,可以塞成 <<< 我方 === 對方 >>>。但 modify/delete 是「檔案內容」對上「這檔不該存在」——兩者無法在同一個檔裡並排(你沒辦法寫「內容 vs 不存在」)。所以 Git 不放標記,而是把修改方的整版檔案留在工作目錄,改用 git status 的文字告訴你:

CONFLICT (modify/delete): config.yml deleted in feature
  and modified in HEAD.
# 措辭會是 deleted by them(對方刪、你改)或 deleted by us(你刪、對方改)

解法不是編輯內容,而是下決定。因為沒有標記可清,你用指令表態「要哪一邊」:

git add config.yml   # 採用「修改方」→ 保留這個檔(就用留著的那版)
git rm  config.yml   # 採用「刪除方」→ 真的把它刪掉
git commit           # 表態完照常收尾

對比一般衝突的流程(見 6.3):那邊是「編輯檔案挑內容、刪掉標記git add」;這裡跳過「編輯 + 刪標記」,直接 git add(留)或 git rm(刪)做檔案層級的裁決。

一句話記:一般衝突是「拼內容」,modify/delete 是「保留還是刪除,二選一」——前者用編輯器,後者用 git add / git rm 表態。⚠️ 小陷阱:忘了表態就直接 git commit,Git 會擋下來(還有未解的 unmerged 路徑),一定要先對那個檔 addrm

6.2衝突標記:<<< === >>> 怎麼讀

碰到衝突時,Git 不會擅自二選一,而是把兩邊的版本都原封不動寫進檔案,中間用三組標記隔開,等你來裁決。打開那個檔案,衝突的那段會長這樣:

衝突標記的結構解讀 從 <<<<<<< HEAD 到 ======= 之間是目前分支(HEAD/ours)的內容,從 ======= 到 >>>>>>> feature 之間是被合併進來那一方(theirs)的內容。 function greet() { <<<<<<< HEAD   return "你好"; =======   return "Hello"; >>>>>>> feature } ▲ 開始標記 HEAD = 你目前分支(ours) ◆ 分隔線 上半 = 我方,下半 = 對方 ▼ 結束標記 feature = 併進來那方(theirs)
圖 6-2衝突區三段式:<<<<<<< HEAD=======我方、到 >>>>>>> feature對方
這三行標記是普通文字、會真的存進檔案。解完衝突務必把 <<<===>>> 三行全部刪掉,只留你要的內容。忘了刪,等於把這些亂碼提交進程式碼裡(編譯器/直譯器多半會直接報錯)。git diff 在解衝突期間也會特別把這些標記凸顯出來,提交前可再掃一眼。

圖 6-2 只列了我方 / 對方兩段,但第 5 章說過三方合併真正靠的是共同祖先(merge base)當基準——執行一次 git config merge.conflictStyle diff3,衝突標記就會多一段,變成三段式:

開啟 diff3 後,======= 分隔線前會多插入 |||||||======= 這一段,內容是共同祖先當初的版本。三段對照起來就是我方 / 祖先 / 對方——有了祖先版本當基準,才看得出「兩邊各自相對原始內容動了什麼」,而不是只看到兩個沒有上下文的結果,對判斷「這行到底該留誰的」特別有幫助。新版 Git 還有加強版 zdiff3(git config merge.conflictStyle zdiff3),會把兩邊沒動、其實相同的行自動摺疊,三段對照更乾淨。這個設定是全域偏好,不影響衝突判定本身,純粹是看得更清楚

6.3解衝突的完整流程與工具

衝突發生時,git merge停下來(合併處於「進行中」狀態),git status 會列出 Unmerged paths。流程其實很固定,四步:

解衝突四步流程 merge 停下 → 編輯檔案挑內容並刪標記 → git add 標記為已解決 → git commit 完成合併 ① 停 merge 中斷 git status 看衝突檔 ② 編輯 挑內容 + 刪標記 手改 或 git mergetool ③ 標記已解 git add <檔> 告訴 Git「這檔好了」 ④ 完成 git commit 生 merge commit 想整個放棄、回到合併前:git merge --abort
圖 6-3解衝突四步:停 → 編輯(挑內容、刪標記)→ git add 標記已解 → git commit。中途反悔用 git merge --abort

幾個關鍵指令與心法:

指令用途
git status列出 Unmerged paths——哪些檔還沒解;解衝突期間最常看的指令。
git add <檔>解衝突的「確認鍵」:把檔案 add 進暫存區,等於告訴 Git「這檔我搞定了」。衝突標記沒清乾淨也照 add,所以 add 前要自己檢查。
git commit所有衝突檔都 add 後,提交即生成 merge commit(訊息已預填,通常直接存檔)。也可用 git merge --continue
git merge --abort後悔藥:放棄這次合併,工作目錄回到 merge 之前的乾淨狀態,當沒發生過。
git mergetool叫出設定好的三方比對工具(VS Code、Beyond Compare、vimdiff…),用「我方 / 對方 / 結果」三欄圖形化挑選,比手刪標記直覺。
git restore --ours/--theirs <檔>整檔直接採用某一方(ours = 你目前分支,theirs = 併進來那方),適合「這檔我確定全聽某一邊」的情況。這是「還原檔案內容」,呼應第 5 章拆分後的職責分工,理當用 restore;舊版寫法 git checkout --ours/--theirs <檔> 效果相同,兩者都通,新代碼建議用前者。
現代編輯器(如 VS Code)會把衝突區上色,並在標記上方直接給「Accept Current(我方)/ Accept Incoming(對方)/ Accept Both(兩者都留)」按鈕,點一下就幫你挑好內容並清掉標記——下一節就用同樣的互動讓你練手。但無論工具多方便,原理都是圖 6-2 那三段:挑哪一邊(或都要、或重寫),然後讓標記消失。

6.4互動:親手解一個衝突檔案

下面是一個含衝突的 greet.js。試著當一次裁判:你要留我方留對方兩者都留,還是自己重寫?點按鈕看解完後檔案長怎樣——注意每種選擇三行標記都會一併消失

注意「兩者都留」不是 Git 替你做的決定——是你判斷這兩段邏輯上可以共存(例如兩個都該保留的函式)。Git 永遠只負責呈現選項,真正「這段程式碼到底該是什麼」的責任,始終在你身上。

6.5本章小結

概念一句話記住
判定單位衝突看的是檔案內的同一段(hunk/行),不是整個檔案——同檔不同段不衝突
保守判定改動緊鄰、同一行各改各的、modify/delete,即使邏輯不衝突仍會被標衝突
衝突標記<<< HEAD===我方,到 >>> 分支對方;三行都是真文字,解完必刪
解衝突流程停 → 編輯挑內容刪標記 → git add(確認鍵)→ git commit
後悔與工具merge --abort 整個放棄;mergetool / --ours / --theirs 加速裁決
到這裡,「合併」的兩種劇情(第 5 章的自動合好 + 本章的手動解衝突)都齊了。但 merge 始終是「保留真實歷史、生一顆 merge commit」。下一章換個角度:rebase 不縫合、而是重寫 commit、把分支「接到」另一條尾端,讓歷史變成乾淨的直線——它怎麼做到、和 merge 各自的取捨、以及那條「不要 rebase 已推送的公開分支」的黃金法則,是第 7 章的主題。