Git hoạt động như thế nào
Xem thửTình huống: repo 400 MB và nỗi sợ tạo nhánh
Trưởng nhóm hạ tầng của GDev cấm tạo nhánh cho từng ticket. Lý do nghe có vẻ hợp lý: repo infra nặng 400 MB, mỗi nhánh chắc là một bản sao, ba mươi nhánh thì ổ đĩa của runner cạn. Anh còn nói thêm: Git lưu sự khác biệt giữa các phiên bản, nên sửa file phải được "áp dụng lại" từng lần, vì thế lịch sử dài thì chậm.
Cả hai câu đều sai, và cách duy nhất để thuyết phục không phải tranh luận mà là mở thư mục .git ra xem. Bài này làm đúng việc đó: tạo từng object bằng tay, đọc từng file, và đo.
Sau bài này bạn làm được các việc sau:
- Mô tả bốn loại object của Git (blob, tree, commit, tag) và chứng minh bằng lệnh rằng mã băm được tính từ nội dung.
- Giải thích ba khu vực (thư mục làm việc, index, kho) và dự đoán đúng thứ gì đi vào commit khi bạn sửa file sau
git add. - Bác bỏ hai hiểu lầm bằng số liệu từ chính repo của bạn: "Git lưu diff" và "nhánh là bản sao".
Bạn cần có sẵn: Git từ 2.28 trở lên và danh tính đã cấu hình (bài trước). Bài này dùng lệnh git add và git commit ở mức cơ bản; bài lệnh Git cơ bản sẽ đi sâu vào chu trình hằng ngày.
Ba mô hình cần nhớ
- Git là kho khóa và giá trị, khóa là mã băm của nội dung: ghi cùng nội dung thì ra cùng khóa, đổi một byte thì ra khóa khác. Giá trị là một trong bốn loại object.
- Commit là ảnh chụp (snapshot) cộng với con trỏ tới commit cha: ảnh chụp là một tree gốc, các commit nối với nhau qua mã băm của cha nên sửa lịch sử là đổi mã băm của mọi commit phía sau.
- Nhánh, tag và HEAD chỉ là con trỏ, còn index là bản nháp của commit kế tiếp: tạo con trỏ rẻ như ghi một file 41 byte, và commit chỉ lấy đúng thứ nằm trong index.
Chuẩn bị môi trường lab
Mọi lệnh chạy trên Linux hoặc macOS với Git 2.28 trở lên (kết quả chạy với Git 2.54.0). Mã băm của blob và tree sẽ giống hệt máy bạn vì chúng chỉ phụ thuộc nội dung. Mã băm của commit sẽ khác vì commit chứa tên, email và giờ của bạn.
mkdir -p ~/gdev-labs/objects && cd ~/gdev-labs/objects
git init -b main
git config user.name "GDev Lab"
git config user.email "lab@gdev.local"
ls -F .git
find .git/objects -type f | wc -l
Initialized empty Git repository in /root/gdev-labs/objects/.git/
HEAD
config
description
hooks/
info/
objects/
refs/
0
Một kho trống chỉ có vài thành phần. Hai thứ quan trọng nhất ở đây: objects/ (nơi chứa dữ liệu, hiện có 0 file) và HEAD cùng refs/ (nơi chứa con trỏ). Mọi thứ còn lại trong bài đều là cách hai nơi này được lấp đầy.
Object đầu tiên: blob và mã băm tính từ nội dung
Dự đoán trước khi chạy: bạn tạo hai file có tên khác nhau nhưng cùng nội dung hello. Git sẽ lưu mấy object?
Ta bắt đầu bằng lệnh cấp thấp git hash-object, thứ tính mã băm của một file mà chưa ghi gì vào kho:
printf 'hello\n' > hello.txt
git hash-object hello.txt
printf 'blob 6\0hello\n' | sha1sum
ce013625030ba8dba906f756967f9e9ca394464a
ce013625030ba8dba906f756967f9e9ca394464a -
Hai dòng giống nhau. Dòng thứ hai là phép tính thủ công: ghép chữ blob, dấu cách, độ dài nội dung (6 byte), một byte rỗng, rồi nội dung, và băm bằng SHA-1. Đây chính là công thức của Git, không có gì bí mật. Tên file không tham gia phép tính. Vì thế câu trả lời cho câu dự đoán là một object.
Thêm tham số -w để ghi object vào kho, rồi đọc lại bằng git cat-file:
git hash-object -w hello.txt
find .git/objects -type f
git cat-file -t ce013625030ba8dba906f756967f9e9ca394464a
git cat-file -s ce013625030ba8dba906f756967f9e9ca394464a
git cat-file -p ce013625030ba8dba906f756967f9e9ca394464a
ce013625030ba8dba906f756967f9e9ca394464a
.git/objects/ce/013625030ba8dba906f756967f9e9ca394464a
blob
6
hello
Đọc kết quả: Git lưu object thành một file có đường dẫn là hai ký tự đầu của mã băm làm thư mục, 38 ký tự còn lại làm tên file. Loại là blob (nội dung của một file, không kèm tên), kích thước 6 byte, nội dung là hello. Chỉ có một object nằm trong kho, và nó không biết mình tên hello.txt.
Về mã băm: Git 2.54 vẫn dùng SHA-1 mặc định (kiểm tra bằng git rev-parse --show-object-format, in sha1). Theo tài liệu Breaking Changes của Git, mặc định cho repo mới dự kiến đổi sang SHA-256 ở bản Git 3.0, nhưng điều kiện là hệ sinh thái (thư viện, máy chủ Git) đã hỗ trợ. Mọi lệnh trong bài vẫn đúng với cả hai, chỉ khác độ dài mã băm.
Index, tree và commit: từ file tới lịch sử
Blob chỉ chứa nội dung. Tên file, thư mục và quyền nằm ở tree. Danh sách thứ sẽ vào commit kế tiếp nằm ở index (còn gọi là staging area). Hãy tạo thêm một thư mục cấu hình rồi đưa vào index:
mkdir deploy && printf 'replicas: 2\nimage: gdev/api:1.0\n' > deploy/app.yaml
git add hello.txt deploy
git ls-files -s
git cat-file --batch-all-objects --batch-check
100644 1967996281cd24ecd2ba1e90eaa7d5f10907b2a5 0 deploy/app.yaml
100644 ce013625030ba8dba906f756967f9e9ca394464a 0 hello.txt
1967996281cd24ecd2ba1e90eaa7d5f10907b2a5 blob 32
ce013625030ba8dba906f756967f9e9ca394464a blob 6
git ls-files -s in nội dung index: quyền file (100644), mã băm blob, số giai đoạn (0, khác 0 khi có xung đột merge) và đường dẫn. Dòng cuối của khối là điều đáng chú ý: ngay sau git add, blob của app.yaml đã nằm trong kho, nhưng chưa có tree hay commit nào. Lúc này app.yaml tồn tại như một object mồ côi, chưa ai trỏ tới. Ghi nhớ điều này, vì nó giải thích lỗi ở phần Failure lab.
Giờ tạo commit và liệt kê lại kho:
git commit -m "feat: add hello and deploy config"
git cat-file --batch-all-objects --batch-check
git cat-file -p HEAD
git cat-file -p 'HEAD^{tree}'
git cat-file -p HEAD:deploy
Kết quả minh họa (mã băm của commit và dòng author của bạn sẽ khác):
[main (root-commit) 1065291] feat: add hello and deploy config
2 files changed, 3 insertions(+)
create mode 100644 deploy/app.yaml
create mode 100644 hello.txt
106529182d6def9081d96f6f3c1a9c888c80bfb8 commit 184
1967996281cd24ecd2ba1e90eaa7d5f10907b2a5 blob 32
2341aaf0bb9bad0c5b1e28b296e6d95539c92a71 tree 36
6a8b671004620e39aa0ffb820b9117d80051d496 tree 70
ce013625030ba8dba906f756967f9e9ca394464a blob 6
tree 6a8b671004620e39aa0ffb820b9117d80051d496
author GDev Lab <lab@gdev.local> 1792799320 +0000
committer GDev Lab <lab@gdev.local> 1792799320 +0000
feat: add hello and deploy config
040000 tree 2341aaf0bb9bad0c5b1e28b296e6d95539c92a71 deploy
100644 blob ce013625030ba8dba906f756967f9e9ca394464a hello.txt
100644 blob 1967996281cd24ecd2ba1e90eaa7d5f10907b2a5 app.yaml
Sau commit kho có năm object. Đọc từng loại:
- Hai blob đã có từ trước (nội dung
hellovàapp.yaml). - Hai tree: tree gốc (
6a8b67..., 70 byte) liệt kêdeploy(một tree con) vàhello.txt(một blob); treedeploy(2341aa...) liệt kêapp.yaml. Tree là thứ gắn tên cho các blob. - Một commit: trỏ tới tree gốc, ghi tác giả, người commit, giờ (số giây từ năm 1970) và thông điệp. Commit đầu tiên không có dòng
parent.
Khi tạo commit thứ hai, Git thêm dòng parent mang mã băm của commit trước. Đó là sợi dây nối cả lịch sử.
Cuối cùng là các con trỏ:
cat .git/HEAD
cat .git/refs/heads/main
git rev-parse HEAD
ref: refs/heads/main
106529182d6def9081d96f6f3c1a9c888c80bfb8
106529182d6def9081d96f6f3c1a9c888c80bfb8
HEAD là một file chứa chữ ref: refs/heads/main: "tôi đang ở nhánh main". File refs/heads/main chỉ chứa mã băm của commit mới nhất. Tạo commit mới nghĩa là Git ghi object mới rồi sửa nội dung file này sang mã băm mới.
Nhìn toàn cảnh: đồ thị object
Dự đoán: nếu bạn commit lần nữa chỉ để thêm một bản sao của hello.txt tên hello-copy.txt, đồ thị object tăng thêm gì: một blob mới, một tree mới, hay cả hai?
Đang tải sơ đồ…
Cách đọc sơ đồ, với commit C2 là commit thêm hello-copy.txt:
- Chuỗi
main,C2, rồiparenttớiC1là lịch sử.mainchỉ là một con trỏ vào đầu chuỗi. - Mỗi commit có đúng một tree gốc. C1 và C2 có hai tree gốc khác nhau vì danh sách tên file khác nhau (C2 có thêm một dòng).
- Hai mũi tên cùng đi vào blob
hello: filehello.txtvà filehello-copy.txtdùng chung một blob. Vì mã băm là của nội dung, Git không lưuhellohai lần. - Cả hai tree gốc cùng trỏ vào tree
deployvà blobapp.yaml, vì thư mục này không đổi giữa hai commit. Git chỉ tạo object mới cho thứ đã thay đổi, phần còn lại được tái sử dụng bằng cách trỏ lại.
Vậy đáp án cho câu dự đoán là một tree mới và một commit mới, không có blob mới. Chạy để kiểm chứng:
cp hello.txt hello-copy.txt && git add hello-copy.txt && git ls-files -s
git cat-file --batch-all-objects --batch-check | wc -l
git commit -q -m "chore: duplicate file"
git cat-file -p 'HEAD^{tree}'
git cat-file -p HEAD
100644 1967996281cd24ecd2ba1e90eaa7d5f10907b2a5 0 deploy/app.yaml
100644 ce013625030ba8dba906f756967f9e9ca394464a 0 hello-copy.txt
100644 ce013625030ba8dba906f756967f9e9ca394464a 0 hello.txt
5
040000 tree 2341aaf0bb9bad0c5b1e28b296e6d95539c92a71 deploy
100644 blob ce013625030ba8dba906f756967f9e9ca394464a hello-copy.txt
100644 blob ce013625030ba8dba906f756967f9e9ca394464a hello.txt
tree df7b23b14dfa134cb40b115d7864222ef1c995fa
parent 106529182d6def9081d96f6f3c1a9c888c80bfb8
author GDev Lab <lab@gdev.local> 1792799320 +0000
committer GDev Lab <lab@gdev.local> 1792799320 +0000
chore: duplicate file
Hai dòng hello.txt và hello-copy.txt mang cùng một mã ce0136.... Kho vẫn có 5 object ngay sau git add (tree và commit mới chưa được tạo cho tới lúc commit), và dòng parent đã xuất hiện, nối C2 vào C1. Tính chất này cũng là lý do Git phát hiện được việc đổi tên file: hai đường dẫn khác nhau trỏ vào cùng một blob.
Hiểu lầm 1: "Git lưu diff"
Hãy đo thay vì tin. Tạo một file 1000 dòng, commit, đổi đúng một dòng, commit lần nữa:
seq 1 1000 > big.txt && git add big.txt && git commit -q -m "feat: add big.txt"
sed -i 's/^500$/five-hundred/' big.txt && git commit -q -am "fix: change line 500"
git diff --stat HEAD~1 HEAD
git rev-parse HEAD~1:big.txt HEAD:big.txt
git cat-file -s HEAD~1:big.txt
git cat-file -s HEAD:big.txt
git cat-file -p HEAD~1:big.txt | sed -n 499,501p
Trên macOS thay sed -i bằng sed -i ''. Kết quả thật:
big.txt | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
1179824569dcb14413904cb2b5cb036a9551024d
88c32cfeecb8637c2fa8d6f4d527752f11d1f153
3893
3902
499
500
501
Phân tích:
git diffbáo một dòng đổi. Đó là thứ Git tính ra khi bạn hỏi, không phải thứ nó lưu.- Hai phiên bản có hai blob khác nhau, mỗi blob có kích thước đầy đủ của file (3893 và 3902 byte). Git lưu cả hai như hai bản chụp hoàn chỉnh.
- Đọc phiên bản cũ ra dòng
500ngay lập tức, không cần "áp dụng ngược" thay đổi nào. Đó là lý do checkout một commit cổ vẫn nhanh.
Mô hình lưu logic là snapshot. Còn dưới đáy, Git có tối ưu lưu trữ gọi là packfile: khi bạn chạy git gc (hoặc Git tự chạy, hoặc lúc push), nó gom nhiều object vào một file và nén các object giống nhau bằng delta. Hãy xem:
git count-objects -v | grep -E '^(count|in-pack|packs)'
git gc -q
git count-objects -v | grep -E '^(count|in-pack|packs)'
git verify-pack -v .git/objects/pack/*.idx | grep blob
count: 13
in-pack: 0
packs: 0
count: 0
in-pack: 13
packs: 1
88c32cfeecb8637c2fa8d6f4d527752f11d1f153 blob 3902 1863 612
1967996281cd24ecd2ba1e90eaa7d5f10907b2a5 blob 32 42 2475
ce013625030ba8dba906f756967f9e9ca394464a blob 6 15 2517
1179824569dcb14413904cb2b5cb036a9551024d blob 16 28 2851 1 88c32cfeecb8637c2fa8d6f4d527752f11d1f153
Trước gc, 13 object nằm rời (loose, mỗi object một file). Sau gc, cả 13 nằm trong một pack. Trong bảng verify-pack, các cột là mã băm, loại, kích thước thật, kích thước trong pack, vị trí, rồi (chỉ với delta) độ sâu chuỗi và mã băm của gốc. Dòng cuối là blob cũ 1179...: kích thước thật 3893 byte nhưng trong pack chỉ tốn 28 byte, vì nó được lưu thành delta dựa trên blob mới 88c3.... Delta không bắt buộc đi từ cũ sang mới, Git tự chọn gốc sao cho nhỏ nhất.
Kết luận hai tầng: về mô hình dữ liệu, Git lưu snapshot và đọc phiên bản nào cũng nhanh như nhau. Về lưu trữ vật lý, Git nén bằng delta nhưng đó là tối ưu ở tầng dưới, bạn không bao giờ phải "áp dụng lại" thay đổi bằng tay.
Hiểu lầm 2: "nhánh là bản sao thư mục"
git branch feature/readyz
cat .git/refs/heads/feature/readyz
wc -c .git/refs/heads/feature/readyz
git count-objects -v | grep -E '^(count|in-pack)'
git log --graph --oneline --all --decorate
Kết quả minh họa (mã băm commit của bạn sẽ khác):
6b44656f9e439b2075d517fa3d1de5ea582f6318
41 .git/refs/heads/feature/readyz
count: 0
in-pack: 13
* 6b44656 (HEAD -> main, feature/readyz) fix: change line 500
* a4ef499 feat: add big.txt
* d0ae464 chore: duplicate file
* 1065291 feat: add hello and deploy config
Tạo nhánh chỉ ghi một file 41 byte: 40 ký tự mã băm cộng một ký tự xuống dòng. Số object trong kho không đổi (13), nghĩa là không có file nào được sao chép. git log --graph cho thấy hai nhánh đang trỏ vào cùng một commit.
Vậy câu hỏi của trưởng nhóm có đáp án rõ: ba mươi nhánh cho ba mươi ticket tốn khoảng 30 file 41 byte, cộng với những object thật sự mới mà mỗi ticket tạo ra. Dung lượng của repo tăng theo lượng nội dung khác nhau, không theo số nhánh.
Bài này dùng git log --graph ở dạng văn bản thay vì sơ đồ. Cách đọc: mỗi dấu * là một commit, đường dọc nối tới commit cha, và chữ trong ngoặc là các con trỏ đang trỏ vào commit đó.
Tới giờ bạn đã thấy từng bộ phận. Hãy xem chúng chuyển động cùng nhau. Dự đoán trước khi bấm: sau khi bạn git add một file rồi sửa tiếp nó trước khi git commit, commit sẽ chứa phiên bản nào?
Đang tải Animation…
Failure lab: sửa file sau git add, commit thiếu thứ bạn tưởng
Đây chính là kịch bản trong animation, giờ chạy thật. Lan tăng replicas lên 3, add, rồi thêm debug: true và commit:
sed -i 's/replicas: 2/replicas: 3/' deploy/app.yaml && git add deploy/app.yaml && echo 'debug: true' >> deploy/app.yaml
git status -s
git ls-files -s deploy/app.yaml
git hash-object deploy/app.yaml
git commit -q -m "feat: scale to 3"
git status -s
git show HEAD:deploy/app.yaml
git diff
Kết quả minh họa (mã băm blob phụ thuộc nội dung nên sẽ giống máy bạn, commit thì không):
MM deploy/app.yaml
100644 73bf1dc3b760517ef100340f13ecf25ca7014045 0 deploy/app.yaml
47608427a5094bf30c4d5ab277006e6dc53a347d
M deploy/app.yaml
replicas: 3
image: gdev/api:1.0
diff --git a/deploy/app.yaml b/deploy/app.yaml
index 73bf1dc..4760842 100644
--- a/deploy/app.yaml
+++ b/deploy/app.yaml
@@ -1,2 +1,3 @@
replicas: 3
image: gdev/api:1.0
+debug: true
Triệu chứng: sau commit, git status -s vẫn báo M, nghĩa là file còn thay đổi chưa commit, và git show HEAD:deploy/app.yaml không có dòng debug: true.
Nguyên nhân: hai chữ MM ở lệnh đầu cho biết file có thay đổi ở cả hai nơi: chữ M thứ nhất là chênh lệch giữa index và commit hiện tại, chữ M thứ hai là chênh lệch giữa thư mục làm việc và index. git ls-files -s cho mã băm blob trong index là 73bf1dc..., còn git hash-object trên file thật cho 4760842.... Hai mã khác nhau, nên hai nội dung khác nhau. Commit lấy blob 73bf1dc... từ index.
Cách kiểm chứng: so hai mã băm như trên, hoặc git diff (so thư mục làm việc với index). Cách sửa: git add lại rồi commit bổ sung, hoặc dùng git commit -a từ đầu, lệnh này tự stage mọi file đã được theo dõi trước khi commit. Với DevOps, thói quen an toàn là chạy git status và git diff --staged ngay trước git commit, bài lệnh Git cơ bản sẽ luyện kỹ.
Lỗi thường gặp
| Hiểu sai hoặc triệu chứng | Sự thật | Cách kiểm chứng |
|---|---|---|
| Git lưu diff nên lịch sử dài thì chậm | Git lưu snapshot; delta chỉ là nén vật lý trong packfile | git cat-file -s hai phiên bản, git verify-pack -v |
| Mỗi nhánh là một bản sao thư mục | Nhánh là file 41 byte chứa mã băm commit | cat .git/refs/heads/<tên>, git count-objects -v |
| Đổi tên file sẽ nhân đôi dữ liệu | Hai tên cùng nội dung dùng chung một blob | git ls-files -s, so mã băm |
| Commit chứa mọi thứ đang thấy trong thư mục | Commit chỉ lấy index | git status -s có MM, so ls-files -s với hash-object |
HEAD là một commit | HEAD thường là tham chiếu tới tên nhánh | cat .git/HEAD |
| Xóa file khỏi commit mới là xóa khỏi lịch sử | Commit cũ vẫn chứa blob cũ | git show <commit cũ>:<đường dẫn> |
Điều cuối là hệ quả quan trọng cho bảo mật: một bí mật đã từng vào commit vẫn đọc được từ commit cũ. Bài Giới thiệu GitHub trình bày cách xử lý.
Tự kiểm tra
Hãy trả lời trước khi đọc đáp án ngay bên dưới mỗi câu.
Câu 1. Bạn đổi tên deploy/app.yaml thành deploy/api.yaml, không sửa nội dung, rồi commit. Có blob mới nào không, và tree nào đổi?
Đáp án: Không có blob mới, vì nội dung và do đó mã băm blob không đổi. Tree deploy đổi vì tên trong danh sách đổi, kéo theo tree gốc và commit mới.
Câu 2. Hai commit liền kề khác nhau đúng một byte trong một file. Tính từ blob trở lên, những loại object nào có mã băm khác nhau?
Đáp án: Blob của file đó, tree chứa nó (và mọi tree cha tới tree gốc) và commit. Đó là lý do sửa một commit cũ làm đổi mã băm của mọi commit sau nó.
Câu 3. Trưởng nhóm lo tạo 30 nhánh làm repo 400 MB phình lên 12 GB. Bạn trả lời bằng thông tin nào?
Đáp án: Mỗi nhánh là một file 41 byte chứa mã băm commit, không sao chép file nào. Dung lượng tăng theo lượng nội dung mới, không theo số nhánh.
Nếu chỉ nhớ 3 điều
- Git lưu object theo mã băm của nội dung: blob (nội dung), tree (tên và cấu trúc), commit (tree cộng cha cộng thông điệp).
- Nhánh, tag và HEAD là con trỏ nhỏ; tạo nhánh gần như không tốn gì, còn lịch sử là chuỗi commit nối bằng mã cha.
- Commit lấy từ index chứ không từ thư mục làm việc: kiểm tra
git statusvàgit diff --stagedtrước khi commit.
Bài tiếp theo đưa mã lên một remote để cả đội cùng làm việc, với bài học về xác thực và về bí mật bị lộ.
Tài liệu chính thức: Pro Git, Git Objects, Git References và Packfiles.