3.7 - Git Conflicts
Tóm tắt: Xung đột xảy ra khi hai nhánh sửa cùng một vùng của cùng một file, và Git từ chối đoán bên nào đúng. Đó là hành vi có chủ đích, không phải lỗi. Phần lớn xung đột giải trong vài phút một khi bạn đọc được ba dấu
<<<<<<<,=======,>>>>>>>. Nhưng bài này dành phần quan trọng nhất cho loại xung đột Git không phát hiện được: hai người sửa hai file khác nhau, merge sạch sẽ không một cảnh báo, và hệ thống hỏng — vì một người đổi tên phương thức còn người kia vừa thêm chỗ gọi nó. Lưới an toàn duy nhất cho loại này là CI chạy test sau mỗi lần merge.
Mục tiêu bài học
Sau bài này bạn có thể:
- Đọc và giải một xung đột văn bản thủ công.
- Chọn giữa
--ours,--theirsvà giải thủ công, và biếtoursnghĩa là gì trong từng ngữ cảnh. - Huỷ một lần merge hoặc rebase đang dở.
- Nhận ra xung đột ngữ nghĩa mà Git không báo.
- Giải xung đột bằng merge editor của VS Code.
- Giảm tần suất xung đột bằng thói quen làm việc.
Cần biết trước: bài 3.5 (merge, rebase), bài 3.6 (CI và branch protection). Bài tập 3 cần biết tạo project console như ở bài 0.2.
Nội dung bài học
3.7.1 — Vì sao Git từ chối đoán
Git hợp nhánh theo từng khối dòng. Nếu hai nhánh sửa hai vùng cách xa nhau trong cùng một file, nó gộp được. Chỉ khi hai bên đụng vào cùng một vùng thì nó mới dừng.
Nó dừng vì việc chọn bên nào là quyết định nghiệp vụ, không phải quyết định kỹ thuật. Một công cụ đoán bừa sẽ âm thầm xoá code của ai đó.
3.7.2 — Đọc dấu xung đột
public decimal CalculateDiscount(Order order)
{
<<<<<<< HEAD
// Phiên bản trên nhánh HIỆN TẠI (nhánh bạn đang đứng)
return order.Total * 0.15m;
=======
// Phiên bản từ nhánh ĐANG ĐƯỢC GỘP VÀO
return order.Total >= 500_000m ? order.Total * 0.20m : 0m;
>>>>>>> feature/discount-tier
}Ba dấu, ba vai trò:
<<<<<<< HEAD— bắt đầu phiên bản của nhánh hiện tại=======— ranh giới>>>>>>> <tên nhánh>— kết thúc phiên bản của nhánh đang gộp vào
Bạn có bốn lựa chọn, và lựa chọn thứ tư mới là thường gặp nhất:
- Giữ phiên bản trên
- Giữ phiên bản dưới
- Giữ cả hai
- Viết một phiên bản thứ ba kết hợp ý của cả hai
Sau khi sửa, xoá sạch ba dấu rồi:
git add src/OrderService.cs
git commit # Git đã soạn sẵn message merge3.7.3 — --ours và --theirs: cẩn thận với nghĩa
git restore --ours src/OrderService.cs # lấy nguyên bản của nhánh hiện tại
git restore --theirs src/OrderService.cs # lấy nguyên bản của nhánh kia
git add src/OrderService.csTài liệu cũ viết git checkout --ours <file>; hai cách cho cùng kết quả.
Có một chỗ rất dễ nhầm: nghĩa của hai từ này đảo ngược giữa merge và rebase.
--ours | --theirs | |
|---|---|---|
Trong merge | Nhánh bạn đang đứng | Nhánh được gộp vào |
Trong rebase | Nhánh đích (nhánh đang rebase lên) | Nhánh của bạn |
Lý do: khi rebase, Git lần lượt đặt commit của bạn lên trên nhánh đích, nên "phía hiện tại" lúc đó chính là nhánh đích. Nhiều người đã lấy nhầm bên vì quên điều này.
Mẹo an toàn: khi đang rebase, đừng dùng --ours/--theirs theo phản xạ. Mở file ra đọc.
3.7.4 — Huỷ khi cần
git merge --abort # huỷ merge, về nguyên trạng trước khi merge
git rebase --abort # huỷ rebase, về nguyên trạng trước khi rebaseHai lệnh này là lối thoát an toàn. Khi một lần merge hoá ra phức tạp hơn dự kiến, --abort đưa mọi thứ về chính xác như cũ — rồi bạn chuẩn bị kỹ hơn và làm lại.
Với rebase nhiều commit, bạn giải xung đột từng commit một:
# sửa xong xung đột của commit hiện tại
git add <file>
git rebase --continue # sang commit tiếp theo
git rebase --skip # bỏ qua commit này (cẩn thận)Đây chính là lý do rebase một nhánh dài có thể phải giải cùng một xung đột nhiều lần — mỗi commit đụng vào vùng đó là một lần.
3.7.5 — Giải xung đột bằng VS Code
Không bắt buộc phải sửa dấu xung đột bằng tay. Mở file đang xung đột trong VS Code, ngay trên mỗi khối có các nút Accept Current Change (giữ bản của nhánh hiện tại), Accept Incoming Change (giữ bản của nhánh đang gộp vào), Accept Both Changes và Compare Changes.
Với xung đột phức tạp, bấm Resolve in Merge Editor ở góc dưới file. Merge editor chia ba ô: Incoming và Current ở trên, Result ở dưới. Bạn tick chọn từng thay đổi hoặc sửa thẳng ô Result, rồi bấm Complete Merge — VS Code tự git add file đó. Sau đó vẫn phải git commit (merge) hoặc git rebase --continue (rebase) như bình thường.
Công cụ chỉ giúp thao tác nhanh hơn; quyết định giữ bên nào vẫn là của bạn, và vẫn phải build, chạy test trước khi commit.
3.7.6 — Xung đột ngữ nghĩa: loại Git không thấy
Đây là phần quan trọng nhất của bài.
// Nhánh A — đổi tên phương thức
public decimal ComputeTotal(Order o) { ... } // trước: CalculateTotal
// Nhánh B — file khác, thêm một chỗ gọi
var total = _service.CalculateTotal(order); // tên cũHai nhánh sửa hai file khác nhau. Git merge sạch sẽ, không một cảnh báo. Và code không biên dịch được.
Đây là xung đột ngữ nghĩa: văn bản không đụng nhau, nhưng ý nghĩa thì có. Git làm việc trên dòng chữ, nên nó không thể phát hiện.
Những dạng thường gặp:
| Nhánh A | Nhánh B | Hậu quả |
|---|---|---|
| Đổi tên phương thức | Thêm chỗ gọi tên cũ | Không biên dịch |
| Đổi kiểu trả về | Dùng kiểu cũ | Không biên dịch |
| Đổi ý nghĩa một trường | Viết code dựa trên ý nghĩa cũ | Chạy được nhưng sai |
Thêm cột NOT NULL | Thêm chỗ INSERT không có cột đó | Hỏng lúc chạy |
Dòng thứ ba nguy hiểm nhất vì không có lỗi nào được ném ra.
Lưới an toàn duy nhất: CI chạy build và test sau mỗi lần merge. Không có cách nào khác — con người không đọc hết được mọi tương tác. Đây cũng là lý do mục "Require status checks to pass" trong bài 3.6 không phải thủ tục hình thức.
3.7.7 — Giảm xung đột bằng thói quen
Bốn thói quen, theo thứ tự hiệu quả:
- Đồng bộ thường xuyên. Mỗi ngày kéo
mainvào nhánh của bạn. Xung đột nhỏ hằng ngày dễ hơn nhiều so với một xung đột khổng lồ sau hai tuần.
git switch feature/abc
git fetch origin
git rebase origin/main # nhánh riêng
# hoặc
git merge origin/main # nhánh có người khác dùngNhánh sống ngắn. Nhánh sống hai ngày hiếm khi xung đột nặng. Nhánh sống ba tuần thì gần như chắc chắn.
Chia việc theo ranh giới file. Nếu hai người phải sửa cùng một file lớn, đó thường là dấu hiệu file đó đang làm quá nhiều việc — tách nó ra giúp cả kiến trúc lẫn quy trình.
Format tự động, thống nhất. Một file
.editorconfigtrong kho vàdotnet formattrong CI loại bỏ toàn bộ nhóm xung đột do khác thiết lập thụt lề.
3.7.8 — Rà lại cách bạn xử lý xung đột
Danh sách rà soát xung đột
- Sau khi giải xung đột, đã xoá sạch mọi dấu <<<<<<<, =======, >>>>>>>.
- Đã build và chạy test sau khi giải xung đột, trước khi commit.
- Biết --ours và --theirs đảo nghĩa giữa merge và rebase.
- Biết dùng merge --abort và rebase --abort khi cần lối thoát.
- Kéo main vào nhánh làm việc ít nhất mỗi ngày một lần.
- Nhánh tính năng sống dưới một tuần.
- Kho có .editorconfig và CI kiểm tra format.
- CI chạy build và test trên kết quả merge, không chỉ trên nhánh nguồn.
Bài tập áp dụng
Bài 1 — Tự tạo một xung đột
Trên kho thử, tạo hai nhánh cùng sửa một dòng. Merge và giải bằng cả bốn cách, mỗi lần git merge --abort rồi làm lại.
Tiêu chí hoàn thành: bạn đọc được ba phần của dấu xung đột mà không cần tra tài liệu.
Gợi ý và lời giải — Bài 1
Gợi ý. git merge --abort luôn an toàn và đưa mọi thứ về trạng thái trước khi merge, nên cứ thử thoải mái.
Lời giải — dựng kịch bản:
git init -q -b main
echo "gốc" > f.txt && git add . && git commit -q -m base
git switch -q -c branch-b
echo "TỪ NHÁNH B" > f.txt && git commit -q -am "edit from B"
git switch -q main
echo "TỪ MAIN" > f.txt && git commit -q -am "edit from main"
git merge branch-bOutput thật:
Auto-merging f.txt
CONFLICT (content): Merge conflict in f.txt
Automatic merge failed; fix conflicts and then commit the result.Nội dung f.txt:
<<<<<<< HEAD
TỪ MAIN
=======
TỪ NHÁNH B
>>>>>>> branch-bĐọc ba phần:
| Phần | Nội dung | Nghĩa |
|---|---|---|
<<<<<<< HEAD tới ======= | TỪ MAIN | Phiên bản của nhánh bạn đang đứng |
======= tới >>>>>>> | TỪ NHÁNH B | Phiên bản của nhánh bạn đang gộp vào |
Sau >>>>>>> | branch-b | Tên nhánh, để bạn biết phần dưới đến từ đâu |
Bốn cách giải:
# Cách 1 — giữ phiên bản của nhánh hiện tại
git restore --ours f.txt && git add f.txt
# Cách 2 — giữ phiên bản của nhánh được gộp
git restore --theirs f.txt && git add f.txt
# Cách 3 — sửa tay, giữ cả hai hoặc viết lại hoàn toàn
# mở f.txt, xoá ba dòng dấu, để lại nội dung đúng
git add f.txt
# Cách 4 — huỷ toàn bộ
git merge --abortSau cách 1, 2 hoặc 3, hoàn tất bằng git commit.
Cách 3 là cách đúng trong phần lớn trường hợp. Cách 1 và 2 tiện nhưng nguy hiểm: chúng vứt bỏ toàn bộ phần của một bên, kể cả những thay đổi không hề xung đột nằm trong cùng file. Chỉ dùng khi bạn chắc chắn một bên là hoàn toàn sai.
Một chi tiết hữu ích. Bật chế độ hiển thị ba phần thay vì hai:
git config --global merge.conflictstyle zdiff3Khi đó dấu xung đột thêm một phần ở giữa, bắt đầu bằng ||||||| kèm mã của commit gốc, cho thấy nội dung gốc trước khi hai bên cùng sửa (output thật, mã băm trên máy bạn sẽ khác):
<<<<<<< HEAD
TỪ MAIN
||||||| ea38f24
gốc
=======
TỪ NHÁNH B
>>>>>>> branch-bBiết được điểm xuất phát giúp bạn hiểu mỗi bên đã thay đổi gì, thay vì chỉ thấy hai kết quả cuối và phải đoán. Đây là một dòng cấu hình đáng bật ngay.
Bài 2 — Chứng minh --ours đảo nghĩa
Tạo xung đột và giải bằng --ours trong merge. Rồi tạo xung đột tương đương và giải bằng --ours trong rebase. So sánh kết quả và giải thích.
Tiêu chí hoàn thành: bạn nêu đúng nghĩa của --ours trong cả hai ngữ cảnh, và giải thích được vì sao chúng ngược nhau.
Gợi ý và lời giải — Bài 2
Gợi ý. --ours luôn có nghĩa là "phiên bản đang nằm trên nhánh mà Git đã checkout". Câu hỏi thật là: trong lúc rebase, Git đang đứng trên nhánh nào?
Lời giải — kết quả thật của cùng một kịch bản.
Trong merge (đang ở main, gộp branch-b vào):
git switch main
git merge branch-b # xung đột
git restore --ours f.txt
cat f.txt
# TỪ MAIN--ours = main = nhánh bạn đang đứng. Đúng như trực giác.
Trong rebase (đang ở branch-b, rebase lên main):
git merge --abort # huỷ lần merge ở trên trước
git switch branch-b
git rebase main # xung đột
git restore --ours f.txt
cat f.txt
# TỪ MAIN <- KHÔNG phải "TỪ NHÁNH B"
git rebase --abort--ours = main = nhánh đích, không phải nhánh của bạn. Ngược với trực giác.
Vì sao ngược. Rebase hoạt động theo ba bước:
1. Git chuyển sang nhánh ĐÍCH (main)
2. Phát lại từng commit của nhánh BẠN lên trên đó, như cherry-pick
3. Khi phát lại gặp xung đột, "ours" là thứ đang có = main
còn "theirs" là commit đang được phát lại = commit của bạnNói cách khác: trong rebase, bạn mới là "theirs". Đây là một trong những chỗ gây nhầm lẫn nhiều nhất của Git, và nó dẫn tới hậu quả rất cụ thể — người ta gõ --ours với ý "giữ code của tôi" rồi vô tình vứt bỏ toàn bộ công việc của mình.
Bảng tra nhanh:
| Thao tác | --ours là | --theirs là |
|---|---|---|
git merge branch-b (đang ở main) | main | branch-b |
git rebase main (đang ở branch-b) | main | branch-b |
git cherry-pick X | Nhánh hiện tại | Commit X |
Cách không bao giờ nhầm. Đừng dựa vào --ours và --theirs. Đọc thẳng dấu xung đột trong file — nó nói rõ phần dưới đến từ đâu. Và dấu xung đột cũng khác nhau giữa hai trường hợp:
# Khi merge — phần dưới ghi TÊN NHÁNH
>>>>>>> branch-b
# Khi rebase — phần dưới ghi MÃ BĂM và MESSAGE của commit đang phát lại
>>>>>>> 27f6cf0 (edit from B)Nhìn dòng đó là biết mình đang ở tình huống nào.
Lệnh hữu ích khi rối. git status trong lúc xung đột luôn nói rõ bạn đang ở giữa thao tác gì và gợi ý lệnh tiếp theo. Đọc nó trước khi gõ bất cứ thứ gì.
Bài 3 — Dựng một xung đột ngữ nghĩa
Nhánh A đổi tên một phương thức, nhánh B thêm một chỗ gọi tên cũ ở file khác. Merge và xác nhận Git không báo gì. Rồi chạy dotnet build và xem lỗi.
Tiêu chí hoàn thành: bạn nêu được vì sao Git về nguyên tắc không thể phát hiện loại xung đột này.
Gợi ý và lời giải — Bài 3
Gợi ý. Git so sánh văn bản theo từng dòng. Nó không biết C# là gì, không biết phương thức là gì, không biết dòng nào gọi dòng nào. Vậy khi hai nhánh sửa hai file khác nhau, Git thấy gì?
Lời giải — kịch bản (output thật trên .NET 10):
mkdir sem && cd sem && git init -q -b main
dotnet new console -n Crm -o .
dotnet new gitignore// Program.cs
var service = new CustomerService();
Console.WriteLine(service.CalculateRevenue(1));// CustomerService.cs
public class CustomerService
{
public decimal CalculateRevenue(int customerId) => 1_500_000m;
}git add . && git commit -q -m "feat: add customer service"Nhánh A — đổi tên cho rõ nghĩa hơn, sửa luôn chỗ gọi trong Program.cs:
git switch -q -c branch-a
# đổi CalculateRevenue thành CalculateAnnualRevenue ở cả hai file
git commit -q -am "refactor: rename CalculateRevenue to CalculateAnnualRevenue"Nhánh B — tách từ main cũ, thêm một chỗ gọi ở file hoàn toàn mới:
git switch -q main && git switch -q -c branch-b// ReportService.cs
public class ReportService
{
private readonly CustomerService _customerService = new();
public decimal ExportReport(int id) => _customerService.CalculateRevenue(id);
}git add . && git commit -q -m "feat: add report service"Merge cả hai vào main:
git switch -q main
git merge branch-a
git merge --no-edit branch-bUpdating e480487..dad6b3b
Fast-forward
CustomerService.cs | 2 +-
Program.cs | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
Merge made by the 'ort' strategy.
ReportService.cs | 6 ++++++
1 file changed, 6 insertions(+)
create mode 100644 ReportService.csGit báo thành công, vì hai nhánh sửa hai nhóm file khác nhau và không có dòng nào chồng lấn.
Nhưng:
dotnet build...\ReportService.cs(5,61): error CS1061: 'CustomerService' does not contain a definition for 'CalculateRevenue' and no accessible extension method 'CalculateRevenue' accepting a first argument of type 'CustomerService' could be found (are you missing a using directive or an assembly reference?)
Build FAILED.Vì sao Git không thể phát hiện. Git là hệ thống quản lý phiên bản cho văn bản, không phải cho ngôn ngữ lập trình. Nó không phân tích cú pháp, không biết khái niệm phương thức hay lời gọi. Để phát hiện được xung đột này, Git sẽ phải hiểu C# — và tương tự với mọi ngôn ngữ khác mà nó quản lý. Đó không phải giới hạn về kỹ thuật mà là giới hạn về thiết kế có chủ ý: Git giữ mình ở tầng văn bản để dùng được cho mọi loại nội dung.
Bốn dạng xung đột ngữ nghĩa hay gặp:
| Nhánh A | Nhánh B | Git thấy gì | Hậu quả |
|---|---|---|---|
| Đổi tên phương thức | Thêm chỗ gọi tên cũ | Không xung đột | Lỗi biên dịch |
| Đổi kiểu tham số | Thêm lời gọi với kiểu cũ | Không xung đột | Lỗi biên dịch |
| Đổi ý nghĩa một trường | Thêm code dựa trên ý nghĩa cũ | Không xung đột | Lỗi lúc chạy — tệ nhất |
| Xoá một cột database | Thêm truy vấn dùng cột đó | Không xung đột | Lỗi lúc chạy |
Hai dạng cuối nguy hiểm hơn hẳn, vì trình biên dịch cũng không bắt được. Ví dụ nhánh A đổi Discount từ đơn vị phần trăm sang đơn vị thập phân, nhánh B thêm một phép tính dùng nó như phần trăm — code biên dịch sạch và tính sai gấp một trăm lần.
Ba lớp phòng vệ, theo thứ tự hiệu quả:
- CI chạy build và test trên kết quả merge. Đây là lớp quan trọng nhất và gần như bắt được toàn bộ hai dạng đầu. Lưu ý: CI phải chạy trên kết quả merge, không phải trên nhánh của bạn — với sự kiện
pull_request, GitHub Actions mặc định checkout commit merge thử giữa nhánh của bạn và nhánh đích. - Nhánh sống ngắn. Nhánh sống một ngày thì hai người khó mà đổi cùng một khái niệm theo hai hướng. Nhánh sống ba tuần thì gần như chắc chắn.
- Trao đổi trước khi đổi thứ dùng chung. Đổi tên một API nội bộ, đổi ý nghĩa một trường, xoá một cột — báo trước một câu trong kênh chung rẻ hơn nhiều so với gỡ rối sau khi merge.
Điểm đáng nhớ. "Merge sạch" chỉ có nghĩa là không có xung đột văn bản. Nó không có nghĩa là code còn chạy. Bài này là lý do vì sao merge xong vẫn phải chạy build và test, chứ không chỉ nhìn Git báo thành công.
Tự kiểm tra
Vì sao Git không tự chọn bên khi có xung đột?
Vì chọn bên nào là quyết định nghiệp vụ chứ không phải quyết định kỹ thuật. Git hợp nhánh theo từng khối dòng và chỉ dừng lại khi hai bên đụng vào cùng một vùng của cùng một file. Nếu nó đoán bừa thì sẽ âm thầm xoá mất code của một người.
Ba dấu xung đột có nghĩa gì?
Dấu nhỏ hơn mở đầu phiên bản của nhánh hiện tại, dấu bằng là ranh giới, dấu lớn hơn kết thúc phiên bản của nhánh đang được gộp vào. Bạn có bốn lựa chọn: giữ bên trên, giữ bên dưới, giữ cả hai, hoặc viết một phiên bản thứ ba kết hợp — và lựa chọn cuối mới là thường gặp nhất.
--ours và --theirs có gì cần cẩn thận?
Nghĩa của chúng đảo ngược giữa merge và rebase. Trong merge, ours là nhánh bạn đang đứng. Trong rebase, ours lại là nhánh đích đang rebase lên, còn theirs mới là nhánh của bạn. Lý do là khi rebase, Git đặt commit của bạn lên trên nhánh đích nên phía hiện tại chính là nhánh đích.
Xung đột ngữ nghĩa là gì?
Là khi hai nhánh sửa hai file khác nhau nên Git merge sạch không cảnh báo, nhưng ý nghĩa thì xung đột — ví dụ một nhánh đổi tên phương thức còn nhánh kia thêm chỗ gọi tên cũ. Git làm việc trên dòng chữ nên không phát hiện được. Nguy hiểm nhất là trường hợp đổi ý nghĩa một trường: code vẫn chạy nhưng cho kết quả sai.
Làm sao phát hiện xung đột ngữ nghĩa?
Chỉ có một cách đáng tin: CI chạy build và test trên kết quả merge sau mỗi lần gộp. Con người không đọc hết được mọi tương tác giữa các file. Đó là lý do tuỳ chọn require status checks to pass trong branch protection không phải thủ tục hình thức.
Làm sao giảm tần suất xung đột?
Bốn thói quen theo thứ tự hiệu quả: kéo nhánh chính vào nhánh làm việc mỗi ngày, giữ nhánh tính năng sống ngắn dưới một tuần, chia việc theo ranh giới file thay vì để nhiều người sửa cùng một file lớn, và dùng .editorconfig cùng format tự động để loại bỏ nhóm xung đột do khác thiết lập thụt lề.
Kết luận
Ba điều đáng nhớ nhất:
- Xung đột là Git đang tôn trọng bạn, không phải đang gây khó.
--oursđảo nghĩa giữa merge và rebase. Khi rebase, hãy mở file ra đọc.- Loại xung đột nguy hiểm nhất là loại Git không báo. CI là lưới an toàn duy nhất.
Tham khảo
- git-merge — phần "How Conflicts Are Presented"
- git-rebase —
--continue,--skip,--abort - Pro Git — Advanced Merging — chiến lược và công cụ giải xung đột
- GitHub Actions — chạy test trên kết quả merge
Điều hướng
- Bài trước: 3.6 — GitHub và Pull Request
- Bài tiếp theo: 3.8 — Developer Workflow thực tế
- Về module: Trang mục lục