git add, git commit — hai lệnh ai cũng gõ hàng chục lần mỗi ngày. Nhưng câu hỏi "git add thực sự làm gì?" và "index/staging là cái gì?" thì ít người trả lời được. Bài này (phần 2 của loạt "Git nội bộ") mổ xẻ ba vùng của Git bằng lệnh thật, làm rõ index thực chất là gì — thứ mà mọi thao tác staging đụng vào. Hiểu nó giúp bạn đọc đúng git status và dùng đúng git reset/git restore.

Ba vùng và hai lệnh chuyển giữa chúng

Git có ba vùng, và chỉ hai lệnh cơ bản để chuyển dữ liệu giữa chúng:

WORKING DIR ──(git add)──> INDEX (staging) ──(git commit)──> REPOSITORY (HEAD)
 file thật            danh sách file sẽ commit           lịch sử bất biến

Điểm hiểu lầm phổ biến: git add không chỉ "đánh dấu" file. Nó ghi blob (như bài trước) và cập nhật index. git commit thì đóng băng toàn bộ index hiện tại thành một commit — commit lưu đúng những gì đang ở index, không phải working directory.

Index thực chất là gì

Index (staging area) là một file nhị phân ở .git/index. Nó không lưu nội dung file — nó lưu một danh sách: mode + SHA blob + path. Xem bằng git ls-files --stage:

$ git ls-files --stage
100644 626799f0f85326a8c1fc522db584e86cdfccd51f 0  a.txt
#  mode    SHA của blob (trỏ tới object store)    stage  path
$ ls -l .git/index   ->  137 byte   (nhị phân, không đọc thẳng)

Vậy index là một "ảnh chụp phẳng" của lần commit sắp tới: mỗi file trỏ tới một blob đã nằm trong object store. Đây là lý do git add phải ghi blob trước — index chỉ trỏ SHA, không giữ nội dung.

Ảnh chụp đoạn mã nền tối minh hoạ ba vùng của Git working directory index staging repository git 3 vùng index là một file nhị phân git add commit chỉ chuyển giữa các vùng, một ba vùng và hai lệnh chuyển working dir git add index staging git commit repository HEAD file thật danh sách file sẽ commit lịch sử bất biến git add không đánh dấu mà ghi blob cộng cập nhật index commit đóng băng index thành 1 commit, hai index thực chất là gì xem bằng git ls-files --stage 100644 626799f 0 a.txt mode SHA của blob trỏ tới object store stage path index lưu SHA blob cộng mode cộng path không lưu nội dung nó là file .git index nhị phân, ba hai lệnh diff soi hai khe khác nhau git diff working vs index thay đổi chưa add git diff --cached index vs HEAD thay đổi đã add sắp commit git status -s 2 cột X index vs HEAD Y working vs index M mới sửa chưa add M đã add MM add rồi sửa tiếp

Hình 1: Ba vùng của Git và hai lệnh git add/git commit chuyển giữa chúng; git ls-files --stage cho thấy index lưu SHA blob + mode + path; và hai lệnh diff soi hai "khe" khác nhau.

Ba vùng lệch nhau: đọc git status và git diff cho đúng

Ta sửa một file nhưng chưa add — working khác index, index vẫn bằng HEAD:

$ echo "v2" > a.txt            # working thay đổi
$ git status -s   ->   M a.txt   (M ở cột 2 = working khác index, CHƯA add)
$ git diff        ->   a.txt | 2 +-   1 file changed   (WORKING vs INDEX)
$ git diff --cached ->  (rỗng)        (INDEX vẫn == HEAD)

Giờ git add — nó ghi blob mới và đổi SHA trong index:

$ git add a.txt
$ git ls-files --stage
100644 8c1384d825dbbe41309b7dc18ee7991a9085c46e 0  a.txt   # blob "v2" — SHA ĐỔI
$ git status -s   ->   M  a.txt   (M ở cột 1 = index khác HEAD, sắp commit)
$ git diff        ->   (rỗng)     (working == index)
$ git diff --cached ->  1 file changed, 1 insertion(+), 1 deletion(-)

Hai điều quan trọng lộ ra. SHA blob trong index đổi từ 626799f (v1) sang 8c1384d (v2) khi add — chứng minh git add thực sự ghi một object mới. Và hai lệnh diff soi hai khe khác nhau: git diff so working vs index (thay đổi chưa add), git diff --cached so index vs HEAD (thay đổi đã add, sắp commit). Đây là lý do nhiều người bối rối "sao git diff rỗng mà vẫn có gì để commit" — vì thay đổi đã ở index rồi.

