Git: commit, branch, merge vs rebase và cách cứu commit
Bắt đầu chương DevOps nhé: Git. Team shop-app đang để mỗi người tự merge theo kiểu của mình, history nhìn rối như mì.
Chương này đưa shop-app (monorepo gồm order-service, payment-service, inventory-service, gateway) từ máy dev lên Kubernetes trên cloud. Bước đầu tiên là Git, vì mọi thứ phía sau (CI, CD, GitOps) đều bắt đầu từ một commit.
1. Bài toán
Repo shop-app có 6 dev. Mỗi người làm feature trên branch riêng. Có người merge, có người rebase, có người push --force lên branch chung. Kết quả:
- History đầy commit "merge branch main into feature" chồng chéo.
- Một hôm commit của đồng nghiệp biến mất sau khi ai đó force push.
- Bug trên prod, không biết commit nào gây ra, cũng không biết bỏ nó đi kiểu gì cho an toàn.
2. Vấn đề: không hiểu mô hình của Git
Phần lớn rắc rối đến từ việc nghĩ Git lưu "diff" và branch là "thư mục". Thật ra:
- Commit là snapshot toàn bộ cây file + metadata + con trỏ tới commit cha. Commit bất biến, id là hash SHA.
- Branch chỉ là một con trỏ (file chứa 1 hash) trỏ vào một commit.
- HEAD là con trỏ "mình đang đứng ở đâu", thường trỏ vào một branch.
A ── B ── C ← main
\
D ── E ← feature/checkout ← HEAD
Hiểu vậy thì: rebase = tạo commit mới; reset = di chuyển con trỏ branch; commit "mất" thật ra vẫn nằm trong repo cho tới khi bị garbage collect.
3. Giải pháp: ba cách đưa feature vào main
| Cách | Kết quả | History |
|---|---|---|
merge (no-ff) | Tạo merge commit có 2 cha | Giữ nguyên sự thật, nhưng nhiều nhánh |
rebase rồi fast-forward | Chép D, E thành D', E' trên đỉnh main | Thẳng, đọc dễ; đổi hash |
squash merge | Gom D+E thành 1 commit trên main | Mỗi PR = 1 commit; mất commit nhỏ |
merge: A ─ B ─ C ───── M rebase: A ─ B ─ C ─ D' ─ E'
\ /
D ─ E squash: A ─ B ─ C ─ S (S = D+E)
4. Code từng bước
Bước 1: cập nhật branch feature bằng rebase (branch chỉ của mình)
git switch feature/checkout
git fetch origin
git rebase origin/main # chép D, E lên đỉnh main mới
# có conflict:
git status # xem file conflict
# sửa file, xóa marker <<<<<<< ======= >>>>>>>
git add src/main/java/.../OrderService.java
git rebase --continue # hoặc git rebase --abort để quay về như cũ
git push --force-with-lease # an toàn hơn --force
--force-with-lease từ chối push nếu remote đã có commit mới mà mình chưa thấy, tránh đè mất commit của người khác.
Bước 2: dọn commit trước khi mở PR
git commit --fixup=<hash> # sửa nhỏ cho một commit cũ
git rebase -i --autosquash origin/main
Bước 3: cứu commit "mất" bằng reflog
git reset --hard HEAD~3 # lỡ tay xóa 3 commit
git reflog # nhật ký HEAD đã đi qua đâu
# a1b2c3d HEAD@{0}: reset: moving to HEAD~3
# 9f8e7d6 HEAD@{1}: commit: add PaymentRetry
git branch rescue 9f8e7d6 # hoặc git reset --hard HEAD@{1}
Reflog là local, mặc định giữ khoảng 90 ngày. Commit chưa từng commit (chỉ sửa file) thì reflog không cứu được.
Bước 4: bỏ một commit lỗi đã lên main
git revert 4c5d6e7 # tạo commit mới đảo ngược 4c5d6e7, an toàn trên branch chung
git cherry-pick 4c5d6e7 # chép 1 commit sang branch khác, vd hotfix sang release/1.4
git bisect start; git bisect bad; git bisect good v1.3.0 # tìm commit gây bug
5. Cách hoạt động: revert vs reset
reset --hard B (di chuyển con trỏ, viết lại lịch sử):
trước: A ─ B ─ C ─ D ← main
sau: A ─ B ← main (C, D chỉ còn trong reflog)
revert D (thêm commit, không viết lại):
A ─ B ─ C ─ D ─ D⁻¹ ← main (ai đã pull vẫn đồng bộ bình thường)
| Lệnh | Đổi history? | Dùng trên branch chung? |
|---|---|---|
reset --soft | Có, giữ thay đổi ở staging | Không |
reset --mixed (mặc định) | Có, giữ thay đổi ở working dir | Không |
reset --hard | Có, xóa luôn thay đổi chưa commit | Không |
revert | Không, thêm commit | Có |
6. So sánh branching strategy
| Trunk-based | GitFlow | |
|---|---|---|
| Branch | main + branch ngắn (< 1–2 ngày) | main, develop, feature, release, hotfix |
| Code dở dang | Ẩn sau feature flag | Nằm trên feature branch lâu |
| Release | Liên tục, từ main | Theo đợt, qua release branch |
| Hợp với | SaaS, CI/CD, microservices | Phần mềm có version, nhiều bản song song (mobile, on-prem) |
| Rủi ro | Cần test tự động tốt | Merge hell, feedback chậm |
shop-app deploy nhiều lần mỗi ngày lên Kubernetes nên chọn trunk-based + squash merge qua PR, branch protection bắt buộc CI xanh (xem quality gate).
7. Khi nào không được rebase
- Không rebase branch người khác đã pull (main, develop, branch làm chung). Rebase đổi hash, người khác sẽ có hai bản commit giống nhau.
- Không rebase khi cần giữ bằng chứng thời điểm merge (audit). Dùng merge commit.
- Rebase thoải mái trên branch cá nhân chưa ai dùng. Quy tắc vàng: rebase cái của mình, merge cái của chung.
Squash merge thì mất hết commit nhỏ, vậy sau này git bisect có còn tìm được lỗi không?
Bạn lo đúng chỗ, nhưng thực tế không tệ như vậy:
- Bisect trên main sẽ chỉ ra PR gây lỗi (1 squash commit = 1 PR). Với PR nhỏ (vài trăm dòng) như trunk-based khuyến khích, vậy là đủ khoanh vùng.
- Commit nhỏ vẫn còn trên branch PR trên GitHub/GitLab (nếu chưa xóa), cần thì bisect tiếp trong đó.
- Squash còn giúp mọi commit trên main đều build xanh, bisect không bị kẹt ở commit "WIP" build lỗi.
Nếu PR lớn và các commit trong đó có ý nghĩa riêng, dùng rebase merge thay vì squash. Điều quan trọng là team thống nhất một kiểu, cấu hình luôn trong repo settings.
Hotfix trên prod thì sao? Trunk-based chỉ có main, mà main đã có thêm mấy feature chưa muốn release.
Bạn hiểu gần đúng, chỉ cần chỉnh một điểm: trong trunk-based, feature chưa muốn release vẫn nằm trên main nhưng tắt bằng feature flag, nên thường cứ fix trên main rồi deploy là xong.
Nếu thật sự không thể deploy main hiện tại (vd có migration chưa sẵn sàng):
git switch -c release/1.4.1 v1.4.0 # branch từ tag đang chạy prod
git cherry-pick 7a8b9c0 # commit fix đã merge vào main
git tag v1.4.1 && git push origin release/1.4.1 v1.4.1
Fix luôn vào main trước rồi cherry-pick xuống, không làm ngược lại. Làm ngược rất dễ quên đưa fix về main, bản sau bug quay lại.
Phỏng vấn hỏi về Git workflow thì trả lời sao?
Q1. Merge và rebase khác nhau thế nào? Khi nào không nên rebase?
🇻🇳 Tiếng Việt:
Merge tạo merge commit có hai cha, giữ nguyên lịch sử thật. Rebase chép các commit của mình lên đỉnh branch đích, tạo commit mới với hash mới, lịch sử thẳng hơn. Không rebase branch đã chia sẻ cho người khác vì nó viết lại lịch sử; chỉ rebase branch cá nhân và push bằng --force-with-lease.
🇬🇧 English:
Merge creates a merge commit with two parents and preserves the real history. Rebase replays my commits on top of the target branch, creating new commits with new hashes and a linear history. I never rebase a branch that others have pulled, because it rewrites shared history; I only rebase my own branches and push with --force-with-lease.
Q2. Revert và reset khác nhau thế nào?
🇻🇳 Tiếng Việt:
Reset di chuyển con trỏ branch về commit cũ, viết lại lịch sử, chỉ dùng local. Revert tạo commit mới đảo ngược thay đổi, không đổi lịch sử, nên là cách đúng để bỏ một commit đã lên main. Lỡ reset mất commit thì dùng git reflog để tìm hash và khôi phục.
🇬🇧 English:
Reset moves the branch pointer back and rewrites history, so it is for local work only. Revert adds a new commit that undoes the change without rewriting history, which is the right way to undo something already on main. If I lose commits with a reset, git reflog shows where HEAD has been and I can restore them.
Q3. Team bạn dùng branching strategy nào, vì sao?
🇻🇳 Tiếng Việt:
Trunk-based: branch ngắn, PR nhỏ, squash merge, CI bắt buộc xanh, code dở dang ẩn sau feature flag. Hợp với microservices deploy liên tục. GitFlow hợp hơn với phần mềm phát hành theo version và phải duy trì nhiều bản song song.
🇬🇧 English:
Trunk-based development: short-lived branches, small PRs, squash merge, mandatory green CI, and unfinished work hidden behind feature flags. It fits microservices with continuous delivery. GitFlow fits better for versioned products that must maintain several releases in parallel.
Cách nhớ ngắn gọn
- Commit = snapshot bất biến; branch, HEAD = con trỏ.
- Rebase cái của mình, merge cái của chung; push bằng --force-with-lease.
- Trên main: revert, không reset. Mất commit: reflog.
- Hotfix: fix main trước, cherry-pick xuống release.
- Deploy liên tục → trunk-based + feature flag.
Tự kiểm tra
Branch trong Git thực chất là gì?
Một con trỏ (tham chiếu) tới một commit. Tạo branch chỉ là tạo con trỏ mới, rất rẻ.
Vì sao rebase branch chung gây rắc rối?
Rebase tạo commit mới với hash mới. Người đã pull bản cũ sẽ có hai bộ commit trùng nội dung, merge lại sinh conflict và lịch sử rối.
--force-with-lease khác --force chỗ nào?
Chỉ ghi đè khi remote vẫn ở đúng trạng thái mình đã fetch; nếu ai đó đã push thêm, lệnh bị từ chối.
Lỡ reset --hard mất 2 commit, cứu thế nào?
git reflog tìm hash trước khi reset, rồi git reset --hard <hash> hoặc tạo branch mới từ hash đó.
Trunk-based xử lý feature chưa xong thế nào?
Merge vào main sớm nhưng tắt bằng feature flag, bật khi sẵn sàng.