Bài trước ta thấy merge tạo một commit mới nối hai nhánh, giữ nguyên lịch sử rẽ nhánh. git rebase giải cùng bài toán — đưa công việc của nhánh này lên trên nhánh kia — nhưng theo cách hoàn toàn khác: nó viết lại lịch sử. Câu này nghe đáng sợ, và nhiều người dùng rebase mà không thật sự hiểu nó làm gì, để rồi hoảng khi thấy commit "biến mất" hay SHA đổi hết. Bài này chứng minh chính xác điều gì xảy ra, bằng SHA thật chạy trong Git 2.39.

Rebase làm gì: đổi điểm neo của một chuỗi commit

Hình dung bạn tách nhánh tinh-nang từ main tại một điểm nào đó, làm hai commit, rồi trong lúc đó main cũng tiến thêm. Giờ lịch sử rẽ đôi. Có hai cách gộp lại:

  • merge: tạo một commit merge có hai parent, giữ nguyên vết rẽ nhánh (bài trước).
  • rebase: lấy các commit của tinh-nang, phát lại chúng lần lượt lên trên đỉnh main — như thể bạn vừa tách nhánh từ chỗ main đang đứng bây giờ.

Điểm mấu chốt mà đa số hiểu sai: rebase không di chuyển các commit cũ sang chỗ mới. Một commit trong Git là bất biến — SHA của nó băm từ toàn bộ nội dung bao gồm cả con trỏ parent. Đổi parent nghĩa là đổi nội dung băm, nghĩa là ra một SHA khác, nghĩa là một commit mới. Rebase thực chất tạo một chuỗi commit mới sao chép nội dung (diff) của chuỗi cũ, rồi dời con trỏ nhánh sang chuỗi mới. Chuỗi cũ bị bỏ lại, không còn nhánh nào trỏ tới.

# Tách nhánh tinh-nang từ main, làm 2 commit
git switch -c tinh-nang
echo "dòng A" >> app.txt && git commit -am "feat 1"
echo "dòng B" >> app.txt && git commit -am "feat 2"

# Trong lúc đó main tiến thêm
git switch main
echo "hotfix" >> other.txt && git commit -am "main tien them"

# Phát lại tinh-nang lên đỉnh main
git switch tinh-nang
git rebase main

Ảnh chụp đoạn mã shell nền tối minh hoạ rebase phát lại chuỗi commit lên đỉnh nhánh khác, tạo nhánh tinh-nang từ main làm hai commit feat 1 và feat 2, trong lúc đó main tiến thêm commit main tien them, chạy git rebase main để phát lại tinh-nang lên đỉnh main, giải thích commit là bất biến SHA băm từ nội dung bao gồm con trỏ parent nên đổi parent thì đổi SHA thành commit mới, rebase không di chuyển commit cũ mà tạo chuỗi mới sao chép diff rồi dời con trỏ nhánh, so sánh merge giữ vết rẽ nhánh còn rebase cho lịch sử thẳng

Hình 1: Tách tinh-nang làm hai commit, main tiến thêm, rồi git rebase main phát lại hai commit lên đỉnh main. Vì SHA băm cả con trỏ parent, đổi parent tạo ra commit mới chứ không dời commit cũ.

Bằng chứng: cùng nội dung, cùng message, SHA khác hẳn

Đây là phần thuyết phục nhất. Trước rebase, hai commit của tinh-nang là 8aac0ef (feat 1) và 9fc4d6c (feat 2). Sau git rebase main, chạy lại git log thấy hai commit mang đúng message "feat 1"/"feat 2", đúng nội dung — nhưng SHA đã là f45bc00 và efd241e. Chúng là commit mới toanh.

Ảnh chụp bảng kết quả đo thật nền tối chạy git 2.39, SHA hai commit tính năng trước và sau rebase, trước rebase feat 2 là 9fc4d6c feat 1 là 8aac0ef, sau git rebase main feat 2 thành efd241e feat 1 thành f45bc00, cả hai SHA đổi thành commit mới vì parent khác, nội dung và message giữ nguyên chỉ parent đổi nên lịch sử thành thẳng với thứ tự efd241e feat 2 f45bc00 feat 1 8f0c79a main tien them f088032 base nhánh nối thẳng lên đỉnh main, commit cũ vẫn còn trong reflog an toàn khôi phục với efd241e HEAD rebase finish returning to refs heads tinh-nang efd241e HEAD rebase pick feat 2 f45bc00 HEAD rebase pick feat 1, so sánh merge hay rebase rebase cho lịch sử thẳng dễ đọc nhưng viết lại SHA đừng rebase nhánh đã push chung merge giữ lịch sử thật an toàn cho nhánh chia sẻ quy tắc vàng chỉ rebase commit chưa ai khác dùng

Hình 2: Trước rebase feat 1/feat 2 là 8aac0ef/9fc4d6c; sau rebase là f45bc00/efd241e — SHA đổi hoàn toàn dù nội dung và message y nguyên. Lịch sử thành một đường thẳng, và các commit cũ vẫn nằm trong reflog.

Ba điều rút ra ngay từ số liệu này:

  1. SHA đổi = commit mới. Không phải Git "sửa" commit cũ; nó tạo commit mới với cùng diff nhưng parent khác. Đây là lý do rebase một nhánh người khác đang dùng lại gây rối: SHA họ đang giữ bỗng không còn nhánh nào trỏ tới.
  2. Nội dung không mất. Diff của từng commit được phát lại y nguyên (trừ khi có xung đột phải giải). Chỉ có "danh tính" (SHA) thay đổi.
  3. Lịch sử thành đường thẳng. Sau rebase, git log cho: base → main tien them → feat 1 → feat 2. Không còn điểm rẽ, không có commit merge. Đây chính là sức hút của rebase — lịch sử sạch, dễ đọc, dễ git bisect.

Commit cũ chưa mất — nó nằm trong reflog

Nghe "viết lại lịch sử" dễ tưởng commit cũ bị xoá vĩnh viễn. Không. Hai SHA cũ 8aac0ef/9fc4d6c giờ mồ côi (orphaned) — không nhánh nào trỏ tới — nhưng object vẫn còn trong kho cho tới lần thu gom rác (git gc) tiếp theo, thường là hàng tuần trở lên. Và Git ghi lại mọi bước dời HEAD trong reflog:

git reflog
# efd241e HEAD@{0}: rebase (finish): returning to refs/heads/tinh-nang
# efd241e HEAD@{1}: rebase (pick): feat 2
# f45bc00 HEAD@{2}: rebase (pick): feat 1

Reflog cho thấy từng bước pick mà rebase thực hiện. Lỡ tay rebase hỏng? git reset --hard HEAD@{n} hoặc git reset --hard 9fc4d6c đưa nhánh về đúng chuỗi commit cũ — miễn là gc chưa dọn. Đây là lưới an toàn khiến rebase ít đáng sợ hơn nhiều so với danh tiếng của nó (bài sau sẽ đào sâu reflog).

Quy tắc vàng: đừng rebase nhánh đã chia sẻ

Vì rebase đổi SHA, nó chỉ an toàn khi không ai khác đang dùng các commit đó. Quy tắc vàng của Pro Git: chỉ rebase những commit còn nằm trong nhánh riêng của bạn, chưa push chung. Nếu bạn rebase một nhánh đã push và người khác đã pull, SHA của họ và của bạn phân kỳ — lần push sau bạn phải --force, và đồng đội của bạn sẽ thấy lịch sử "nhảy" khỏi dưới chân họ, dẫn tới commit trùng lặp khi họ merge lại.

Lựa chọn thực tế thường là kết hợp: rebase nhánh tính-năng riêng của mình cho gọn trước khi mở PR (lịch sử sạch, review dễ), rồi merge --no-ff vào nhánh chung (giữ được ranh giới của tính năng). So sánh nhanh:

rebase merge
Lịch sử thẳng, dễ đọc giữ vết rẽ nhánh, có commit merge
SHA viết lại (commit mới) giữ nguyên, thêm 1 commit merge
An toàn với nhánh chia sẻ Không — đừng rebase commit đã push chung Có
Hợp khi dọn nhánh riêng trước khi gộp gộp nhánh nhiều người dùng

Ba ý mang về

  1. Rebase tạo commit mới, không di chuyển commit cũ. SHA băm cả con trỏ parent, nên đổi parent là đổi SHA — đo thật: 8aac0ef/9fc4d6c thành f45bc00/efd241e dù nội dung và message y nguyên. "Viết lại lịch sử" nghĩa đen là vậy.
  2. Commit cũ vẫn cứu được qua reflog. SHA cũ mồ côi nhưng còn trong kho tới lần gc; git reflog liệt kê từng bước pick, và git reset --hard <SHA-cũ> đưa nhánh về nguyên trạng.
  3. Đừng rebase nhánh đã push chung. Rebase cho lịch sử thẳng đẹp nhưng đổi SHA, nên chỉ dùng cho commit chưa ai khác giữ — quy tắc vàng của Pro Git. Nhánh chia sẻ thì merge; nhánh riêng thì rebase cho gọn rồi mới merge vào chung.

Nguồn

Phần sau ta đi sâu vào chính lưới an toàn vừa nhắc: reflog ghi lại mọi lần HEAD dịch chuyển thế nào, và vì sao gần như mọi thao tác "mất commit" (reset, rebase, xoá nhánh) đều khôi phục được nhờ nó.