Skip to content

Zoom-in: Git Commit

Karify98·
Cover Image for Zoom-in: Git Commit

git commit -m "fix bug" — một lệnh. Bên dưới là một cấu trúc dữ liệu bất biến đã tồn tại gần 20 năm mà không cần thay đổi gì lớn. Hiểu nó giải thích tại sao rebase, cherry-pick, merge hoạt động như chúng vốn có — và tại sao "xóa commit" trong Git thực ra không xóa gì cả.

Phóng to dần vào đó.


Layer 1 — Blob: lưu nội dung file

Git không lưu diff. Git lưu snapshot toàn bộ nội dung.

graph LR
    F["📄 hello.txt\n'Hello, world!'"] -->|"SHA-1 hash"| B["🗂️ Blob\nsha: a8c3f..."]
    style F fill:#1e3a5f,stroke:#3b82f6,color:#93c5fd
    style B fill:#3b2a1a,stroke:#f59e0b,color:#fcd34d

Mỗi file được hash bằng SHA-1 (Git mới hơn dùng SHA-256). Kết quả là một blob — object lưu nội dung file, không có tên, không có metadata. Hai file có nội dung giống nhau → cùng một blob, không lưu hai lần.

Nội dung thay đổi → hash thay đổi → blob mới. Blob cũ không bị xóa — Git là append-only bởi thiết kế.

Vấn đề còn lại: blob chỉ lưu nội dung, không biết file tên gì hay nằm ở đâu trong project. Cần một tầng lưu cấu trúc thư mục.

Layer 2 — Tree: lưu cấu trúc thư mục

Tree object là snapshot của một thư mục tại một thời điểm.

graph TD
    T["🌳 Tree (root)\nsha: 9f2a1..."]
    T -->|"README.md"| B1["🗂️ Blob\nsha: a8c3f..."]
    T -->|"src/"| T2["🌳 Tree\nsha: 4d7e8..."]
    T2 -->|"index.ts"| B2["🗂️ Blob\nsha: c91b2..."]
    T2 -->|"utils.ts"| B3["🗂️ Blob\nsha: 7f4a9..."]

    style T fill:#3b2a1a,stroke:#f59e0b,color:#fcd34d
    style T2 fill:#3b2a1a,stroke:#f59e0b,color:#fcd34d
    style B1 fill:#1e3a5f,stroke:#3b82f6,color:#93c5fd
    style B2 fill:#1e3a5f,stroke:#3b82f6,color:#93c5fd
    style B3 fill:#1e3a5f,stroke:#3b82f6,color:#93c5fd

Tree lưu danh sách các entry: tên, permissions, và SHA-1 của blob hoặc tree con. Thư mục lồng nhau = tree trỏ đến tree khác.

Nếu chỉ thay đổi utils.ts, Git tạo blob mới cho utils.ts, tree mới cho src/, tree mới cho root — nhưng index.tsREADME.md vẫn trỏ về blob cũ. Không copy, không duplicate.

Vấn đề còn lại: tree lưu được cấu trúc file tại một thời điểm, nhưng không biết ai tạo, khi nào, và trạng thái trước đó là gì.

Layer 3 — Commit object: metadata và lịch sử

Commit object bọc tree lại và thêm context.

graph LR
    C["📦 Commit\nsha: 1a2b3c...\n---\ntree: 9f2a1...\nparent: 8e4f7...\nauthor: Nam\ndate: 2026-06-25\nmessage: fix bug"]
    C -->|"trỏ đến"| T["🌳 Tree\nsha: 9f2a1..."]
    C -->|"trỏ đến"| P["📦 Parent commit\nsha: 8e4f7..."]

    style C fill:#1a3a2a,stroke:#22c55e,color:#86efac
    style T fill:#3b2a1a,stroke:#f59e0b,color:#fcd34d
    style P fill:#1a3a2a,stroke:#22c55e,color:#86efac

Commit chứa: SHA-1 của tree (snapshot hiện tại), SHA-1 của parent commit (commit trước đó), author, timestamp, và message.

SHA-1 của commit được tính từ toàn bộ nội dung trên — bao gồm cả parent SHA. Thay đổi bất cứ thứ gì (message, timestamp, parent) → SHA-1 của commit thay đổi. Đây là lý do git commit --amend tạo ra một commit mới, không sửa commit cũ.

