0

Git as a Database: What git-bug Teaches Us About Storing Data in Refs

Tuần này git-bug lên trang nhất Hacker News. Đây là một bug tracker phân tán, ưu tiên làm việc offline, và lưu toàn bộ issue ngay trong repo Git. Không cần server, không cần database, cũng không phải đồng bộ với Jira. Bạn git push là issue được đẩy lên theo. Lần đầu đọc thì mình nghĩ đó là một hack cho vui. Đọc kỹ source rồi mình mới thấy ý tưởng bên dưới khá hay: Git thực chất là một content-addressable database có sẵn cơ chế replication. Phần lớn developer mới chỉ dùng khoảng 10% khả năng của nó.

Bài này không nhằm giới thiệu git-bug. Mình muốn mổ xẻ kỹ thuật nó dùng, tức là lưu dữ liệu tùy ý vào custom refs, để bạn tự áp dụng cho việc của mình: lưu metadata của CI, review note, audit log, hay cấu hình deploy gắn với từng commit.

Git bên dưới: một object store cộng với các con trỏ

Trước hết cần quên git add và git commit đi một chút. Ở tầng dưới, Git chỉ có 4 loại object: blob (nội dung file), tree (danh sách file), commit (tree + parent + message) và tag. Mỗi object được định danh bằng hash của chính nội dung nó (SHA-1, hoặc SHA-256 nếu repo bật --object-format=sha256).

Một ref chỉ là một file text chứa hash, trỏ tới một commit. refs/heads/main là branch, refs/tags/v1.0 là tag. Điểm mấu chốt là Git không bắt buộc ref phải nằm trong heads hay tags. Bạn tạo refs/issues/123 hay refs/whatever/abc đều được. Những ref này không hiện trong git branch, không làm rối working tree, nhưng vẫn push, fetch và garbage collect như mọi object khác.

