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 HEAD trỏ 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 restore là mất hẳn. Git chưa từng lưu chúng, nên reflog cũng không cứu được. Nếu còn phân vân, hãy chạy git stash thay 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 --hard xó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ùng git 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ớ:

  • restore làm việc với file.
  • reset làm việc với branch, dùng ở local.
  • revert làm việc với lịch sử chung.

7. Lỗi hay gặp

  • Nhầm reset với revert trên branch chung. Đây là lỗi phổ biến nhất. Đã push thì hãy revert.
  • Chạy reset --hard khi còn thay đổi chưa commit. Trước khi chạy, hãy xem git status. Còn thay đổi thì git stash trước.
  • Tưởng reset --hard dọn sạch thư mục. File untracked vẫn còn nguyên. Bạn cần git clean -n để xem trước, sau đó git clean -fd để xóa.
  • Quên rằng HEAD~1 và HEAD^ khác nhau ở merge commit. HEAD^2 là cha thứ hai, còn HEAD~2 là ông, tức cha của cha. Với lịch sử tuyến tính thì HEAD~1 và HEAD^ như nhau.
  • Dùng git checkout <file> theo thói quen. Lệnh này vẫn chạy được, nhưng checkout gánh quá nhiều vai trò, rất dễ gõ nhầm thành chuyển branch. Ở Git hiện đại, restore và switch rõ 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 restore khôi phục nội dung file trong working tree hoặc staging area mà không đụng tới lịch sử.
  • git reset dờ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 revert tạ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.

gitversion-controlresetrevertrestore