Vấn đề còn lại: commit là immutable, SHA-1 là định danh. Làm sao biết commit nào là "mới nhất" của một nhánh?

Layer 4 — Branch: chỉ là một con trỏ

Branch không phải container, không phải copy. Branch là một file chứa một SHA-1.

graph LR
    Main["🔖 main\n→ C3"] --> C3["📦 Commit C3\nsha: 1a2b3c"]
    Feature["🔖 feat/login\n→ C4"] --> C4["📦 Commit C4\nsha: 7d9e2f"]
    C3 --> C2["📦 Commit C2\nsha: 8e4f7a"]
    C4 --> C2
    C2 --> C1["📦 Commit C1\nsha: 3c8d1e"]

    style Main fill:#1e3a5f,stroke:#3b82f6,color:#93c5fd
    style Feature fill:#1e3a5f,stroke:#3b82f6,color:#93c5fd
    style C1 fill:#1a3a2a,stroke:#22c55e,color:#86efac
    style C2 fill:#1a3a2a,stroke:#22c55e,color:#86efac
    style C3 fill:#1a3a2a,stroke:#22c55e,color:#86efac
    style C4 fill:#1a3a2a,stroke:#22c55e,color:#86efac

Tạo branch mới: Git tạo một file nhỏ trong .git/refs/heads/ chứa SHA-1 của commit hiện tại. Tốn đúng vài byte. Commit mới: con trỏ branch được cập nhật sang SHA-1 mới.

Điều này giải thích tại sao:

  • Tạo/xóa branch trong Git rất nhanh — chỉ tạo/xóa một file nhỏ
  • git rebase thay đổi parent → toàn bộ chuỗi commit sau đó có SHA-1 mới → lịch sử "viết lại"
  • git cherry-pick tạo commit mới với cùng diff nhưng SHA-1 khác (vì parent khác)
  • "Xóa" commit thực ra chỉ là di chuyển con trỏ — commit cũ vẫn còn trong object store cho đến khi git gc dọn dẹp

Full picture

graph TD
    subgraph "Object Store (.git/objects)"
        B1["🗂️ Blob: hello.txt"]
        B2["🗂️ Blob: index.ts"]
        T1["🌳 Tree: src/"]
        T2["🌳 Tree: root"]
        C1["📦 Commit C1"]
        C2["📦 Commit C2"]
    end

    subgraph "Refs (.git/refs)"
        Main["🔖 main → C2"]
        HEAD["👁️ HEAD → main"]
    end

    HEAD --> Main --> C2
    C2 -->|"tree"| T2
    C2 -->|"parent"| C1
    T2 -->|"src/"| T1
    T1 -->|"index.ts"| B2
    T2 -->|"hello.txt"| B1

    style B1 fill:#1e3a5f,stroke:#3b82f6,color:#93c5fd
    style B2 fill:#1e3a5f,stroke:#3b82f6,color:#93c5fd
    style T1 fill:#3b2a1a,stroke:#f59e0b,color:#fcd34d
    style T2 fill:#3b2a1a,stroke:#f59e0b,color:#fcd34d
    style C1 fill:#1a3a2a,stroke:#22c55e,color:#86efac
    style C2 fill:#1a3a2a,stroke:#22c55e,color:#86efac
    style Main fill:#1e3a5f,stroke:#3b82f6,color:#93c5fd
    style HEAD fill:#1e3a5f,stroke:#3b82f6,color:#93c5fd

Takeaway

Git là một content-addressed object store. Blob lưu nội dung, tree lưu cấu trúc, commit lưu snapshot và lịch sử, branch là con trỏ. SHA-1 là định danh bất biến — thay đổi bất cứ thứ gì đều tạo object mới.

Điều này có hệ quả thực tế: rebase an toàn trên branch cá nhân nhưng nguy hiểm trên branch được share vì nó viết lại SHA-1 của mọi commit — người khác đang dùng SHA-1 cũ sẽ bị conflict. merge giữ nguyên lịch sử, rebase làm lịch sử tuyến tính hơn. Cả hai đều đúng — tùy ngữ cảnh.


Bài viết được hỗ trợ bởi Amy 🌸 - AI Assistant. Nội dung đã được kiểm duyệt bởi tác giả.

Related Posts