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ưagit 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 HEADso 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)

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:

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 --shortin hai cột cho mỗi file:XY tên-file, vớiX= 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 diffchỉ 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 --cachedngượ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ề
- Mỗi lệnh diff so hai vùng cụ thể:
git diffso working với index (chưa stage),git diff --cachedso index với HEAD (đã stage),git diff HEADso 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ì saogit difftrống sau khi add". git status --shortđọc bằng hai cột XY: X là trạng thái ở index, Y ở working tree —Mlà đã stage,Mlà chưa stage,??untracked,!!bị ignore (với--ignored)..gitignorechỉ chặn file chưa theo dõi: file đãgit addrồi thì thêm vào.gitignorevô tác dụng (đo thật, vẫn hiệnM) — phảigit rm --cachedmới gỡ khỏi theo dõi. Đây là nguồn gốc tai nạn lỡ commitnode_modules/secret.
Nguồn
- Pro Git — Git Basics: Recording Changes to the Repository (status, diff, .gitignore): https://git-scm.com/book/en/v2/Git-Basics-Recording-Changes-to-the-Repository
- Git Documentation — gitignore và git-status: https://git-scm.com/docs/gitignore
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.