-1

Git as a Database: How git-bug Stores Issues Inside Your Repo (and How to Build Your Own)

Tuần này trên Hacker News, git-bug (một bug tracker phân tán, offline-first, nằm ngay trong Git) lên top với gần 300 điểm. Lần đầu nghe, mình khá nghi ngờ: đưa issue vào Git thì lưu ở đâu? Có làm bẩn lịch sử commit không? Hai người cùng sửa một issue lúc offline thì merge kiểu gì? Tìm hiểu kỹ thì mình nhận ra cái hay nhất của git-bug không nằm ở bug tracker. Cái hay là nó dùng Git như một database phân tán, một kỹ thuật mà đa số dev dùng Git hằng ngày vẫn chưa biết. Bài này mình giải thích cơ chế bên dưới, cho các bạn tự tay làm một "mini git-bug" bằng Git plumbing commands, rồi bàn xem khi nào nên áp dụng.

Git không chỉ là nơi chứa code

Nhiều người nghĩ Git chỉ có branch và tag. Thật ra Git là một content-addressable object store với 4 loại object: blob, tree, commit, tag. Branch chỉ là một ref trỏ vào commit, nằm trong refs/heads/. Tag nằm trong refs/tags/.

Điều ít người để ý: bạn có thể tự tạo namespace ref riêng, ví dụ refs/issues/, refs/bugs/, refs/notes/. Những ref này:

  • Không hiện trong git branch hay git log mặc định
    • Không ảnh hưởng working directory
    • Vẫn có đầy đủ lịch sử, hash, khả năng push/fetch như code

git-bug tận dụng đúng điểm này. Mỗi bug là một chuỗi commit nằm trong refs/bugs/<id>. Mỗi commit chứa một operation (tạo bug, thêm comment, đổi label, đóng bug...), không chứa trạng thái cuối cùng.

