Chiến lược merge
Xem thửTình huống: ba commit wip và một nút merge có ba lựa chọn
Pull request của Minh cho endpoint /readyz đã được duyệt. Nhánh có ba commit: hai commit tên wip và một commit hoàn chỉnh. Lan mở nút merge trên GitHub và thấy ba lựa chọn: Create a merge commit, Squash and merge, Rebase and merge. Cô chọn đại một cái.
Hai tháng sau, gdev-api có lỗi khó hiểu ở readiness. Người trực phải làm hai việc: tìm commit gây lỗi và gỡ nguyên tính năng ra. Kết quả hai việc đó phụ thuộc hoàn toàn vào cái nút Lan đã bấm. Với merge commit, gỡ cả PR là một lệnh. Với squash, tìm lỗi bằng git bisect chỉ chạm tới một commit lớn. Với rebase, mỗi commit wip đều thành điểm dừng khi bisect, kể cả khi nó chưa chạy được.
Bài này chạy cả bốn cách trên cùng một nhánh để bạn thấy hình dạng lịch sử bằng git log --graph --oneline. Sau đó ta cố tình tạo xung đột, đọc từng dòng ký hiệu, và học cách đẩy nhánh đã rebase mà không ghi đè việc của người khác. Bài trước nói khi nào nên tạo nhánh, bài này nói nhánh xong thì đưa vào main ra sao.
Sau bài này bạn làm được các việc sau:
- Chạy và đọc kết quả của merge tua nhanh, merge commit, squash và rebase.
- Đọc một khối xung đột, chọn đúng cách xử lý và hoàn tất merge hoặc rebase.
- Biết rebase an toàn khi nào, và dùng
--force-with-leasethay vì--force. - Gỡ một tính năng đã merge bằng
git revert -m 1.
Bạn cần có sẵn: Git 2.28 trở lên (lab chạy trên Git 2.54.0), bài Lệnh Git thiết yếu (revert, reflog) và bài Chiến lược branching.
Ba mô hình cần nhớ
- Bốn cách merge khác nhau ở thứ chúng giữ lại: tua nhanh giữ lịch sử thẳng, merge commit giữ hình dạng nhánh, squash giữ nội dung nhưng bỏ từng commit, rebase giữ từng commit nhưng viết lại chúng.
- Rebase không di chuyển commit mà tạo commit mới: cùng thay đổi, cha khác nên hash khác. Vì thế chỉ rebase khi chưa ai dựa vào các commit cũ.
- Xung đột là câu hỏi về ý định: Git chỉ biết hai bên sửa cùng một chỗ, không biết bên nào đúng. Con người phải trả lời, nên cần hỏi người viết ra dòng đó.
Chuẩn bị môi trường lab
Script dưới đây dựng một repo nhỏ: main có hai commit, nhánh feature/readyz rẽ ra sau commit đầu và có ba commit, sau đó main đi tiếp thêm một commit đổi log_level. Hai nhánh đã phân kỳ, nghĩa là mỗi bên có commit mà bên kia không có. Lưu thành setup-merge-lab.sh:
#!/bin/sh
set -e
dir="$1"
rm -rf "$dir"
mkdir "$dir"
cd "$dir"
git init -b main
mkdir config src
printf 'service: gdev-api\nreplicas: 2\nlog_level: info\n' > config/app.yaml
printf 'def health():\n return "ok"\n' > src/health.py
git add .
git commit -m "chore: initial gdev-api"
git switch -c feature/readyz
printf '\ndef readyz():\n return "ready"\n' >> src/health.py
git commit -am "wip: readyz skeleton"
printf '\ndef db_ok():\n return True\n' >> src/health.py
git commit -am "wip: check db"
printf '\ndef readyz():\n return "ready" if db_ok() else "no"\n' >> src/health.py
git commit -am "feat(api): readyz checks db"
git switch main
sed -i 's/log_level: info/log_level: warn/' config/app.yaml
git commit -am "chore(config): log level warn"
Bạn cần git config --global user.name và user.email đã đặt. Mỗi thí nghiệm bắt đầu bằng sh setup-merge-lab.sh try-xxx rồi cd try-xxx để bốn cách chạy trên cùng một điểm xuất phát. Hash trong kết quả là minh họa và sẽ khác ở máy bạn.
Bốn cách hòa nhánh, bốn hình dạng lịch sử
Trước khi chạy, hãy dự đoán: ba commit của Minh vào main bằng bốn cách khác nhau. Cách nào làm main vẫn là một đường thẳng, và cách nào làm hash của ba commit đó đổi?
Tua nhanh (fast-forward)
Tua nhanh chỉ khả thi khi main chưa đi đâu cả: nhánh feature nằm hoàn toàn phía trước. Ở repo vừa dựng, main đã có commit riêng nên thử với --ff-only sẽ thất bại:
sh setup-merge-lab.sh try-ff && cd try-ff
git merge --ff-only feature/readyz
hint: Diverging branches can't be fast-forwarded, you need to either:
hint:
hint: git merge --no-ff
hint:
hint: or:
hint:
hint: git rebase
fatal: Not possible to fast-forward, aborting.
Git tự đề xuất hai lối ra: merge có commit, hoặc rebase. Để thấy tua nhanh thật sự, lùi main về trước commit riêng của nó (chỉ để thí nghiệm, vì lab này chưa push):
git reset --hard HEAD~1
git merge --ff-only feature/readyz
git log --oneline --graph
Updating 363708d..35a8010
Fast-forward
src/health.py | 9 +++++++++
1 file changed, 9 insertions(+)
* 35a8010 feat(api): readyz checks db
* 89b61b5 wip: check db
* 8ad8a41 wip: readyz skeleton
* 363708d chore: initial gdev-api
Không có commit mới, không có hash mới: main chỉ dời con trỏ tới trước. Đường thẳng, nhưng chỉ có được khi không ai đẩy gì vào main trong lúc bạn làm.
Merge commit
sh setup-merge-lab.sh try-merge && cd try-merge
git merge --no-ff --no-edit feature/readyz
git log --oneline --graph
git log --oneline --first-parent
Kết quả minh họa:
Merge made by the 'ort' strategy.
src/health.py | 9 +++++++++
1 file changed, 9 insertions(+)
* 28f1da7 Merge branch 'feature/readyz'
|\
| * 35a8010 feat(api): readyz checks db
| * 89b61b5 wip: check db
| * 8ad8a41 wip: readyz skeleton
* | 024429a chore(config): log level warn
|/
* 363708d chore: initial gdev-api
28f1da7 Merge branch 'feature/readyz'
024429a chore(config): log level warn
363708d chore: initial gdev-api
Đọc kết quả:
- Commit
28f1da7có hai cha:024429atrênmainvà35a8010trên nhánh. Đó là điều làm nó khác commit thường. - Đồ thị giữ nguyên hình chữ Y rồi khép lại: ba commit của Minh vẫn còn nguyên hash và tên.
- Lệnh
git log --first-parentchỉ đi theo cha thứ nhất, tức dòngmain. Ba commitwipbiến mất khỏi tầm nhìn,mainđọc như danh sách các lần tích hợp.
Điều này phản bác một hiểu lầm hay gặp: "merge commit là rác trong lịch sử". Merge commit là ranh giới của một PR. Với --first-parent, người đọc lịch sử thấy từng lần tích hợp, và khi cần gỡ cả PR thì chỉ cần một lệnh:
git revert -m 1 --no-edit HEAD
git log --oneline --graph
[main 843a5b3] Revert "Merge branch 'feature/readyz'"
1 file changed, 9 deletions(-)
* 843a5b3 Revert "Merge branch 'feature/readyz'"
* 28f1da7 Merge branch 'feature/readyz'
Cờ -m 1 nói rằng "mainline" là cha thứ nhất, tức dòng main, nên Git đảo ngược những gì nhánh đã thêm vào. Tài liệu chính thức của git revert cảnh báo: revert một merge tuyên bố bạn không bao giờ muốn các thay đổi mà merge đó mang vào. Về sau, nếu merge lại cùng nhánh, Git chỉ lấy thêm những commit chưa có trong lịch sử merge đã bị revert. Muốn đưa nhánh trở lại, bạn phải revert chính commit revert trước.
Squash merge
sh setup-merge-lab.sh try-squash && cd try-squash
git merge --squash feature/readyz
git status -sb
git commit -m "feat(api): add /readyz probe"
git log --oneline --graph
git branch -d feature/readyz
Squash commit -- not updating HEAD
Automatic merge went well; stopped before committing as requested
## main
M src/health.py
* 3c8804a feat(api): add /readyz probe
* 024429a chore(config): log level warn
* 363708d chore: initial gdev-api
error: the branch 'feature/readyz' is not fully merged
hint: If you are sure you want to delete it, run 'git branch -D feature/readyz'
Squash áp nội dung của cả nhánh vào index và dừng, rồi bạn tự commit một lần. Lịch sử là một đường thẳng và ba commit wip biến mất. Dòng cuối cho thấy cái giá: commit mới 3c8804a không có quan hệ cha con nào với ba commit của nhánh, nên Git không thể chứng minh nhánh đã được merge. Lệnh git branch -d từ chối, và bạn phải dùng -D sau khi tự chắc là nhánh đã xong.
Squash hợp khi nhánh có nhiều commit sửa-lại-sửa-lại (wip, fix typo, address review). Nó kém khi PR lớn: git bisect chỉ thu hẹp được về một commit khổng lồ. Theo tài liệu GitHub, squash biến mọi commit của PR thành một commit trên nhánh đích, và các nhánh sống lâu có thể gây xung đột lặp lại vì PR sau có thể mang lại các commit đã được squash.
Rebase rồi tua nhanh
sh setup-merge-lab.sh try-rebase && cd try-rebase
git switch feature/readyz
git rebase main
git log --oneline --graph --all
git switch main
git merge --ff-only feature/readyz
Kết quả minh họa:
Rebasing (1/3)Rebasing (2/3)Rebasing (3/3)Successfully rebased and updated refs/heads/feature/readyz.
* e83453e feat(api): readyz checks db
* 40631e8 wip: check db
* 78d78a6 wip: readyz skeleton
* 024429a chore(config): log level warn
* 363708d chore: initial gdev-api
Updating 024429a..e83453e
Fast-forward
Đối chiếu với trước khi rebase: ba commit từng là 8ad8a41, 89b61b5, 35a8010, giờ là 78d78a6, 40631e8, e83453e. Hash đã đổi, vì mỗi commit giờ có cha khác. Cùng thay đổi, nhưng là commit mới. Các commit cũ vẫn nằm trong kho cho tới khi bị dọn, chỉ là không nhánh nào trỏ tới.
Đây là gốc rễ của quy tắc vàng: chỉ rebase những commit chưa ai khác dựa vào. Nếu Lan đã kéo 35a8010 về và viết tiếp lên đó, sau khi Minh rebase thì Lan đang đứng trên một commit mà nhánh của Minh không còn. Vậy hiểu lầm "rebase nguy hiểm" đúng ở đâu và sai ở đâu? Rebase nhánh cá nhân chưa push, hoặc nhánh feature chỉ mình bạn dùng, hoàn toàn an toàn và cho lịch sử sạch. Rebase hay commit --amend trên nhánh chung mới là chuyện nguy hiểm.
GitHub có nút Rebase and merge nhưng khác git rebase cục bộ một điểm: theo tài liệu, GitHub tạo commit mới với SHA mới và cập nhật thông tin committer, và nếu không rebase tự động được thì bạn phải xử lý xung đột ở máy mình.
Chọn cách nào cho từng PR
Trước khi xem sơ đồ, tự trả lời: PR có 12 commit "fix typo" và "address review" nên chọn cách nào, và PR có 3 commit mỗi commit là một bước hoàn chỉnh thì sao?
Đang tải sơ đồ…
Cách đọc sơ đồ:
- Câu hỏi đầu tiên là chất lượng commit. Nếu lịch sử nhánh toàn "wip", không ai muốn đọc nó, nên squash gom lại thành một commit có thông điệp tốt.
- Nếu commit đáng đọc, hãy hỏi có cần ranh giới PR không. Cần, để
--first-parentvàgit revert -m 1hoạt động, thì chọn merge commit. - Chỉ khi không cần ranh giới và nhánh thuộc riêng bạn, rebase rồi tua nhanh cho lịch sử thẳng tuyệt đối.
- Với hai PR trong câu hỏi mở đầu: PR 12 commit nên squash, PR 3 commit hoàn chỉnh hợp merge commit hoặc rebase.
Điều quan trọng không phải chọn cách nào, mà là cả đội chọn cùng một quy ước và bật nó trong cài đặt repo để nút merge không tùy hứng.
Hãy xem lịch sử của main thay đổi ra sao khi cùng ba commit của Minh vào bằng merge commit hoặc rebase. Trước khi bấm, dự đoán: nếu Lan đã kéo nhánh của Minh về máy trước khi cô rebase, điều gì xảy ra với Lan?
Đang tải Animation…
Xung đột: tái hiện và đọc từng dòng
Xung đột xảy ra khi hai bên sửa cùng một vùng. Hãy tạo ra một cái có chủ đích: trên main thay replicas thành 3 (sau load test), trên nhánh thay thành 4 (cho đợt sale), rồi merge.
mkdir try-conflict && cd try-conflict
git init -b main
printf 'service: gdev-api\nreplicas: 2\nlog_level: info\n' > app.yaml
git add .
git commit -m "chore: initial config"
git switch -c feature/scale-up
sed -i 's/replicas: 2/replicas: 4/' app.yaml
git commit -am "feat(config): replicas 4 for sale event"
git switch main
sed -i 's/replicas: 2/replicas: 3/' app.yaml
git commit -am "fix(config): replicas 3 after load test"
git merge feature/scale-up
git status -sb
cat app.yaml
Kết quả minh họa:
Auto-merging app.yaml
CONFLICT (content): Merge conflict in app.yaml
Automatic merge failed; fix conflicts and then commit the result.
## main
UU app.yaml
service: gdev-api
<<<<<<< HEAD
replicas: 3
=======
replicas: 4
>>>>>>> feature/scale-up
log_level: info
Đọc từng dòng:
UUlà hai bên cùng sửa (both modified). Merge đang ở trạng thái dở dang, và Git từ chối làm việc khác cho tới khi bạn xong hoặc hủy.servicevàlog_levelnằm ngoài khối đánh dấu nên đã được hòa tự động.<<<<<<< HEADmở khối. Phần từ đây tới=======là bản của nhánh bạn đang đứng, ở đây làmainvớireplicas: 3.- Phần từ
=======tới>>>>>>> feature/scale-uplà bản của nhánh được merge vào,replicas: 4. - Ba dòng đánh dấu không phải code. Nếu commit lên với chúng còn nằm trong file, cấu hình của bạn hỏng.
Git không thể biết 3 hay 4 mới đúng, hoặc có phải là 5 để thỏa cả hai. Câu trả lời đến từ việc hỏi Minh và Lan. Giả sử họ thống nhất giữ 4 cho đợt sale. Hãy sửa file thành bản cuối, đánh dấu xong, rồi hoàn tất:
printf 'service: gdev-api\nreplicas: 4\nlog_level: info\n' > app.yaml
git add app.yaml
git commit --no-edit
git log --oneline --graph
[main 5acb83e] Merge branch 'feature/scale-up'
* 5acb83e Merge branch 'feature/scale-up'
|\
| * caa3793 feat(config): replicas 4 for sale event
* | b18141a fix(config): replicas 3 after load test
|/
* 1222406 chore: initial config
git add báo cho Git "file này đã giải quyết xong", và git commit tạo merge commit. Nếu giữa chừng bạn muốn bỏ cuộc, git merge --abort đưa mọi thứ về trước lúc merge.
Rebase cũng gặp xung đột đúng chỗ đó nhưng ở từng commit được phát lại. Làm lại đoạn lệnh trên từ đầu trong thư mục mới, dừng ngay trước git merge, rồi thử git switch feature/scale-up và git rebase main:
CONFLICT (content): Merge conflict in app.yaml
error: could not apply caa3793... feat(config): replicas 4 for sale event
hint: Resolve all conflicts manually, mark them as resolved with
hint: "git add/rm <conflicted_files>", then run "git rebase --continue".
service: gdev-api
<<<<<<< HEAD
replicas: 3
=======
replicas: 4
>>>>>>> caa3793 (feat(config): replicas 4 for sale event)
log_level: info
Có một điểm dễ nhầm: trong rebase, HEAD là main (nơi commit đang được phát lại lên), còn phần dưới là commit của bạn đang được áp. Vai trò "của tôi" và "của họ" bị đảo ngược so với merge. Sau khi sửa file, chạy git add app.yaml rồi git rebase --continue. Muốn hủy, dùng git rebase --abort. Mỗi lần dừng là một commit bị xung đột, nên nhánh 12 commit có thể dừng tới 12 lần, một lý do nữa để squash trước khi rebase.
Đẩy nhánh đã rebase: force-with-lease
Sau khi rebase, hash của nhánh đã đổi nên git push thường bị từ chối. Lab dưới dựng tình huống thật: Minh và Lan cùng làm trên feature/readyz. Minh rebase lên main mới, nhưng Lan vừa push thêm một commit mà Minh chưa fetch.
git rebase main
git push --force-with-lease origin feature/readyz
git fetch
git log --oneline --graph --all
Kết quả minh họa:
Rebasing (1/1)Successfully rebased and updated refs/heads/feature/readyz.
! [rejected] feature/readyz -> feature/readyz (stale info)
error: failed to push some refs to '/home/minh/fl/remote.git'
From /home/minh/fl/remote
5553a78..1a089c7 feature/readyz -> origin/feature/readyz
* 754cdbd feat: readyz v1
* bb7865f fix: replicas 3
| * 1a089c7 test: add readyz test
| * 5553a78 feat: readyz v1
|/
* ffc90fc chore: initial
Đọc kết quả:
--force-with-leasecó nghĩa: "chỉ ghi đè nếu remote vẫn đúng như lần cuối tôi thấy". Remote đã có thêm1a089c7mà máy Minh chưa biết, nên Git trảstale infovà không ghi đè.- Sau
git fetch, đồ thị cho thấy nhánh trên remote (1a089c7, bên phải) có commit test của Lan, còn nhánh cục bộ của Minh thì không. - Hướng sửa đúng là lấy commit của Lan về rồi rebase lại, thay vì thử push lần nữa:
git reset --hard origin/feature/readyz
git rebase main
git push --force-with-lease origin feature/readyz
HEAD is now at 1a089c7 test: add readyz test
Rebasing (1/2)Rebasing (2/2)Successfully rebased and updated refs/heads/feature/readyz.
+ 1a089c7...ca00d67 feature/readyz -> feature/readyz (forced update)
Lệnh reset --hard origin/feature/readyz chỉ an toàn ở đây vì mọi commit cục bộ của Minh đã có trên remote, chỉ khác nền. Nếu bạn có commit chưa push, đừng làm vậy.
Theo tài liệu chính thức của git push, --force-with-lease an toàn hơn --force, nhưng nó có giới hạn. Nó dựa vào nhánh theo dõi origin/... trên máy bạn, và nếu một tiến trình nền chạy git fetch thay bạn thì giá trị mong đợi bị cập nhật và lease vẫn cho qua. Tài liệu còn đề cập --force-if-includes để yêu cầu bạn đã tích hợp commit ở đầu nhánh theo dõi. Nguyên tắc chắc chắn nhất là luôn đọc git log của nhánh remote trước khi ghi đè.
Failure lab: force push thật và đường lùi
Cùng tình huống nhưng Minh dùng --force thay vì --force-with-lease:
git rebase -q main
git push --force origin feature/readyz
+ 2fbcd2d...ae52804 feature/readyz -> feature/readyz (forced update)
Không cảnh báo, không từ chối. Trên máy Lan (người có commit 2fbcd2d vừa bị ghi đè), sau git fetch:
git status -sb
git log --oneline origin/feature/readyz
git reflog -2
## feature/readyz...origin/feature/readyz [ahead 2, behind 2]
ae52804 feat: readyz v1
a41f28c fix: replicas 3
fffeef0 chore: initial
2fbcd2d HEAD@{0}: commit: test: add readyz test
4a8fa3e HEAD@{1}: clone: from /root/fl/remote.git
Triệu chứng: nhánh remote không còn commit test của Lan, và máy cô báo ahead 2, behind 2, nghĩa là lịch sử đã rẽ đôi. Nguyên nhân: --force không kiểm tra gì về trạng thái remote. Cách kiểm chứng là so git log origin/feature/readyz với git log cục bộ. Cách sửa vẫn còn, vì commit của Lan còn trên máy cô và trong reflog: Lan tạo git branch rescue 2fbcd2d, rebase lên nhánh mới, rồi hai người thống nhất ai push.
Kết luận: --force không phải lệnh nguy hiểm vì nó xóa, mà vì nó không hỏi. Chúng ta không cấm hẳn mà dùng đúng chỗ: nhánh cá nhân, và luôn ưu tiên --force-with-lease. Trên main và các nhánh dùng chung, bật branch protection để chặn force push hoàn toàn.
Lỗi thường gặp
| Triệu chứng | Nguyên nhân hay gặp | Cách kiểm chứng và sửa |
|---|---|---|
Not possible to fast-forward | main đã có commit riêng sau khi nhánh rẽ ra | Chọn merge commit hoặc rebase có chủ đích |
git branch -d từ chối sau squash | Squash không giữ quan hệ cha con với commit của nhánh | Xác nhận PR đã merge rồi dùng git branch -D |
File còn dòng <<<<<<< trong commit | Quên dọn dấu xung đột trước git add | git grep -n '<<<<<<<' trước khi commit |
| Hiểu ngược bên "của tôi" khi rebase | Trong rebase, HEAD là nhánh đích, không phải commit của bạn | Đọc nhãn sau >>>>>>> thay vì tin vào từ ours hay theirs |
stale info khi push | Remote có commit mà bạn chưa fetch | git fetch, đọc log, rồi tích hợp trước khi push lại |
| Đồng đội báo lịch sử bị lệch sau rebase | Rebase nhánh mà họ đã kéo về | Chỉ rebase nhánh cá nhân; nhánh chung dùng merge |
Một quy tắc thực tế: nếu có thể, hãy chia sẻ nhánh bằng cách chỉ một người push lên nó, hoặc để GitHub xử lý nút merge theo quy ước đã chọn cho repo. Tranh cãi merge hay rebase thường kết thúc trong 10 phút khi cả đội đã viết một dòng quy ước vào CONTRIBUTING.md.
Tự kiểm tra
Hãy trả lời trước khi đọc đáp án ngay bên dưới mỗi câu.
Câu 1. Sau squash merge, git branch -d feature/x báo chưa merge hết. Vì sao, và việc bạn nên làm là gì?
Đáp án: Squash tạo một commit mới không có quan hệ cha con với các commit của nhánh, nên Git không chứng minh được nhánh đã vào main. Sau khi xác nhận PR đã merge, dùng git branch -D.
Câu 2. Trong lúc rebase, file có khối xung đột với HEAD ở trên và >>>>>>> caa3793 ở dưới. Phần nào là của bạn?
Đáp án: Phần dưới, có nhãn commit của bạn. HEAD trong rebase là nhánh đích đang được phát lại lên, nên vai trò bị đảo so với merge.
Câu 3. Vì sao --force-with-lease vẫn có thể ghi đè công việc của đồng đội trong một số trường hợp?
Đáp án: Lease so với nhánh theo dõi trên máy bạn. Nếu một lệnh git fetch (kể cả chạy nền) đã cập nhật nhánh đó mà bạn chưa đọc commit mới, lease vẫn cho qua.
Nếu chỉ nhớ 3 điều
- Bốn cách merge khác nhau ở thứ giữ lại: tua nhanh giữ đường thẳng, merge commit giữ ranh giới PR (kèm
git revert -m 1), squash gom thành một commit, rebase viết lại commit với hash mới. - Chỉ rebase nhánh chưa ai khác dựa vào; xung đột là câu hỏi cho con người, và trong rebase
HEADlà nhánh đích. - Đẩy nhánh đã rebase bằng
--force-with-leasesau khi đã đọc log của remote; không bao giờ--forcetrên nhánh dùng chung.
Bài cuối chương ghép tất cả vào một quy trình trọn vẹn: nhánh, pull request, merge, sự cố, revert và reflog, trên repo gdev/demo-app.
Tài liệu chính thức: git merge, git rebase, git push, git revert và các cách merge pull request trên GitHub.