Hoàn tác trong Git: reset, revert và restore
1. Tổng quan
Ai dùng Git cũng có lúc cần quay lại: sửa nhầm file, git add thừa, hoặc commit một thứ không nên commit. Git có ba lệnh chính cho việc này là restore, reset và revert. Tên của chúng nghe giống nhau, nhưng mỗi lệnh tác động lên một vùng khác nhau.
Bài này đi qua từng lệnh trên cùng một repository mẫu. Đọc xong, bạn sẽ biết nên dùng lệnh nào cho tình huống nào. Bạn cũng sẽ biết cách cứu lại commit khi lỡ tay reset --hard.
2. Chuẩn bị repository mẫu
Trước hết, cần nhớ Git có ba vùng:
- Working tree: các file bạn đang sửa trên đĩa.
- Staging area (index): những gì sẽ vào commit tiếp theo.
- Commit history: các commit đã lưu, với
HEADtrỏ tới commit hiện tại.
Ta tạo một repository nhỏ có bốn commit:
mkdir order-service && cd order-service
git init -b main
echo "class Order {}" > Order.java
git add Order.java && git commit -m "Thêm lớp Order"
echo "class Customer {}" > Customer.java
git add Customer.java && git commit -m "Thêm lớp Customer"
echo "class Order { int total; }" > Order.java
git commit -am "Thêm total cho Order"
echo "class PaymentService {}" > PaymentService.java
git add PaymentService.java && git commit -m "Thêm PaymentService"
git log --oneline
Lệnh git commit -am stage mọi file đã được track rồi commit luôn. Kết quả git log sẽ giống như sau (hash trên máy bạn sẽ khác):
d4e5f60 (HEAD -> main) Thêm PaymentService
c3d4e5f Thêm total cho Order
b2c3d4e Thêm lớp Customer
a1b2c3d Thêm lớp Order
3. git restore: hoàn tác thay đổi chưa commit
git restore có từ Git 2.23. Lệnh này chỉ làm việc với file. Nó không bao giờ di chuyển HEAD hay sửa lịch sử commit. Ở các phiên bản Git cũ, việc này do git checkout -- <file> đảm nhận.
3.1. Bỏ thay đổi trong working tree
Giả sử bạn sửa Order.java rồi đổi ý:
echo "class Order { int total; String note; }" > Order.java
git status --short
git restore Order.java
cat Order.java
M Order.java
class Order { int total; }
git status --short hiện M nghĩa là file đã bị sửa nhưng chưa stage. git restore Order.java chép lại nội dung từ staging area ra working tree. Vì chưa stage gì, nội dung này trùng với HEAD.
Lưu ý: Thay đổi bị bỏ bằng
git restorelà mất hẳn. Git chưa từng lưu chúng, nênreflogcũng không cứu được. Nếu còn phân vân, hãy chạygit stashthay vìrestore.
3.2. Bỏ file khỏi staging area
Bạn lỡ git add một file chưa muốn commit:
echo "class Order { int total; String note; }" > Order.java
git add Order.java
git restore --staged Order.java
git status --short
M Order.java
Tùy chọn --staged chép nội dung từ HEAD vào staging area. Kết quả là file được “unstage”, còn nội dung bạn vừa sửa vẫn nằm nguyên trong working tree. Lệnh cũ tương đương là git reset HEAD Order.java.
Muốn bỏ cả staging lẫn working tree trong một lần, bạn dùng:
git restore --staged --worktree Order.java
3.3. Lấy lại file từ một commit cũ
Tùy chọn --source cho phép lấy nội dung file ở bất kỳ commit nào:
git restore --source=HEAD~2 Order.java
cat Order.java
git status --short
class Order {}
M Order.java
HEAD~2 là commit “Thêm lớp Customer”. Ở commit đó Order.java chưa có total. Mặc định lệnh chỉ ghi ra working tree, nên file hiện là M (chưa stage). Bạn kiểm tra lại rồi tự git add và commit nếu muốn giữ. Còn không thì chạy git restore Order.java để quay về.
4. git reset: dời con trỏ branch
git reset <commit> di chuyển branch hiện tại (và HEAD) về commit chỉ định. Ba chế độ --soft, --mixed, --hard khác nhau ở chỗ staging area và working tree có bị ghi đè theo hay không.
| Chế độ | Branch/HEAD | Staging area | Working tree |
|---|---|---|---|
--soft |
dời về commit đích | giữ nguyên | giữ nguyên |
--mixed (mặc định) |
dời về commit đích | đặt lại theo commit đích | giữ nguyên |
--hard |
dời về commit đích | đặt lại theo commit đích | đặt lại theo commit đích |
Tất cả ví dụ dưới đây xuất phát từ trạng thái sạch ở commit d4e5f60.
4.1. –soft: gỡ commit, giữ thay đổi đã stage
git reset --soft HEAD~1
git log --oneline -1
git status --short
c3d4e5f (HEAD -> main) Thêm total cho Order
A PaymentService.java
Commit “Thêm PaymentService” biến khỏi branch. File PaymentService.java vẫn còn và đang ở trạng thái A (đã stage). Cách này hợp khi bạn muốn sửa message hoặc gộp vài commit thành một:
git commit -m "Thêm PaymentService xử lý thanh toán"
4.2. –mixed: gỡ commit và unstage
git reset HEAD~1
git status --short
?? PaymentService.java
Đây là chế độ mặc định. Branch lùi một commit, staging area được đặt lại. PaymentService.java là file mới nên giờ thành untracked (??). Bạn dùng --mixed khi muốn chia lại commit: stage từng phần rồi commit thành nhiều commit nhỏ.
4.3. –hard: bỏ tất cả
git add PaymentService.java && git commit -m "Thêm PaymentService"
git reset --hard HEAD~1
ls
HEAD is now at c3d4e5f Thêm total cho Order
Customer.java Order.java
Commit, nội dung staging và working tree của các file đã track đều bị đưa về c3d4e5f. PaymentService.java biến mất.
Lưu ý:
reset --hardxóa luôn các thay đổi chưa commit trên file đã track. Những thay đổi này không cứu được. Ngược lại, nó không xóa file untracked. Muốn xóa file untracked, bạn phải dùnggit clean -fd, và lệnh đó cũng nguy hiểm không kém.
4.4. Cứu commit bằng reflog
Commit bị reset chưa mất ngay. Git ghi lại mọi lần HEAD di chuyển trong reflog:
git reflog -3
c3d4e5f HEAD@{0}: reset: moving to HEAD~1
9f8e7d6 HEAD@{1}: commit: Thêm PaymentService
c3d4e5f HEAD@{2}: commit: Thêm PaymentService xử lý thanh toán
Bạn chỉ cần reset về commit muốn lấy lại:
git reset --hard 9f8e7d6
git log --oneline -1
9f8e7d6 (HEAD -> main) Thêm PaymentService
Reflog chỉ tồn tại trên máy bạn. Mặc định các mục không còn được tham chiếu sẽ hết hạn sau 30 ngày rồi bị garbage collector dọn đi. Vì vậy, cứu càng sớm càng tốt.
4.5. Khi nào KHÔNG nên dùng reset
Không nên reset các commit đã push lên branch dùng chung. Sau khi reset, lịch sử local và remote lệch nhau, nên bạn buộc phải git push --force. Đồng nghiệp đã pull các commit đó sẽ gặp lịch sử rối và có thể vô tình đẩy chúng lên lại.
Nếu bắt buộc phải ghi đè, chẳng hạn trên branch cá nhân, hãy dùng lệnh an toàn hơn:
git push --force-with-lease
--force-with-lease từ chối push nếu remote có commit mới mà bạn chưa fetch về. Nhờ vậy bạn không đè mất việc của người khác.
5. git revert: hoàn tác bằng commit mới
git revert không xóa lịch sử. Lệnh này tạo một commit mới có nội dung ngược lại với commit cần hoàn tác. Lịch sử chỉ dài thêm chứ không bị viết lại, nên đây là cách an toàn cho branch đã push.
5.1. Revert một commit
git revert --no-edit HEAD
git log --oneline -3
ls
1a2b3c4 (HEAD -> main) Revert "Thêm PaymentService"
9f8e7d6 Thêm PaymentService
c3d4e5f Thêm total cho Order
Customer.java Order.java
--no-edit dùng luôn message mặc định mà không mở editor. Commit gốc 9f8e7d6 vẫn còn trong lịch sử. Commit mới 1a2b3c4 xóa PaymentService.java. Body của commit mới có dòng This reverts commit 9f8e7d6..., giúp người khác lần ra nguồn gốc.
Bạn có thể revert cả commit cũ ở giữa lịch sử, không nhất thiết phải là HEAD:
git revert --no-edit c3d4e5f
cat Order.java
class Order {}
Trường hợp các commit sau đã sửa cùng dòng code, Git sẽ báo conflict. Bạn sửa conflict, chạy git add, rồi chạy git revert --continue. Muốn hủy giữa chừng thì dùng git revert --abort.
5.2. Revert nhiều commit thành một commit
Mặc định, mỗi commit được revert sinh ra một commit riêng. Muốn gom lại, bạn dùng --no-commit:
git revert --no-commit HEAD~3..HEAD~1
git commit -m "Hoàn tác các thay đổi về Customer và Order"
Cú pháp A..B chọn các commit sau A, tính đến B. Git áp dụng ngược từng commit vào staging area, rồi bạn commit một lần duy nhất.
5.3. Revert một merge commit
Merge commit có hai cha. Vì vậy Git cần biết bạn muốn giữ nhánh nào làm “dòng chính”:
git revert -m 1 <hash-merge-commit>
-m 1 chọn cha thứ nhất, thường là branch bạn đã merge vào (ví dụ main). Kết quả là mọi thay đổi do nhánh kia mang vào sẽ bị hoàn tác.
Lưu ý: Sau khi revert một merge, nếu bạn merge lại cùng branch đó, Git coi các commit cũ là “đã merge rồi”. Những thay đổi đó sẽ không quay lại. Muốn đưa chúng về, bạn phải revert chính commit revert kia.
6. Chọn lệnh nào?
| Tình huống | Lệnh nên dùng |
|---|---|
| Bỏ thay đổi chưa stage của một file | git restore <file> |
| Unstage một file | git restore --staged <file> |
| Lấy phiên bản cũ của một file | git restore --source=<commit> <file> |
| Sửa/gộp commit cuối, chưa push | git reset --soft HEAD~1 |
| Chia lại commit, chưa push | git reset HEAD~1 |
| Vứt bỏ hoàn toàn commit local | git reset --hard <commit> |
| Hoàn tác commit đã push | git revert <commit> |
Quy tắc ngắn gọn để nhớ:
restorelàm việc với file.resetlàm việc với branch, dùng ở local.revertlàm việc với lịch sử chung.
7. Lỗi hay gặp
- Nhầm
resetvớireverttrên branch chung. Đây là lỗi phổ biến nhất. Đã push thì hãy revert. - Chạy
reset --hardkhi còn thay đổi chưa commit. Trước khi chạy, hãy xemgit status. Còn thay đổi thìgit stashtrước. - Tưởng
reset --harddọn sạch thư mục. File untracked vẫn còn nguyên. Bạn cầngit clean -nđể xem trước, sau đógit clean -fdđể xóa. - Quên rằng
HEAD~1vàHEAD^khác nhau ở merge commit.HEAD^2là cha thứ hai, cònHEAD~2là ông, tức cha của cha. Với lịch sử tuyến tính thìHEAD~1vàHEAD^như nhau. - Dùng
git checkout <file>theo thói quen. Lệnh này vẫn chạy được, nhưngcheckoutgánh quá nhiều vai trò, rất dễ gõ nhầm thành chuyển branch. Ở Git hiện đại,restorevàswitchrõ ràng hơn.
8. Kết luận
Trong bài này, bạn đã thấy ba cách hoàn tác trong Git:
git restorekhôi phục nội dung file trong working tree hoặc staging area mà không đụng tới lịch sử.git resetdời branch về một commit khác, với ba mức--soft,--mixed,--hard. Lệnh này chỉ nên dùng cho commit chưa push.git reverttạo commit đảo ngược, là lựa chọn an toàn cho branch dùng chung.
Khi lỡ tay, hãy nhớ tới git reflog. Phần lớn các commit “đã mất” vẫn nằm đó chờ bạn lấy lại.