graph TD
    A[refs/heads/main] --> B[Commit code]
        C[refs/bugs/abc123] --> D[Commit: SetStatus closed]
            D --> E[Commit: AddComment]
                E --> F[Commit: CreateBug]
                    F --> G[Tree]
                        G --> H[Blob: ops JSON]
                        ```
                        
                        Code và issue ở chung một repo nhưng lịch sử tách hẳn nhau. Chạy `git log` trên `main` bạn sẽ không thấy một commit nào của bug.
                        
                        ## Tự làm "mini git-bug" bằng plumbing commands
                        
                        Cách nhanh nhất để hiểu là tự làm. Các lệnh dưới đây chạy được trên Git 2.30 trở lên, trong bất kỳ repo nào:
                        
                        ```bash
                        # 1. Ghi nội dung issue thành một blob
                        BLOB=$(echo '{"title":"Login fail on Safari","status":"open"}' | git hash-object -w --stdin)
                        
                        # 2. Tạo tree chứa file issue.json
                        TREE=$(printf "100644 blob %s\tissue.json\n" "$BLOB" | git mktree)
                        
                        # 3. Tạo commit không có parent (root commit)
                        COMMIT=$(git commit-tree "$TREE" -m "create issue 0001")
                        
                        # 4. Gắn vào namespace riêng
                        git update-ref refs/issues/0001 "$COMMIT"
                        
                        # Kiểm tra
                        git for-each-ref refs/issues/
                        git show refs/issues/0001:issue.json
                        ```
                        
                        Để cập nhật issue (ví dụ đóng nó), tạo commit mới có parent là commit cũ:
                        
                        ```bash
                        PARENT=$(git rev-parse refs/issues/0001)
                        BLOB=$(echo '{"title":"Login fail on Safari","status":"closed"}' | git hash-object -w --stdin)
                        TREE=$(printf "100644 blob %s\tissue.json\n" "$BLOB" | git mktree)
                        COMMIT=$(git commit-tree "$TREE" -p "$PARENT" -m "close issue 0001")
                        
                        # Dùng old-value để tránh race condition (compare-and-swap)
                        git update-ref refs/issues/0001 "$COMMIT" "$PARENT"
                        
                        git log --oneline refs/issues/0001
                        ```
                        
                        Để ý tham số thứ ba của `git update-ref`. Nếu ref đã bị người khác đổi, lệnh sẽ fail thay vì ghi đè. Đây chính là **optimistic locking** kiểu database, và Git có sẵn tính năng này.
                        
                        **Lưu ý quan trọng khi sync:** `git clone` và `git push` mặc định **không** mang theo custom refs. Bạn phải chỉ định refspec rõ ràng:
                        
                        ```bash
                        # Push tất cả issue lên remote
                        git push origin 'refs/issues/*:refs/issues/*'
                        
                        # Fetch về namespace riêng để không đè lên bản local
                        git fetch origin 'refs/issues/*:refs/remotes/origin/issues/*'
                        ```
                        
                        Muốn khỏi gõ lại, thêm vào `.git/config`:
                        
                        ```bash
                        git config --add remote.origin.fetch '+refs/issues/*:refs/remotes/origin/issues/*'
                        ```
                        
                        ## Đọc dữ liệu bằng Python
                        
                        Có dữ liệu rồi thì viết tool đọc cũng đơn giản. Script dưới đây dùng Python 3.10+, không cần thư viện ngoài:
                        
                        ```python
                        import json
                        import subprocess
                        
                        def git(*args: str) -> str:
                            return subprocess.run(
                                    ["git", *args], capture_output=True, text=True, check=True
                                        ).stdout.strip()
                                        
                                        def list_issues(namespace: str = "refs/issues") -> list[dict]:
                                            refs = git("for-each-ref", namespace, "--format=%(refname)").splitlines()
                                                issues = []
                                                    for ref in refs:
                                                            data = json.loads(git("show", f"{ref}:issue.json"))
                                                                    history = git("rev-list", "--count", ref)
                                                                            data["id"] = ref.split("/")[-1]
                                                                                    data["revisions"] = int(history)
                                                                                            issues.append(data)
                                                                                                return issues
                                                                                                
                                                                                                if __name__ == "__main__":
                                                                                                    for i in list_issues():
                                                                                                            mark = "x" if i["status"] == "closed" else " "
                                                                                                                    print(f"[{mark}] #{i['id']} {i['title']} ({i['revisions']} rev)")
                                                                                                                    ```
                                                                                                                    
                                                                                                                    Chạy thử sẽ ra kiểu `[x] #0001 Login fail on Safari (2 rev)`. Chưa tới 30 dòng mà đã có một issue tracker chạy offline, có lịch sử và sync được qua bất kỳ Git remote nào.
                                                                                                                    
                                                                                                                    ## Bài toán khó: merge khi conflict
                                                                                                                    
                                                                                                                    Ví dụ trên dùng cách "lưu trạng thái cuối" (snapshot). Cách này rất dễ conflict: Alice đóng issue, Bob thêm label, cả hai đều offline. Đến lúc push, hai nhánh lịch sử của `refs/issues/0001` bị lệch nhau và bạn phải merge JSON bằng tay.
                                                                                                                    
                                                                                                                    git-bug giải bài toán này bằng **operation log** kết hợp **Lamport clock**. Nó không lưu "issue đang như thế nào" mà lưu "đã có ai làm gì". Khi hai nhánh lệch nhau, git-bug tạo một merge commit và sắp xếp các operation theo logical clock. Trạng thái cuối được tính bằng cách replay lại toàn bộ log. Về bản chất đây là một dạng CRDT đơn giản.
                                                                                                                    
                                                                                                                    ```mermaid
                                                                                                                    sequenceDiagram
                                                                                                                        participant A as Alice (offline)
                                                                                                                            participant R as Remote
                                                                                                                                participant B as Bob (offline)
                                                                                                                                    A->>A: op: SetStatus(closed), clock=5
                                                                                                                                        B->>B: op: AddLabel(ui), clock=5
                                                                                                                                            A->>R: push refs/bugs/abc
                                                                                                                                                B->>R: fetch + merge refs/bugs/abc
                                                                                                                                                    Note over B: Replay ops theo Lamport clock
                                                                                                                                                        B->>R: push merge commit
                                                                                                                                                            Note over R: Status=closed, Label=ui
                                                                                                                                                            ```
                                                                                                                                                            
                                                                                                                                                            Nếu tự build, bài học rút ra là: **lưu event chứ đừng lưu state**. Một file JSON cho mỗi operation, append-only, sẽ khỏe hơn nhiều so với một file `issue.json` bị ghi đè liên tục.
                                                                                                                                                            
                                                                                                                                                            Muốn dùng git-bug thật thì tải binary từ trang release (bản 0.10.x ở thời điểm mình viết). Các lệnh cơ bản:
                                                                                                                                                            
                                                                                                                                                            ```bash
                                                                                                                                                            git bug user new            # tạo identity (cũng được lưu trong Git)
                                                                                                                                                            git bug bug new             # tạo bug mới
                                                                                                                                                            git bug bug                 # liệt kê bug
                                                                                                                                                            git bug push origin         # sync lên remote
                                                                                                                                                            git bug termui              # giao diện terminal
                                                                                                                                                            ```
                                                                                                                                                            
                                                                                                                                                            Cú pháp lệnh đã thay đổi giữa các bản 0.8 và 0.10 (bản cũ dùng `git bug add`), nên các bạn nhớ chạy `git bug --help` để kiểm tra với bản mình đang cài. git-bug còn có *bridge* để sync hai chiều với GitHub/GitLab issues, khá hữu ích nếu team vẫn cần web UI.
                                                                                                                                                            
                                                                                                                                                            ## Khi nào nên dùng, khi nào không
                                                                                                                                                            
                                                                                                                                                            **Nên dùng khi:**
                                                                                                                                                            - Project cá nhân hoặc team nhỏ, muốn issue đi theo code (clone repo là có luôn issue)
                                                                                                                                                            - Hay làm việc ở nơi mạng chập chờn: quán cafe, trên máy bay, server nội bộ không ra được internet
                                                                                                                                                            - Cần lưu metadata gắn với repo như kết quả benchmark, review checklist hay deploy log mà không muốn làm bẩn branch chính
                                                                                                                                                            
                                                                                                                                                            **Không nên khi:**
                                                                                                                                                            - Team lớn, có PM/QA không dùng Git
                                                                                                                                                            - Cần phân quyền chi tiết (ai cũng push được ref thì ai cũng sửa được issue)
                                                                                                                                                            - Dữ liệu lớn hoặc chứa binary, vì Git không sinh ra để làm việc này
                                                                                                                                                            
                                                                                                                                                            ## Kết luận
                                                                                                                                                            
                                                                                                                                                            git-bug hay không phải vì nó thay được Jira. Nó hay vì cho thấy Git có sẵn những khả năng mà nhiều người không biết tới: content-addressable storage, lịch sử bất biến, compare-and-swap, sync phân tán. Mấy việc bạn có thể làm ngay:
                                                                                                                                                            
                                                                                                                                                            1. **Chạy thử 4 lệnh plumbing** (`hash-object`, `mktree`, `commit-tree`, `update-ref`) trong một repo test. Mất 10 phút nhưng bạn sẽ hiểu Git sâu hơn hẳn.
                                                                                                                                                            2. **Nhớ refspec**: custom refs không tự push/fetch. Đây là lỗi hay gặp nhất.
                                                                                                                                                            3. **Dùng `git update-ref` với old-value** mỗi khi viết tool ghi vào ref để tránh race condition.
                                                                                                                                                            4. **Lưu event thay vì state** nếu dữ liệu cần merge giữa nhiều người.
                                                                                                                                                            5. Cài thử git-bug cho một side project, dùng `termui` một tuần rồi hãy quyết định có hợp workflow của mình không.
                                                                                                                                                            
                                                                                                                                                            Lần tới cần lưu metadata gắn với repo, trước khi dựng thêm database hay dịch vụ ngoài, hãy nghĩ tới `refs/` trước. Nhiều khi Git đã đủ dùng.

All Rights Reserved

Viblo
Let's register a Viblo Account to get more interesting posts.