3.12 — Mở rộng và đào sâu
Những công cụ Git nối tiếp Module 3, kèm mức độ ưu tiên. Công cụ bị đánh giá thấp nhất và đáng học nhất là git bisect: nó tìm ra commit gây lỗi bằng tìm kiếm nhị phân, nên với lịch sử 1.000 commit nó chỉ cần 10 lần kiểm tra. Phần lớn lập trình viên không biết nó tồn tại và vẫn dò thủ công. Ngược lại, công cụ bị lạm dụng nhiều nhất là git rebase — nó làm lịch sử đẹp hơn thật, nhưng rebase một nhánh đã push và người khác đang dùng là cách nhanh nhất để làm hỏng việc của cả đội.
Mục tiêu bài học
Sau bài này bạn có thể:
- Dùng
git bisectđể tìm commit gây lỗi. - Dùng rebase tương tác để dọn lịch sử trước khi mở PR.
- Biết khi nào không được rebase.
- Chọn chiến lược nhánh phù hợp với đội.
Nội dung bài học
3.12.1 — git bisect: học sớm
Ưu tiên cao. Tiết kiệm hàng giờ trong những tình huống khó nhất.
Tình huống: "tính năng này hoạt động tháng trước, giờ hỏng, và không ai biết vì sao".
git bisect start
git bisect bad # commit hiện tại có lỗi
git bisect good v1.2.0 # phiên bản này chạy tốt
# Git tự checkout commit ở giữa -> bạn kiểm tra -> trả lời
git bisect good # hoac: git bisect bad
# ... lặp lại ...
# Kết quả:
# a3f8b2c1 is the first bad commit
git bisect reset
Với 1.000 commit giữa hai mốc, bạn chỉ cần khoảng 10 lần kiểm tra, vì mỗi lần loại bỏ một nửa.
Tự động hoá hoàn toàn khi có test tái hiện được lỗi:
git bisect start HEAD v1.2.0
git bisect run dotnet test --filter "FullyQualifiedName~TinhHoaHongTests"
Git chạy lệnh trên từng commit và tự quyết định good/bad theo mã thoát. Bạn đi uống cà phê và quay lại có kết quả.
Đây là lý do mạnh nhất để giữ commit nhỏ và mỗi commit đều biên dịch được — nó làm bisect hoạt động tốt (git bisect).
3.12.2 — Rebase tương tác
Ưu tiên cao. Dọn lịch sử trước khi mở PR.
git rebase -i main
pick a3f8b2c add Lead entity
pick 7c1d9e4 fix typo
pick 2f9a8b3 add validation
pick 8e2c4d1 fix typo again
pick 1a7f3e9 forgot a file
# Đổi thành:
pick a3f8b2c add Lead entity
squash 7c1d9e4 fix typo
pick 2f9a8b3 add validation
squash 8e2c4d1 fix typo again
squash 1a7f3e9 forgot a file
Năm commit lộn xộn thành hai commit có ý nghĩa. Người review đọc được, và git bisect sau này cũng chính xác hơn.
Các lệnh dùng nhiều nhất:
| Lệnh | Việc làm |
|---|---|
pick | Giữ nguyên |
squash | Gộp vào commit phía trên, giữ cả hai thông điệp |
fixup | Gộp vào commit phía trên, bỏ thông điệp |
reword | Sửa thông điệp |
drop | Xoá hẳn commit |
fixup thường đúng hơn squash cho những commit kiểu "sửa lỗi chính tả" — bạn không cần giữ thông điệp đó.
3.12.3 — Khi nào KHÔNG được rebase
# NGUY HIỂM — nhánh này đã push và người khác đang dùng
git checkout main
git rebase feature/abc
git push --force
Rebase viết lại lịch sử: mọi commit nhận mã băm mới. Nếu ai đó đã pull nhánh đó, lịch sử của họ và của bạn phân kỳ, và lần pull tiếp theo của họ tạo ra một mớ hỗn độn.
Quy tắc vàng:
Chỉ rebase nhánh mà chỉ mình bạn dùng.
An toàn: nhánh tính năng cá nhân chưa ai pull.
Nguy hiểm: main, develop, hoặc bất kỳ nhánh nào người khác đang làm việc trên đó.
Nếu bắt buộc phải force push lên nhánh chung, dùng bản an toàn hơn:
git push --force-with-lease
Nó từ chối push nếu remote có commit mới mà bạn chưa thấy — tức là có ai đó vừa push trong lúc bạn đang rebase. --force thường sẽ ghi đè mất commit đó không hỏi han gì (git rebase).
3.12.4 — git worktree
Ưu tiên trung bình. Rất tiện khi phải chuyển việc gấp.
Tình huống: đang làm dở một tính năng, có bug production cần sửa ngay.
# Cách thông thường: stash, checkout, sửa, checkout lại, stash pop
# -> dễ quên stash, và mất trạng thái build
# Worktree: mở một THƯ MỤC khác cho cùng repo
git worktree add ../crm-hotfix main
cd ../crm-hotfix
# sửa bug, commit, push — thư mục gốc KHÔNG bị đụng tới
cd ../crm
git worktree remove ../crm-hotfix
Lợi ích cụ thể: thư mục gốc giữ nguyên mọi thứ — file đang sửa dở, kết quả build, cấu hình IDE. Không có stash nào để quên.
3.12.5 — Git hook
Ưu tiên trung bình. Tự động hoá kiểm tra trước khi commit.
# .git/hooks/pre-commit
#!/bin/bash
dotnet format --verify-no-changes || {
echo "Code chưa đúng định dạng. Chạy: dotnet format"
exit 1
}
Hạn chế lớn: hook nằm trong .git/ nên không được commit và không tự chia sẻ cho cả đội. Giải pháp:
# Đặt hook trong thư mục được commit
git config core.hooksPath .githooks
Giờ thư mục .githooks/ nằm trong repo và mọi người dùng chung — chỉ cần chạy một lệnh git config sau khi clone.
Lưu ý: hook chạy ở máy lập trình viên và bỏ qua được bằng --no-verify. Kiểm tra thật sự quan trọng phải nằm trong CI, nơi không ai bỏ qua được.
3.12.6 — Chiến lược nhánh
| Chiến lược | Phù hợp | Độ phức tạp |
|---|---|---|
| GitHub Flow | Triển khai liên tục, đội nhỏ | Thấp |
| Git Flow | Phát hành theo phiên bản, có hotfix | Cao |
| Trunk-based | Đội trưởng thành, CI mạnh | Thấp nhưng đòi hỏi kỷ luật |
GitHub Flow (đơn giản nhất, đủ cho phần lớn dự án):
main ──●──●──●──●──
\ /
feature ●──●─┘
1. Tách nhánh từ main
2. Commit, push, mở PR
3. Review, merge vào main
4. Triển khai từ main
Git Flow có develop, release, hotfix, feature — mạnh nhưng nặng nề. Nó phù hợp với phần mềm phát hành theo phiên bản (v1.0, v1.1). Với ứng dụng web triển khai liên tục, nó thường là chi phí thuần.
Khuyến nghị: bắt đầu bằng GitHub Flow. Chỉ chuyển sang thứ phức tạp hơn khi có vấn đề cụ thể mà nó giải quyết.
3.12.7 — Những lệnh khác đáng biết
git cherry-pick <sha> # lấy MỘT commit từ nhánh khác
git stash -u # cất tạm cả file chưa đư ợc theo dõi
git blame -L 10,20 file.cs # ai sửa dòng 10-20, commit nào
git log -S "CalculateCommission" # tìm commit THÊM hoặc XOÁ chuỗi này
git diff --stat main # tóm tắt khác biệt so với main
git clean -fdn # xem TRƯỚC những file sẽ bị xoá (-n = thử)
git log -S đặc biệt hữu ích: nó tìm commit mà nội dung thay đổi chứa chuỗi đó, khác với git log --grep chỉ tìm trong thông điệp commit. Đây là cách nhanh nhất để trả lời "hàm này được thêm vào khi nào và vì sao".
git clean -fdn luôn chạy với -n trước — nó liệt kê những file sẽ bị xoá mà không xoá gì. Bỏ -n là xoá thật và không khôi phục được.
3.12.8 — Rà lại code của bạn
Danh sách rà soát Git nâng cao
- •Biết dùng git bisect để tìm commit gây lỗi.
- •Commit nhỏ và mỗi commit đều biên dịch được.
- •Dọn lịch sử bằng rebase tương tác trước khi mở PR.
- •Không bao giờ rebase nhánh người khác đang dùng.
- •Dùng force-with-lease thay vì force khi bắt buộc.
- •Hook dùng chung đặt trong thư mục được commit qua core.hooksPath.
- •Kiểm tra quan trọng nằm trong CI, không chỉ trong hook.
- •Chiến lược nhánh đủ đơn giản cho quy mô đội.
- •Luôn chạy git clean với -n trước.
Bài tập áp dụng
Bài 1 — Thử bisect
Tạo một repo với 20 commit, cố tình làm hỏng ở commit thứ 7, rồi dùng git bisect tìm ra nó.
Tiêu chí hoàn thành: bạn tìm đúng commit gây lỗi trong 4–5 bước, và giải thích được vì sao số bước đó không tăng nhiều khi repo lớn hơn.
Gợi ý và lời giải — Bài 1
Gợi ý. git bisect start <bad> <good> — thứ tự là xấu trước, tốt sau. Dễ nhớ nhầm.
Lời giải — dựng repo thử:
mkdir thu-bisect && cd thu-bisect
git init -q -b main
for i in $(seq 1 20); do
if [ $i -lt 7 ]; then
echo "def tinh(a,b): return a + b" > tinh.py # đúng
else
echo "def tinh(a,b): return a - b" > tinh.py # SAI từ commit 7
fi
echo "dòng $i" >> ghichu.txt
git add -A && git commit -q -m "commit $i"
done
git bisect start HEAD HEAD~19
Chạy trên Git 2.34.1:
Bisecting: 9 revisions left to test after this (roughly 3 steps)
[41ec17aa2ae83dd749a758282daa025a0ed491d6] commit 10
Sau mỗi lần Git chuyển sang một commit, bạn chạy thử rồi báo kết quả:
python3 -c "exec(open('tinh.py').read()); print(tinh(2,3))" # phải ra 5
git bisect bad # hoặc: git bisect good
kết quả test: bad
Bisecting: 4 revisions left to test after this (roughly 2 steps)
kết quả test: good
Bisecting: 2 revisions left to test after this (roughly 1 step)
kết quả test: bad
Bisecting: 0 revisions left to test after this (roughly 0 steps)
kết quả test: good
9834390321714a02142a4a2c45f568751b4e2f2e is the first bad commit
git show 9834390 --stat | head -5
commit 9834390321714a02142a4a2c45f568751b4e2f2e
commit 7
ghichu.txt | 1 +
tinh.py | 2 +-
Đúng commit 7, sau 4 lần thử.
git bisect reset # LUÔN chạy lệnh này khi xong
Vì sao số bước không tăng nhiều khi repo lớn hơn:
Mỗi lần thử loại bỏ MỘT NỬA số commit còn lại.
20 commit -> khoảng 4–5 bước (log₂20 ≈ 4,3)
100 commit -> khoảng 7 bước (log₂100 ≈ 6,6)
1.000 commit -> khoảng 10 bước (log₂1000 ≈ 10)
10.000 commit-> khoảng 14 bước
Repo lớn gấp 500 lần -> chỉ thêm 9 bước.
So với cách tìm tuần tự (đi từ commit mới nhất lùi dần):
1.000 commit -> có thể phải thử tới 1.000 lần
Đây là cùng một tính chất với tìm kiếm nhị phân, và nó là lý do bisect đáng học sớm: với một repo thật, nó biến một buổi chiều mò mẫm thành mười phút.
Ba lệnh phụ đáng biết:
git bisect skip # commit này không build được, bỏ qua
git bisect log # xem lại các bước đã làm
git bisect log > b.txt # lưu lại
git bisect replay b.txt # chạy lại y hệt, hữu ích khi báo cáo cho người khác
Và hai điều kiện để bisect dùng được:
1. Lỗi phải TẤT ĐỊNH — cùng commit luôn cho cùng kết quả.
Lỗi chỉ xảy ra 1/10 lần -> báo "good" nhầm ở một bước
-> bisect đi sai nhánh và cho kết quả sai
-> tệ hơn là không dùng gì, vì bạn tin vào một câu trả lời sai
Với lỗi không tất định: chạy test 20 lần mỗi bước,
báo bad nếu hỏng dù chỉ một lần.
2. Mỗi commit phải chạy được.
Commit giữa chừng không build được -> git bisect skip
Nhiều commit như vậy -> bisect chậm và kém chính xác
-> đây là lập luận thực tế cho việc mỗi commit trên nhánh chính
phải build và chạy test được, không phải chỉ là nguyên tắc suông.
Bài 2 — Bisect tự động
Viết một test tái hiện lỗi và chạy git bisect run với test đó.
Tiêu chí hoàn thành: git bisect run chạy tới kết quả không cần bạn gõ gì thêm, và bạn biết vì sao script test phải nằm ngoài repo.
Gợi ý và lời giải — Bài 2
Gợi ý. git bisect run hiểu mã thoát: 0 là good, khác 0 là bad, 125 là skip.
Lời giải — script kiểm tra:
#!/usr/bin/env bash
# /duong/dan/NGOAI/repo/kt.sh
kq=$(python3 -c "exec(open('tinh.py').read()); print(tinh(2,3))")
[ "$kq" = "5" ]
chmod +x ~/kt.sh
git bisect start HEAD HEAD~19
git bisect run ~/kt.sh
[049874982967db82c4383f71a5efc416ddcc7269] commit 5
[9834390321714a02142a4a2c45f568751b4e2f2e] commit 7
[55e3a2d418e7f06d8237ea6b076cae932612aa38] commit 6
9834390321714a02142a4a2c45f568751b4e2f2e is the first bad commit
commit 9834390321714a02142a4a2c45f568751b4e2f2e
commit 7
ghichu.txt | 1 +
tinh.py | 2 +-
bisect found first bad commit
Không gõ một lệnh nào giữa chừng. Với dự án .NET, script thường là:
#!/usr/bin/env bash
dotnet build -c Release --nologo -v q || exit 125 # không build được -> skip
dotnet test --filter "FullyQualifiedName~LoiTinhHoaHong" --nologo -v q
Mã thoát 125 có ý nghĩa đặc biệt: "bỏ qua commit này"
-> dùng cho commit không build được, thay vì báo nhầm thành bad
Vì sao script test phải nằm NGOÀI repo — đây là cái bẫy chính của bài này:
git bisect checkout một commit cũ
-> cây làm việc trở về trạng thái CỦA COMMIT ĐÓ
-> nếu kt.sh được commit ở commit gần đây, nó KHÔNG TỒN TẠI ở commit cũ
-> bash: ./kt.sh: No such file or directory
-> mã thoát 127 -> git coi là "bad"
-> MỌI commit cũ đều bị đánh dấu bad -> kết quả sai hoàn toàn
Đây là lỗi trực tiếp gặp khi dựng ví dụ cho bài này: lần chạy đầu tiên, script được commit vào repo và bisect chỉ ra sai commit. Triệu chứng rất dễ bỏ qua vì git bisect run vẫn kết thúc bình thường và vẫn đưa ra một câu trả lời — chỉ là câu trả lời sai.
# ĐÚNG — script ở ngoài, đường dẫn tuyệt đối
git bisect run ~/kt.sh
# SAI — script nằm trong repo
git bisect run ./kt.sh
Cùng lý do đó áp cho dữ liệu test và cấu hình:
Test đọc một tệp dữ liệu mẫu trong repo -> tệp đó cũng biến mất ở commit cũ
-> đặt dữ liệu ở ngoài, hoặc sinh ra trong chính script
Ba mẹo làm bisect run chạy nhanh hơn:
1. Giới hạn khoảng tìm càng hẹp càng tốt.
# Biết chắc hai tuần trước còn tốt?
git bisect start HEAD HEAD@{2.weeks.ago}
2. Chỉ chạy đúng một test, không chạy cả bộ.
Cả bộ test: 3 phút mỗi bước × 10 bước = 30 phút
Một test: 8 giây mỗi bước × 10 bước = 80 giây
3. Kiểm chứng script TRƯỚC khi bắt đầu.
git checkout HEAD && ~/kt.sh; echo "HEAD: $?" # phải khác 0
git checkout HEAD~19 && ~/kt.sh; echo "HEAD~19: $?" # phải bằng 0
git checkout main
Bước này mất 20 giây và loại bỏ khả năng chạy cả quá trình rồi nhận
một câu trả lời sai — đúng loại lỗi đã mô tả ở trên.
Bài 3 — Dọn lịch sử
Tạo 5 commit lộn xộn rồi dùng rebase tương tác gộp thành 2 commit có ý nghĩa.
Tiêu chí hoàn thành: git log còn 2 commit với thông điệp rõ ràng, và bạn nêu được vì sao việc này chỉ làm trên nhánh của riêng mình.
Gợi ý và lời giải — Bài 3
Gợi ý. Trong danh sách rebase, squash gộp vào commit phía trên nó. Nên commit đầu tiên luôn giữ pick.
Lời giải — năm commit lộn xộn:
git checkout -b tinh-nang
for m in "thêm hàm tính" "sửa lỗi chính tả" "thêm test" "sửa test" "thêm tài liệu"; do
echo "$m" >> nhatky.txt && git add -A && git commit -q -m "$m"
done
git log --oneline -5
8bf9ee2 thêm tài liệu
55def59 sửa test
ff583af thêm test
878a7d3 sửa lỗi chính tả
b2ec1a6 thêm hàm tính
git rebase -i HEAD~5
Git mở trình soạn thảo với danh sách theo thứ tự cũ trước, mới sau:
pick b2ec1a6 thêm hàm tính
pick 878a7d3 sửa lỗi chính tả
pick ff583af thêm test
pick 55def59 sửa test
pick 8bf9ee2 thêm tài liệu
Sửa thành:
pick b2ec1a6 thêm hàm tính
squash 878a7d3 sửa lỗi chính tả
pick ff583af thêm test
squash 55def59 sửa test
squash 8bf9ee2 thêm tài liệu
Successfully rebased and updated refs/heads/tinh-nang.
git log --oneline -2
bd3d3f7 test: thêm test cho hàm tính hoa hồng
58a1e8e feat: thêm hàm tính hoa hồng theo bậc
Năm commit thành hai, và hai commit đó kể một câu chuyện đọc được.
Bảng các lệnh trong rebase tương tác:
| Lệnh | Tác dụng |
|---|---|
pick | Giữ nguyên |
reword | Giữ thay đổi, sửa thông điệp |
squash | Gộp vào commit phía trên, ghép cả hai thông điệp |
fixup | Gộp vào commit phía trên, bỏ thông điệp của nó |
edit | Dừng lại để sửa nội dung commit |
drop | Xoá hẳn commit |
fixup hữu ích hơn squash trong phần lớn trường hợp:
"sửa lỗi chính tả", "sửa test" không đáng có mặt trong thông điệp cuối
git rebase -i --autosquash HEAD~5
Kết hợp với: git commit --fixup=<hash>
-> Git tự sắp xếp và đánh dấu fixup, không phải sửa tay
Vì sao chỉ làm trên nhánh của riêng mình:
Rebase VIẾT LẠI commit -> mọi commit được viết lại có hash MỚI
-> lịch sử cũ và lịch sử mới là hai đường khác nhau
Nếu ai đó đã pull nhánh đó về:
- họ có lịch sử cũ
- bạn đẩy lên lịch sử mới
- lần pull tiếp theo của họ: hai lịch sử không liên quan gì nhau
- kết quả thường là merge lộn xộn, hoặc commit bị nhân đôi
- và nếu họ giải quyết sai, thay đổi của người khác có thể MẤT
Quy tắc gói gọn:
Commit chỉ nằm trên máy bạn, hoặc trên nhánh chỉ mình bạn dùng -> rebase thoải mái
Commit đã lên nhánh chung (main, develop) -> KHÔNG BAO GIỜ rebase
Đây đúng là điều mục 3.12.3 nói, và bài tập này là chỗ để làm quen với nó ở nơi an toàn.
Nếu đã lỡ đẩy nhánh của riêng mình lên rồi:
git push --force-with-lease
--force-with-lease KHÁC --force:
--force: ghi đè bất chấp -> nếu ai đó vừa đẩy lên, thay đổi của họ MẤT
--force-with-lease: chỉ ghi đè nếu nhánh trên máy chủ ĐÚNG như bạn nghĩ
-> có người khác đẩy lên trong lúc đó -> từ chối
Không có lý do nào để dùng --force khi --force-with-lease tồn tại.
Và nếu rebase đi sai hướng, không có gì mất cả:
git rebase --abort # đang giữa chừng -> quay về nguyên trạng
git reflog # đã xong rồi mới thấy sai
git reset --hard HEAD@{5} # quay về trạng thái trước khi rebase
reflog ghi lại mọi lần HEAD di chuyển, kể cả những commit không còn nhánh nào trỏ tới — nên gần như mọi thao tác Git đều đảo ngược được trong vòng 90 ngày mặc định. Bài 3 của bài 3.12 đi sâu vào cách dùng nó.
Tự kiểm tra
Frequently asked questions
git bisect hoạt động thế nào?
Nó dùng tìm kiếm nhị phân giữa một commit tốt và một commit lỗi. Mỗi lần kiểm tra loại bỏ một nửa phạm vi, nên với một nghìn commit chỉ cần khoảng mười lần kiểm tra.
Vì sao commit nhỏ làm bisect hiệu quả hơn?
Vì khi tìm ra commit gây lỗi, một commit nhỏ chỉ chứa vài dòng thay đổi nên nguyên nhân rõ ngay. Một commit khổng lồ thì bạn vẫn phải dò tiếp bên trong nó. Và mỗi commit phải biên dịch được, nếu không bisect sẽ dừng ở những commit không kiểm tra được.
Quy tắc vàng của rebase là gì?
Chỉ rebase nhánh mà chỉ mình bạn dùng. Rebase viết lại lịch sử nên mọi commit nhận mã băm mới, và nếu người khác đã pull nhánh đó thì lịch sử của hai bên phân kỳ.
force-with-lease khác force thế nào?
Nó từ chối push nếu remote có commit mới mà bạn chưa thấy, tức là có ai đó vừa push trong lúc bạn rebase. Force thường sẽ ghi đè mất commit đó mà không cảnh báo.
Hạn chế của Git hook là gì?
Hook nằm trong thư mục .git nên không được commit và không tự chia sẻ cho cả đội, phải dùng core.hooksPath. Ngoài ra nó chạy ở máy lập trình viên và bỏ qua được bằng --no-verify, nên kiểm tra quan trọng phải nằm trong CI.
git log -S khác git log --grep ở chỗ nào?
git log -S tìm commit mà nội dung thay đổi có thêm hoặc bớt chuỗi đó, còn --grep chỉ tìm trong thông điệp commit. -S là cách nhanh nhất để biết một hàm được thêm vào khi nào.
Kết luận
Ba điều đáng nhớ nhất:
git bisectlà công cụ bị đánh giá thấp nhất — nó biến hàng giờ dò tìm thành 10 phút.- Chỉ rebase nhánh của riêng mình. Đây là quy tắc không có ngoại lệ.
- Hook chạy ở máy lập trình viên và bỏ qua được. Kiểm tra thật phải ở CI.
Tham khảo
Điều hướng
- Bài trước: 3.10 — 9. Conventional Commits
- Bài tiếp theo: 3.12 — Mini case study
- Về module: Trang mục lục