Một hiểu lầm phổ biến: "Git lưu các thay đổi (diff) giữa các phiên bản". Nghe hợp lý, nhưng sai — và hiểu đúng giúp bạn suy luận về merge, rebase, cherry-pick sau này. Sự thật: mỗi commit lưu một ảnh chụp (snapshot) toàn bộ cây, và trỏ tới commit cha, tạo thành một đồ thị có hướng không chu trình (DAG). Bài này (phần 4 loạt "Git nội bộ") chứng minh cả hai bằng lệnh thật.
Commit trỏ tới cha → một DAG
Bài trước ta thấy branch chỉ là con trỏ tới commit mới nhất. Vậy các commit cũ hơn nối với nhau ra sao? Mỗi commit chứa (một hoặc nhiều) con trỏ parent trỏ ngược về commit trước:
$ git cat-file -p HEAD
tree 2de8407... # ảnh chụp thư mục tại commit này
parent db2414f... # trỏ NGƯỢC tới commit trước
$ git rev-list --parents HEAD
dd3ee1d -> parent db2414f
db2414f -> parent 7f4b7e8
7f4b7e8 -> parent # commit gốc, không có cha
Vì mỗi commit chỉ trỏ về quá khứ (không bao giờ trỏ tới tương lai), đồ thị không có chu trình — đó là "acyclic". Commit gốc không có parent; commit thường có một; commit merge có nhiều parent.
Snapshot, không phải diff: bằng chứng blob dùng lại
Đây là điểm dễ hiểu sai nhất. Ta tạo 3 commit, mỗi lần chỉ đổi a.txt, giữ nguyên b.txt, rồi xem SHA blob của từng file ở mỗi commit:
commit 7f4b7e8: a.txt=0dd77d7 b.txt=657690b
commit db2414f: a.txt=493c465 b.txt=657690b
commit dd3ee1d: a.txt=768641e b.txt=657690b
a.txt đổi mỗi commit (SHA khác nhau), nhưng b.txt cùng một blob 657690b qua cả ba commit. Nếu Git lưu diff, sẽ không có khái niệm "cùng blob". Điều xảy ra: mỗi commit đúng là một ảnh chụp đầy đủ (tree trỏ tới cả a.txt và b.txt), nhưng các object không đổi được chia sẻ (structural sharing) — nên snapshot đầy đủ mà không nhân bản và không lưu diff. Diff bạn thấy khi git show/git diff là Git tính ra lúc chạy từ hai snapshot.

Hình 1: Commit trỏ tới parent (tạo DAG); mỗi commit là snapshot toàn bộ cây; file không đổi (b.txt) dùng lại cùng blob qua nhiều commit; merge tạo commit nhiều parent.
Merge = commit có nhiều parent
Khi gộp hai nhánh, Git tạo một commit merge có hai con trỏ parent — biểu diễn đúng "hai dòng phát triển gộp lại":
$ git merge --no-ff nhanh
$ git cat-file -p HEAD | grep -c parent -> 2
$ git log --oneline --graph
* 8b3c5ef merge nhanh
|\
| * 2ccd88c them c tren nhanh
* | dd3ee1d c3
|/
* db2414f c2
Commit merge 8b3c5ef có hai parent (dd3ee1d của main và 2ccd88c của nhanh). Cấu trúc DAG cho phép biểu diễn tự nhiên lịch sử phân nhánh và hợp nhất — điều một chuỗi tuyến tính không làm được.

Hình 2: Chạy thật — DAG parent pointers; b.txt cùng blob 657690b qua 3 commit (snapshot + chia sẻ object, không lưu diff); merge commit có 2 parent.
Vì sao mô hình DAG + snapshot lại mạnh
- Bất biến & chống giả mạo: vì SHA commit băm cả tree lẫn parent, sửa một commit cũ đổi SHA của nó → đổi luôn mọi commit sau (chúng trỏ parent bằng SHA). Không thể lén sửa lịch sử mà không đổi toàn bộ SHA phía sau — đây là nền của tính toàn vẹn lịch sử.
- Rẻ về chỗ dù là snapshot: nhờ chia sẻ blob không đổi (và packfile nén delta ở bài sau), lưu snapshot mỗi commit không tốn như trực giác lo.
- Merge & lịch sử phi tuyến tự nhiên: nhiều parent biểu diễn đúng việc nhánh tách và gộp; các thao tác như
git merge-base,git log --graphđều dựa trên cấu trúc DAG này.
Đánh đổi cần cân nhắc
Diff được tính lúc chạy — nhanh cho file thường, chậm cho file khổng lồ. Vì Git không lưu diff sẵn, git log -p trên lịch sử dài hoặc file rất lớn phải tính nhiều diff, có thể chậm. Với repo lớn, git log không có -p nhanh hơn nhiều; và tránh commit file nhị phân khổng lồ (mỗi phiên bản là blob mới toàn phần).
Sửa lịch sử = tạo lịch sử mới, không phải "chỉnh chỗ cũ". git commit --amend, rebase không sửa commit cũ mà tạo commit mới với SHA khác (bài rebase sẽ đo). Điều này an toàn (commit cũ vẫn còn tới khi bị GC) nhưng nghĩa là đừng rebase nhánh người khác đang dùng — SHA đổi hết.
DAG có thể rối nếu merge bừa. Sức mạnh biểu diễn phân nhánh cũng là con dao hai lưỡi: lịch sử với hàng trăm merge chồng chéo khó đọc. Nhiều đội chọn quy ước (rebase trước khi merge, hoặc squash) để giữ DAG sạch — đó là lựa chọn quy trình, không phải giới hạn của Git.
Ba ý mang về
- Lịch sử Git là một DAG: mỗi commit trỏ ngược tới (các) commit cha — đo thật chuỗi
dd3ee1d → db2414f → 7f4b7e8(gốc không cha); commit merge có nhiều parent (đo thật = 2). - Commit lưu snapshot, KHÔNG lưu diff: đo thật
b.txtkhông đổi dùng lại cùng blob657690bqua cả 3 commit — Git chia sẻ object không đổi nên snapshot đầy đủ mà không nhân bản; diff được tính lúc chạy. - Mô hình này cho toàn vẹn và merge tự nhiên: SHA băm cả parent nên sửa commit cũ đổi mọi SHA sau (chống giả mạo); nhiều parent biểu diễn đúng nhánh tách/gộp — nền cho merge, rebase, cherry-pick ở các bài sau.
Nguồn
- Pro Git — Git Internals: Git Objects (commit & tree): https://git-scm.com/book/en/v2/Git-Internals-Git-Objects
- Git Documentation — git-rev-list / gitrevisions (DAG traversal): https://git-scm.com/docs/git-rev-list
Phần sau ta mổ xẻ merge ba chiều (3-way merge): Git tìm commit tổ tiên chung (merge base) rồi hoà giải thay đổi từ hai phía thế nào, và vì sao xung đột xảy ra.