Rất nhiều người dùng Git nhiều năm nhưng chưa từng mở thử thư mục .git xem bên trong có gì. Sự thật khá bất ngờ: Git không hề biết "file" hay "thư mục" là gì theo nghĩa thông thường. Với Git, toàn bộ repository chỉ là một cơ sở dữ liệu khoá-giá trị chứa 3 loại đối tượng — Blob, Tree, Commit — và mọi lệnh bạn gõ chỉ là cách tạo đối tượng mới hoặc di chuyển vài con trỏ trỏ vào chúng.

1. Git Không Lưu File — Nó Lưu Đối Tượng

Mỗi khi Git cần lưu một thứ gì đó (nội dung file, cấu trúc thư mục, hay một lần commit), nó tạo ra một đối tượng (object) mới và lưu vào .git/objects. Mỗi đối tượng có một hash làm định danh, và một khi đã tạo, đối tượng đó không bao giờ bị sửa đổi — chỉ có thể tạo đối tượng mới. Có 3 loại đối tượng chính:

  • Blob (Binary Large OBject) — lưu nội dung thô của 1 file.
  • Tree — lưu cấu trúc 1 thư mục: ánh xạ tên → blob hoặc tree con.
  • Commit — lưu 1 lần chụp lịch sử: trỏ tới 1 tree gốc + (các) commit cha.

2. Blob — Đối Tượng Nội Dung Thuần Tuý

Điều quan trọng nhất cần nhớ về blob: nó không lưu tên file. Blob chỉ là nội dung thô của file, không hơn không kém. Hai file tên khác nhau nhưng nội dung giống hệt nhau (vd 2 file LICENSE giống hệt ở 2 thư mục con) sẽ tạo ra đúng 1 blob — vì hash chỉ phụ thuộc nội dung, không phụ thuộc tên hay vị trí:

terminal
# git hash-object tính hash mà KHÔNG cần tên file làm đầu vào
$ echo "Xin chào Git!" | git hash-object --stdin
a1b2c3d4e5f6...  # hash chỉ phụ thuộc nội dung "Xin chào Git!\n"

# Đổi tên file không tạo blob mới — nội dung không đổi, hash không đổi
$ git cat-file -p a1b2c3d4   # xem lại nội dung thô của blob theo hash

3. Tree — Ảnh Chụp Một Thư Mục

Tree là nơi tên file thực sự xuất hiện. Một tree object liệt kê các entry, mỗi entry gồm mode (quyền + loại: file thường, file thực thi, thư mục con...), tên, và hash trỏ tới 1 blob (nếu là file) hoặc 1 tree khác (nếu là thư mục con) — đệ quy hoá toàn bộ cấu trúc thư mục thành một cây các đối tượng:

tree-object.txt
100644 blob a1b2c3d...   README.md
100644 blob 9f8e7d6...   app.js
040000 tree 5c4b3a2...   src        <- thư mục con là 1 TREE khác, đệ quy

4. Commit — Con Trỏ Tới 1 Tree Và (Các) Commit Cha

Commit là lớp mỏng nhất nhưng quan trọng nhất: nó chỉ chứa hash của 1 tree gốc (snapshot toàn bộ repo tại thời điểm đó), hash của (các) commit cha (rỗng nếu là commit đầu tiên, 2 cha nếu là merge commit), cùng tác giả, thời gian, và message. Chuỗi các commit nối với nhau qua con trỏ cha chính là lịch sửgit log hiển thị:

commit-object.txt
tree 7d3f9c1...
parent 4a8b2e0...
author Tang Thanh Quang <[email protected]> 1751500000 +0700
committer Tang Thanh Quang <[email protected]> 1751500000 +0700

Sửa lỗi hiển thị sai ở trang chủ
ℹ️ Lưu ý: Branch chỉ là 1 file trỏ tới hash commit này
Nhìn vào cấu trúc commit ở trên, dễ thấy commit hoàn toàn không "biết" nó thuộc branch nào. Branch (vd .git/refs/heads/main) chỉ là 1 file văn bản chứa đúng 1 dòng: hash của commit mới nhất trên nhánh đó. Đây là nền tảng cho Bài 3 — branch rẻ tới mức tạo hàng trăm nhánh không tốn gì đáng kể.

5. Content-Addressable Storage: Vì Sao Định Danh Bằng Hash?