Ảnh chụp bảng kết quả chạy thật git 2.39 output thật, index bằng SHA blob cộng mode cộng path không có nội dung là file nhị phân git ls-files --stage 100644 626799f 0 a.txt blob v1 ls -l .git index 137 byte nhị phân không đọc thẳng, sửa file v1 sang v2 nhưng chưa add working khác index echo v2 lớn hơn a.txt git status -s M a.txt M ở cột 2 working khác index git diff a.txt 2 dấu cộng trừ 1 file changed working vs index git diff --cached rỗng index vẫn bằng HEAD, git add ghi blob mới cộng cập nhật index SHA trong index đổi git add a.txt git ls-files --stage 100644 8c1384d 0 a.txt blob v2 SHA đổi git status -s M a.txt M ở cột 1 index khác HEAD sắp commit git diff rỗng working bằng index git diff --cached 1 file changed 1 insertion 1 deletion, vì sao hiểu index cứu bạn nguồn Pro Git git add v1 sang v2 đã đổi SHA blob trong index từ 626799f sang 8c1384d staged commit chỉ commit đúng thứ đã add soạn commit gọn tách thay đổi 2 cột status X index vs HEAD Y working vs index đọc đúng mình đang ở đâu git reset đưa thay đổi từ index về lại working unstage hiểu index mới dùng đúng

Hình 2: Chạy thật — index lưu SHA blob (đổi 626799f→8c1384d khi git add); status hai cột ( M=chưa add, M =đã add); git diff so working-vs-index còn --cached so index-vs-HEAD.

Đọc git status hai cột như một chuyên gia

git status -s in hai cột cho mỗi file: cột X = index vs HEAD (đã staged gì), cột Y = working vs index (chưa staged gì). Nhờ đó:

  • M (cách + M): working khác index — đã sửa, chưa add.
  • M (M + cách): index khác HEAD — đã add, sẵn sàng commit.
  • MM: đã add một phiên bản, rồi lại sửa tiếp — index có v2, working có v3. git commit sẽ commit v2, không phải v3.
  • ??: file chưa được Git theo dõi (untracked).

Chỉ cần nhớ "trái = staged, phải = chưa" là bạn đọc được mọi trạng thái.

Đánh đổi cần cân nhắc

Index cho quyền soạn commit gọn — nhưng dễ quên "MM". Sức mạnh của staging là bạn commit đúng thứ đã chọn (thậm chí git add -p add từng đoạn). Cái bẫy: add rồi sửa tiếp thì commit chỉ lấy bản đã add (MM). Nếu định commit trọn thay đổi, nhớ add lại sau khi sửa.

git reset vs git restore — đúng vùng. "Unstage" (đưa từ index về lại working, giữ thay đổi) là git reset HEAD <file> hoặc git restore --staged <file>. Còn "vứt thay đổi ở working" là git restore <file> (mất dữ liệu). Nhầm hai cái là mất việc — hiểu ba vùng giúp chọn đúng lệnh cho đúng vùng.

Một số file lớn trong working không ảnh hưởng index cho tới khi add. Index chỉ chứa thứ được theo dõi/đã add; file untracked (??) không nằm trong index. Đây là lý do .gitignore (bài sau) chỉ tác động tới việc đưa vào index, không đụng file đã theo dõi.

Ba ý mang về

  1. Git có ba vùng — working, index, repository — và git add/git commit chỉ chuyển dữ liệu giữa chúng: git add ghi blob và cập nhật index (đo thật SHA blob trong index đổi 626799f→8c1384d), git commit đóng băng index thành một commit.
  2. Index là một file nhị phân lưu SHA blob + mode + path (không lưu nội dung): xem bằng git ls-files --stage; nó là "ảnh chụp phẳng" của commit sắp tới.
  3. Đọc đúng status/diff nhờ hiểu vùng: git diff = working vs index, git diff --cached = index vs HEAD; status hai cột (trái=staged, phải=chưa) — và chọn đúng reset/restore cho đúng vùng để không mất việc.

Nguồn

Phần sau ta mổ xẻ ref, HEAD và branch — và chứng minh một branch chỉ là một con trỏ 41 byte tới commit, không phải một "bản sao" nặng nề như nhiều người tưởng.