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.

Ảnh chụp đoạn mã nền tối minh hoạ lịch sử Git là một DAG commit trỏ cha lưu snapshot không lưu diff commit trỏ parent mỗi commit là ảnh chụp toàn bộ cây file không đổi dùng lại blob, một mỗi commit trỏ tới commit cha tạo đồ thị có hướng không chu trình DAG git cat-file -p HEAD tree 2de8407 ảnh chụp thư mục parent db2414f trỏ ngược tới commit trước commit gốc không có dòng parent commit merge có nhiều dòng parent git rev-list --parents HEAD liệt kê con tới cha, hai snapshot không phải diff file không đổi dùng lại cùng blob commit c1 c2 c3 đều chỉ đổi a.txt giữ nguyên b.txt git ls-tree commit b.txt xem SHA blob b.txt cùng 1 SHA blob qua cả 3 commit Git không nhân bản không lưu diff mỗi commit là ảnh chụp đầy đủ nhưng object không đổi được chia sẻ structural sharing, ba merge tạo commit có hai hoặc nhiều parent git merge --no-ff nhanh git cat-file -p HEAD grep parent 2 dòng parent git log --oneline --graph thấy nhánh tách rồi gộp diff bạn thấy khi git show git diff được Git tính lúc chạy từ 2 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.

Ảnh chụp bảng kết quả chạy thật git 2.39 output thật, DAG commit trỏ tới commit cha git rev-list --parents dd3ee1d parent db2414f db2414f parent 7f4b7e8 7f4b7e8 parent commit gốc không có cha git cat-file -p HEAD tree 2de8407 parent db2414f, snapshot không diff b.txt không đổi blob SHA giống nhau cả 3 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 b.txt cùng 1 blob 657690b qua cả 3 commit Git chia sẻ object không đổi ảnh chụp đầy đủ mà không nhân bản không lưu diff, merge bằng commit có hai parent git merge --no-ff nhanh số dòng parent trong commit merge bằng 2 log graph 8b3c5ef merge nhanh 2ccd88c them c tren nhanh dd3ee1d c3 db2414f c2, vì sao đây là mô hình mạnh nguồn Pro Git bất biến sửa 1 commit cũ tạo chuỗi commit mới SHA đổi hết lịch sử chống giả mạo rẻ về chỗ file không đổi dùng lại blob snapshot không tốn như tưởng merge tự nhiên nhiều parent biểu diễn đúng hai dòng phát triển gộp lại diff động git diff show tính diff lúc chạy từ 2 snapshot không đọc diff lưu sẵn

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ề

  1. 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).
  2. Commit lưu snapshot, KHÔNG lưu diff: đo thật b.txt không đổi dùng lại cùng blob 657690b qua 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.
  3. 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

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.