Git dùng SHA-1 (và đang chuyển dần sang SHA-256) để định danh mọi đối tượng — gọi là content-addressable storage: địa chỉ lưu trữ của 1 đối tượng chính là hash nội dung của nó. Cách chọn này giải quyết đồng thời 3 vấn đề:

  • Chống trùng lặp tự động (deduplication): nội dung giống hệt luôn cho cùng 1 hash → chỉ lưu 1 bản duy nhất, bất kể xuất hiện ở bao nhiêu file/thư mục/commit.
  • Kiểm tra toàn vẹn miễn phí: chỉ cần băm lại nội dung và so hash — phát hiện ngay dữ liệu bị hỏng hoặc bị thay đổi trái phép.
  • So sánh nhanh không cần đọc nội dung: 2 cây thư mục giống hệt nhau sẽ luôn có cùng tree hash — Git có thể bỏ qua toàn bộ cây con mà không cần duyệt sâu vào bên trong.
🔬 Đào sâu: Hash thực chất được tính trên gì?
Git không băm trực tiếp nội dung file — nó băm một chuỗi gồm "<loại> <kích-thước-byte>\0<nội-dung>". Ví dụ blob nội dung "hi\n" (3 byte) sẽ băm chuỗi "blob 3\0hi\n", không phải chỉ "hi\n". Tiền tố loại + kích thước này là lý do 1 blob, 1 tree, và 1 commit không bao giờ va chạm hash dù có thể có cùng "nội dung thô" — chúng thuộc 2 không gian băm khác nhau ngay từ tiền tố.

6. Snapshot, Không Phải Diff

Đây là khác biệt triết lý lớn nhất giữa Git và các hệ quản lý phiên bản cũ hơn (CVS, Subversion): mỗi commit trỏ tới một snapshot ĐẦY ĐỦ của toàn bộ cây thư mục tại thời điểm đó — không phải một "bản vá" (diff) so với commit trước. Khi bạn chạy git log -p và thấy diff đẹp đẽ giữa 2 commit, đó là vì Git tính toán diff đó ngay lúc hiển thị bằng cách so sánh 2 tree, chứ không phải Git đã lưu sẵn diff đó ở đâu.

🕳️ Cạm bẫy thường gặp: "Vậy mỗi commit tốn dung lượng gấp đôi à?"
Không — vì cơ chế content-addressable ở mục 5, nếu 1 file không đổi giữa 2 commit, blob của file đó được tái sử dụng nguyên vẹn giữa 2 tree khác nhau, không hề tốn thêm dung lượng. Chỉ những blob/tree thực sự thay đổi mới cần đối tượng mới. Kết quả là bạn nhận được cả 2 lợi ích: mô hình "snapshot toàn bộ" đơn giản để suy luận, và dung lượng lưu trữ hiệu quả gần như "chỉ lưu phần thay đổi" — mà không cần thuật toán diff phức tạp.

7. So Sánh Blob, Tree, Commit

Đối tượng Lưu gì Trỏ tới Biết tên file?
Blob Nội dung thô 1 file Không trỏ tới đâu (là lá) Không
Tree Danh sách entry (mode + tên + hash) Blob hoặc Tree con
Commit Metadata + message 1 Tree gốc + (các) Commit cha Không (gián tiếp qua tree)

Sân chơi tương tác: Git Object Explorer

Sửa nội dung 2 file bên dưới rồi bấm "Commit Snapshot" nhiều lần. Quan sát: file không đổi → blob được tái sử dụng (không tạo object mới); file đổi → blob mới xuất hiện, nhưng tree gốc luôn phải tạo mới vì nó phụ thuộc hash của mọi blob con. Hash trong demo dùng SHA-1 thật qua Web Crypto API của trình duyệt.

🗂️ Sân chơi tương tác: Git Object Explorer

Working Directory

Tổng commit0
Tổng object đã tạo0

Nhật ký

Click 1 commit trên đồ thị để xem cây tree/blob của nó.

git-object-model-live.js

Trắc nghiệm ôn tập

Câu 1: Vì sao 2 file có tên khác nhau nhưng nội dung giống hệt nhau chỉ tạo ra đúng 1 blob object?

Trắc nghiệm ôn tập

Câu 2: Bạn commit sau khi chỉ sửa 1 trong 5 file. Vì sao tree gốc (root tree) luôn phải tạo object mới, dù 4 blob còn lại không hề thay đổi?

Trắc nghiệm ôn tập

Câu 3: "Git lưu diff (bản vá) giữa các commit, giống SVN/CVS." Nhận định này đúng hay sai?

📖 Tài liệu tham khảo / References

Bài viết liên quan trong series

Bài 2: Three Trees & Staging Quay lại Lộ trình Series Git Series DSA: Hash Table & Va Chạm