graph LR
    A[refs/heads/main] --> C1[commit: code]
        B[refs/bugs/7f3a] --> C2[commit: op 2]
            C2 --> C3[commit: op 1]
                C2 --> T[tree]
                    T --> O[blob: ops.json]
                        C1 --> T2[tree: source code]
                        ```
                        
                        git-bug làm đúng như vậy. Mỗi bug là một ref `refs/bugs/<id>`. Mỗi lần có thay đổi như tạo bug, thêm comment hay đổi label, nó sinh ra một commit mới chứa các **operation** dạng JSON, với parent là commit trước đó. Nói cách khác, mỗi issue là một **event log bất biến**, và Git gánh luôn phần lưu trữ, dedup lẫn đồng bộ.
                        
                        ## Tự tay làm với plumbing commands
                        
                        Bạn thử ngay trên một repo bất kỳ (Git 2.30+ là đủ). Cả đoạn dưới đây không đụng tới working tree hay index:
                        
                        ```bash
                        # 1. Ghi một operation vào object store dưới dạng blob
                        BLOB=$(echo '{"op":"create","title":"Login bị timeout","author":"andy"}' \
                          | git hash-object -w --stdin)
                          
                          # 2. Tạo tree chứa blob đó
                          TREE=$(printf "100644 blob %s\tops.json\n" "$BLOB" | git mktree)
                          
                          # 3. Tạo commit (chưa có parent vì đây là event đầu tiên)
                          COMMIT=$(git commit-tree "$TREE" -m "create issue")
                          
                          # 4. Gắn một ref tùy chỉnh vào commit
                          git update-ref refs/issues/login-timeout "$COMMIT"
                          
                          # Kiểm tra
                          git for-each-ref refs/issues/
                          git show refs/issues/login-timeout:ops.json
                          ```
                          
                          Để thêm event thứ hai, bạn tạo commit mới với `-p` trỏ về commit cũ:
                          
                          ```bash
                          PARENT=$(git rev-parse refs/issues/login-timeout)
                          BLOB=$(echo '{"op":"comment","body":"Repro được trên staging"}' | git hash-object -w --stdin)
                          TREE=$(printf "100644 blob %s\tops.json\n" "$BLOB" | git mktree)
                          COMMIT=$(git commit-tree "$TREE" -p "$PARENT" -m "add comment")
                          
                          # update-ref với old-value để tránh ghi đè race condition
                          git update-ref refs/issues/login-timeout "$COMMIT" "$PARENT"
                          ```
                          
                          Tham số thứ ba của `update-ref` quan trọng hơn bạn nghĩ. Đó là **compare-and-swap**: nếu ref đã bị process khác đổi thì lệnh fail thay vì âm thầm ghi đè. Tức là bạn có sẵn optimistic locking mà không phải viết thêm dòng nào.
                          
                          Đồng bộ với remote cần khai báo refspec rõ ràng, vì mặc định Git chỉ push/fetch `heads` và `tags`:
                          
                          ```bash
                          git push origin 'refs/issues/*:refs/issues/*'
                          git fetch origin 'refs/issues/*:refs/remotes/origin/issues/*'
                          
                          # Hoặc cấu hình một lần cho tiện
                          git config --add remote.origin.fetch '+refs/issues/*:refs/remotes/origin/issues/*'
                          ```
                          
                          GitHub, GitLab và Gitea đều nhận custom refs, chỉ không hiển thị trên UI. GitHub có dành riêng một số namespace như `refs/pull/*`, nên bạn nên đặt tên riêng để tránh đụng.
                          
                          ## Viết một mini tracker bằng Python
                          
                          Có nền tảng rồi thì bọc lại bằng Python chưa tới 50 dòng. Mình dùng `subprocess` thuần, không cần thư viện ngoài:
                          
                          ```python
                          import json
                          import subprocess
                          
                          def git(*args, input=None):
                              return subprocess.run(
                                      ["git", *args], input=input, capture_output=True, text=True, check=True
                                          ).stdout.strip()
                                          
                                          def append_event(issue_id: str, event: dict) -> str:
                                              ref = f"refs/issues/{issue_id}"
                                                  try:
                                                          parent = git("rev-parse", "--verify", "-q", ref)
                                                              except subprocess.CalledProcessError:
                                                                      parent = None
                                                                      
                                                                          blob = git("hash-object", "-w", "--stdin", input=json.dumps(event, ensure_ascii=False))
                                                                              tree = git("mktree", input=f"100644 blob {blob}\tops.json\n")
                                                                                  args = ["commit-tree", tree, "-m", event["op"]]
                                                                                      if parent:
                                                                                              args += ["-p", parent]
                                                                                                  commit = git(*args)
                                                                                                  
                                                                                                      # CAS: nếu parent là None thì yêu cầu ref chưa tồn tại (40 số 0)
                                                                                                          git("update-ref", ref, commit, parent or "0" * 40)
                                                                                                              return commit
                                                                                                              
                                                                                                              def read_issue(issue_id: str) -> list[dict]:
                                                                                                                  ref = f"refs/issues/{issue_id}"
                                                                                                                      commits = git("rev-list", "--reverse", ref).splitlines()
                                                                                                                          return [json.loads(git("show", f"{c}:ops.json")) for c in commits]
                                                                                                                          
                                                                                                                          if __name__ == "__main__":
                                                                                                                              append_event("42", {"op": "create", "title": "Memory leak ở worker"})
                                                                                                                                  append_event("42", {"op": "label", "add": ["bug", "p1"]})
                                                                                                                                      append_event("42", {"op": "close"})
                                                                                                                                          for e in read_issue("42"):
                                                                                                                                                  print(e)
                                                                                                                                                  ```
                                                                                                                                                  
                                                                                                                                                  Để biết trạng thái hiện tại của issue, bạn **replay** toàn bộ event từ đầu. Đây chính là event sourcing, nhưng storage engine lại là thứ mọi developer đã cài sẵn.
                                                                                                                                                  
                                                                                                                                                  ## Vấn đề thật sự: merge khi offline
                                                                                                                                                  
                                                                                                                                                  Phần khó nằm ở đây. Giả sử hai người cùng sửa issue #42 khi đang offline, sau đó cùng push. Ref của hai bên đã **diverge**. Với code thì Git merge theo từng dòng, còn với event log thì bạn phải tự định nghĩa cách merge.
                                                                                                                                                  
                                                                                                                                                  ```mermaid
                                                                                                                                                  sequenceDiagram
                                                                                                                                                      participant A as Dev A (offline)
                                                                                                                                                          participant R as Remote
                                                                                                                                                              participant B as Dev B (offline)
                                                                                                                                                                  A->>A: append "comment X"
                                                                                                                                                                      B->>B: append "label p1"
                                                                                                                                                                          B->>R: push refs/issues/42
                                                                                                                                                                              A->>R: push (rejected, non-fast-forward)
                                                                                                                                                                                  A->>R: fetch refs/issues/42
                                                                                                                                                                                      A->>A: merge 2 nhánh event, sắp theo Lamport clock
                                                                                                                                                                                          A->>R: push merge commit
                                                                                                                                                                                          ```
                                                                                                                                                                                          
                                                                                                                                                                                          git-bug giải quyết bằng **Lamport clock**. Mỗi operation mang một logical timestamp, và khi merge thì các event của hai nhánh được sắp xếp lại theo timestamp đó. Nhờ event log chỉ append, không có update hay delete tại chỗ, việc merge gần như luôn thành công. Tệ nhất là hai thao tác đổi title đè lên nhau, và bên có clock cao hơn sẽ thắng.
                                                                                                                                                                                          
                                                                                                                                                                                          Nếu tự làm, bạn nên nhớ vài quy tắc sau:
                                                                                                                                                                                          
                                                                                                                                                                                          - **Chỉ append, không sửa**: xóa comment cũng là một event `delete`, không động vào blob cũ.
                                                                                                                                                                                          - **Tạo merge commit có 2 parent** (`commit-tree -p A -p B`) để giữ lại lịch sử của cả hai bên.
                                                                                                                                                                                          - **Không lưu dữ liệu lớn**: Git không hợp với file nhị phân lớn thay đổi liên tục. JSON vài KB mỗi event là vừa.
                                                                                                                                                                                          - **Nhớ tới `git gc`**: object không có ref nào trỏ tới sẽ bị dọn sau thời gian `gc.pruneExpire` (mặc định 2 tuần). Custom ref đã giữ object lại rồi, nhưng blob tạo ra mà chưa kịp gắn ref thì vẫn có thể mất.
                                                                                                                                                                                          
                                                                                                                                                                                          ## Kết luận
                                                                                                                                                                                          
                                                                                                                                                                                          Git không chỉ là công cụ quản lý source code. Nó là một distributed, content-addressable object store có sẵn CAS, replication và toàn vẹn dữ liệu bằng hash. git-bug là ví dụ tốt cho việc tận dụng điều đó thay vì dựng thêm một service mới.
                                                                                                                                                                                          
                                                                                                                                                                                          Một số việc bạn có thể làm ngay:
                                                                                                                                                                                          
                                                                                                                                                                                          1. **Chạy thử 4 lệnh plumbing** `hash-object`, `mktree`, `commit-tree`, `update-ref` trên một repo nháp để thấy Git hoạt động bên dưới ra sao.
                                                                                                                                                                                          2. **Dùng custom refs cho metadata gắn với repo**: kết quả benchmark của CI, lịch sử deploy, checklist review. Như vậy không cần thêm database và dữ liệu đi theo repo khi clone.
                                                                                                                                                                                          3. **Luôn truyền old-value cho `update-ref`** khi có nhiều process cùng ghi, để tránh race condition.
                                                                                                                                                                                          4. **Thiết kế dữ liệu dạng event log chỉ append** nếu cần sync offline, và sắp thứ tự bằng Lamport clock thay vì wall-clock time.
                                                                                                                                                                                          5. **Nhớ khai báo refspec** trong `remote.origin.fetch`. Nếu thiếu, custom refs sẽ không bao giờ tới được máy đồng nghiệp.
                                                                                                                                                                                          
                                                                                                                                                                                          Lần tới khi định dựng thêm Postgres chỉ để lưu vài trăm record gắn với code, hãy thử xem Git đã làm được việc đó chưa.

All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí