git status và git diff là hai lệnh bạn gõ nhiều nhất trong ngày, nhưng ít người dừng lại hỏi: chúng so cái gì với cái gì? Câu trả lời làm sáng tỏ vô số nhầm lẫn — vì sao git diff "không thấy gì" sau khi bạn git add, vì sao file vừa thêm vào .gitignore vẫn hiện trong git status. Tất cả quy về ba vùng đã gặp ở bài "ba vùng" đầu sê-ri: working tree, index (staging area), và HEAD (commit gần nhất). Bài này đo thật từng lệnh trong Git 2.39.

Ba vùng, và mỗi lệnh so hai vùng nào

Đây là bản đồ cần thuộc:

  WORKING TREE  <--diff-->  INDEX  <--diff --cached-->  HEAD
  (file bạn sửa)          (đã git add)          (commit gần nhất)
  • git diff (không tham số) so working tree với index — tức những thay đổi bạn chưa git add.
  • git diff --cached (hay --staged) so index với HEAD — những thay đổi bạn đã stage, sẵn sàng commit.
  • git diff HEAD so working tree với HEAD — tất cả thay đổi, dù đã stage hay chưa.

Hiểu điều này là hết bối rối "vì sao git diff trống rỗng": khi bạn đã git add mọi thứ, working tree khớp index nên git diff không có gì để in — thay đổi giờ nằm ở git diff --cached.

git diff            # WORKING vs INDEX  (thay đổi CHƯA stage)
git diff --cached   # INDEX vs HEAD     (thay đổi ĐÃ stage)
git diff HEAD       # WORKING vs HEAD   (tất cả thay đổi)

Ảnh chụp đoạn mã shell nền tối giải thích git status và git diff so cái gì với cái gì, ba vùng working tree là file bạn sửa index là đã git add HEAD là commit gần nhất, git diff so working với index tức thay đổi chưa stage, git diff --cached so index với HEAD tức thay đổi đã stage, git diff HEAD so working với HEAD tức tất cả thay đổi, git status --short có hai cột XY với X là trạng thái ở index Y là ở working tree, M ở cột 1 nghĩa đã stage index khác HEAD, M ở cột 2 nghĩa sửa nhưng chưa stage, hai dấu hỏi là chưa theo dõi untracked, hai dấu chấm than là bị gitignore loại chỉ hiện với --ignored, gitignore ví dụ sao chấm log bỏ qua mọi file log và build gạch chéo bỏ cả thư mục, bẫy gitignore chỉ chặn file chưa theo dõi file đã git add rồi thì thêm vào gitignore vô tác dụng phải git rm --cached mới gỡ khỏi theo dõi

Hình 1: Ba vùng và lệnh diff nào so hai vùng nào. git status --short đọc bằng hai cột X (index) và Y (working tree); .gitignore chỉ chặn file chưa theo dõi.

Đo thật: ba trạng thái cùng lúc

Dựng repo với app.txt và readme.txt đã commit. Giờ tạo ba trạng thái khác nhau trong một lần: sửa app.txt rồi stage nó, sửa readme.txt nhưng không stage, và thêm một file mới tmp.log chưa theo dõi. Chạy git status --short:

Ảnh chụp bảng kết quả đo thật nền tối chạy git 2.39, app.txt sửa và đã stage readme.txt sửa chưa stage tmp.log chưa theo dõi, git status --short hai cột X Y cho M app.txt là staged index khác HEAD, khoảng trắng rồi M readme.txt là chưa stage working khác index, hai dấu hỏi tmp.log là untracked, git diff working vs index chỉ hiện readme.txt chưa stage với --- a readme.txt +++ b readme.txt @@ -1 +1,2 @@ ban dau cộng them dong moi chua stage, git diff --cached index vs HEAD chỉ hiện app.txt đã stage với --- a app.txt +++ b app.txt @@ -1,3 +1,3 @@ alpha trừ beta cộng BETA-da-sua gamma, gitignore sao chấm log build gạch chéo rồi git status --ignored cho hai dấu hỏi gitignore hai chấm than build bị loại hai chấm than tmp.log bị loại biến khỏi hai dấu hỏi thường, bẫy thêm sao chấm txt vào gitignore sau khi theo-doi.txt đã add thì khoảng trắng M theo-doi.txt vẫn theo dõi gitignore chỉ chặn file chưa track

Hình 2: git status --short in M (app.txt staged), M (readme.txt chưa stage), ?? (tmp.log). git diff chỉ hiện readme.txt; git diff --cached chỉ hiện app.txt. Bên dưới: .gitignore và cái bẫy file đã theo dõi.

Kết quả xác nhận đúng mô hình:

  • git status --short in hai cột cho mỗi file: XY tên-file, với X = trạng thái ở index, Y = ở working tree. M (M cột 1) là app.txt đã stage; M (M cột 2) là readme.txt sửa chưa stage; ?? là tmp.log chưa theo dõi. Đọc được hai cột này là đọc được toàn bộ trạng thái trong một dòng.
  • git diff chỉ in thay đổi của readme.txt (+them dong moi chua stage) — vì nó so working với index, mà app.txt đã stage nên working khớp index, không hiện.
  • git diff --cached ngược lại, chỉ in thay đổi của app.txt (-beta / +BETA-da-sua) — vì nó so index với HEAD.

Cùng một repo, hai lệnh diff cho hai kết quả hoàn toàn khác nhau — không phải lệnh nào sai, mà chúng nhìn vào hai ranh giới khác nhau.

.gitignore và cái bẫy kinh điển

Thêm .gitignore với *.log và build/. Ngay lập tức tmp.log biến mất khỏi danh sách ?? trong git status — nó bị loại. Muốn thấy lại các file bị loại, dùng git status --short --ignored: chúng hiện với mã !! (!! build/, !! tmp.log). Đây là cách kiểm tra một file có đang bị .gitignore nuốt hay không.

Nhưng đây là cái bẫy làm khổ vô số người: .gitignore chỉ có tác dụng với file chưa theo dõi. Đo thật — commit theo-doi.txt trước, rồi thêm *.txt vào .gitignore, rồi sửa theo-doi.txt:

git add theo-doi.txt && git commit -m "them theo-doi.txt"
echo "*.txt" >> .gitignore     # thêm vào ignore SAU khi đã theo dõi
echo "sua sau khi ignore" >> theo-doi.txt
git status --short theo-doi.txt
#  M theo-doi.txt              <- VẪN hiện! gitignore vô tác dụng

theo-doi.txt vẫn hiện M — Git tiếp tục theo dõi nó bất chấp .gitignore. Lý do: .gitignore là quy tắc cho việc quyết định có bắt đầu theo dõi một file mới hay không, không phải quy tắc "ẩn file". File đã nằm trong index rồi thì nó ngoài phạm vi của .gitignore. Đây là nguồn gốc của tai nạn kinh điển: lỡ commit node_modules/ hay một file secret, thêm vào .gitignore tưởng đã xong, nhưng nó vẫn bị theo dõi và vẫn bị commit tiếp. Cách sửa đúng:

git rm --cached theo-doi.txt   # gỡ khỏi index (giữ file trên đĩa)
git commit -m "gỡ file khỏi theo dõi"
# giờ .gitignore mới có hiệu lực với nó

Đánh đổi và mẹo thực dụng

git status có thể chậm trên repo lớn. Nó phải so từng file trong working tree với index (kiểm mtime, kích thước). Repo hàng trăm nghìn file thì bước này tốn. Git có core.fsmonitor (theo dõi thay đổi qua hệ điều hành) và core.untrackedCache để tăng tốc — bật khi git status bắt đầu ì ạch.

Dùng --short để đọc nhanh, dạng dài để học. Dạng dài của git status in kèm gợi ý lệnh (use "git restore --staged"), tốt cho người mới; nhưng khi đã quen, -s/--short gọn hơn nhiều và hai cột XY chứa đủ thông tin.

git diff mặc định bỏ qua file untracked. Nó chỉ so file đã theo dõi. File mới tmp.log không xuất hiện trong bất kỳ git diff nào cho tới khi bạn git add — vì trước đó nó chưa có phiên bản nào trong index để so. Đừng tưởng git diff trống là "không có gì mới".

Ba ý mang về

  1. Mỗi lệnh diff so hai vùng cụ thể: git diff so working với index (chưa stage), git diff --cached so index với HEAD (đã stage), git diff HEAD so working với HEAD (tất cả) — đo thật, cùng repo cho ba kết quả khác nhau. Hết bối rối "vì sao git diff trống sau khi add".
  2. git status --short đọc bằng hai cột XY: X là trạng thái ở index, Y ở working tree — M là đã stage, M là chưa stage, ?? untracked, !! bị ignore (với --ignored).
  3. .gitignore chỉ chặn file chưa theo dõi: file đã git add rồi thì thêm vào .gitignore vô tác dụng (đo thật, vẫn hiện M) — phải git rm --cached mới gỡ khỏi theo dõi. Đây là nguồn gốc tai nạn lỡ commit node_modules/secret.

Nguồn

Phần sau ta dùng chính khả năng snapshot của Git để săn lỗi: git bisect tìm nhị phân commit đầu tiên gây hỏng giữa hàng trăm commit — và ta sẽ tự động hoá nó để máy tự chạy.