DEV Community

EME GUG
EME GUG

Posted on

Taming Huge Git Repos: Partial Clone, Sparse-Checkout and Scalar in Practice

Tuần này trên HN có bài về việc Anthropic thiết kế lại hệ thống source control nội bộ. Đọc xong mình thấy quen, vì team nào để monorepo phình lên vài chục GB cũng sẽ gặp đúng những vấn đề đó: git clone mất 40 phút, git status chạy 8 giây, CI thì tốn phần lớn thời gian để kéo code về. Phần lớn chúng ta không cần (và cũng không nên) tự viết VCS riêng. Git bản hiện đại đã có sẵn công cụ để xử lý repo lớn, chỉ là ít người dùng. Bài này tổng hợp những gì mình đã áp dụng cho một monorepo khoảng 18GB, gồm partial clone, sparse-checkout, Scalar và vài config đáng bật.

1. Biết repo nặng vì đâu trước khi tối ưu

Repo chậm thường vì một trong ba lý do sau, hoặc cả ba:

  • Nhiều blob lớn trong history: ai đó từng commit file .zip, model ML, dump SQL rồi xóa đi, nhưng các blob đó vẫn còn trong history.
  • Nhiều file trong working tree: hàng trăm nghìn file khiến git status phải stat() từng file một.
  • History dài: hàng triệu commit làm git log, git blame và merge-base chậm đi.

Để biết repo của mình thuộc loại nào, mình hay dùng script Python sau để liệt kê các blob lớn nhất trong toàn bộ history:

#!/usr/bin/env python3
# find_big_blobs.py - liệt kê top N blob lớn nhất trong history
import subprocess
import sys

TOP_N = int(sys.argv[1]) if len(sys.argv) > 1 else 20

rev_list = subprocess.Popen(
    ["git", "rev-list", "--objects", "--all"],
    stdout=subprocess.PIPE,
)
cat_file = subprocess.run(
    ["git", "cat-file",
     "--batch-check=%(objecttype) %(objectname) %(objectsize) %(rest)"],
    stdin=rev_list.stdout,
    capture_output=True,
    text=True,
)

blobs = []
for line in cat_file.stdout.splitlines():
    parts = line.split(" ", 3)
    if parts[0] == "blob":
        path = parts[3] if len(parts) > 3 else "(unknown)"
        blobs.append((int(parts[2]), parts[1][:10], path))

blobs.sort(reverse=True)
total = sum(b[0] for b in blobs)
print(f"Tổng {len(blobs)} blob, {total / 1024**3:.2f} GB (chưa nén)")
for size, sha, path in blobs[:TOP_N]:
    print(f"{size / 1024**2:8.1f} MB  {sha}  {path}")
Enter fullscreen mode Exit fullscreen mode

Chạy python3 find_big_blobs.py 30 ở repo của mình thì thấy khoảng 70% dung lượng nằm ở các file .onnx và .parquet có từ 3 năm trước. Lúc đó có hai lựa chọn. Một là rewrite history bằng git filter-repo, nhưng cách này đau vì mọi người phải clone lại và mọi SHA đều đổi. Hai là giữ nguyên history và để client không tải blob mình không cần. Trên repo dùng chung, mình gần như luôn chọn cách thứ hai.

2. Partial clone: chỉ tải blob khi thật sự cần

Partial clone ổn định từ khoảng Git 2.22 trở đi, và GitHub, GitLab, Bitbucket đều đã hỗ trợ. Có hai filter hay dùng:

# Blobless clone: tải toàn bộ commit + tree, blob thì tải lazy
git clone --filter=blob:none git@github.com:company/monorepo.git

# Treeless clone: chỉ tải commit, tree và blob đều lazy (hợp cho CI build 1 lần)
git clone --filter=tree:0 git@github.com:company/monorepo.git

# Kiểm tra repo hiện tại có phải partial clone không
git config --get remote.origin.promisor        # true
git config --get remote.origin.partialclonefilter  # blob:none
Enter fullscreen mode Exit fullscreen mode

Với repo 18GB của mình, blobless clone còn khoảng 1.9GB và mất chưa tới 3 phút. Cơ chế hoạt động như sau:

sequenceDiagram
    participant Dev as Developer
    participant Local as Local repo
    participant Remote as Remote (promisor)
    Dev->>Local: git clone --filter=blob:none
    Remote-->>Local: commits + trees (không có blob)
    Dev->>Local: git checkout main
    Local->>Remote: fetch các blob của HEAD
    Remote-->>Local: blobs cần thiết
    Dev->>Local: git log -p old_file.py
    Local->>Remote: fetch lazy blob của commit cũ
    Remote-->>Local: blob cũ

Cần lưu ý là partial clone đổi dung lượng lấy network. Các lệnh phải đọc nội dung file cũ như git blame hay git log -p trên một file có history dài sẽ phải kéo blob về, nên lần đầu chạy sẽ chậm và cần có mạng. Với developer thì blob:none là lựa chọn hợp lý. Mình không khuyên dùng tree:0 cho máy dev, vì khi đó gần như lệnh nào cũng phải fetch thêm.

3. Sparse-checkout: chỉ checkout phần mình làm

Partial clone giải quyết chuyện dung lượng. Sparse-checkout giải quyết chuyện working tree có quá nhiều file. Nên dùng cone mode (mặc định từ Git 2.27+), vì nó match theo thư mục và nhanh hơn nhiều so với pattern kiểu .gitignore:

# Clone kết hợp cả hai: blobless + sparse
git clone --filter=blob:none --sparse git@github.com:company/monorepo.git
cd monorepo

# Lúc này chỉ có file ở root. Thêm các thư mục cần làm:
git sparse-checkout set services/payment libs/common tools/ci

# Thêm thư mục mà không ghi đè danh sách cũ
git sparse-checkout add services/notification

# Xem đang checkout những gì
git sparse-checkout list

# Muốn quay lại full checkout
git sparse-checkout disable
Enter fullscreen mode Exit fullscreen mode

Kết hợp hai kỹ thuật này thì blob của các thư mục nằm ngoài sparse set sẽ không bao giờ bị tải về. Ở team mình, mỗi service có một file .sparse-profile liệt kê dependency, kèm một target make sparse SERVICE=payment để set cho đúng. Người mới vào chỉ cần clone vài trăm MB thay vì 18GB.

Vẫn còn vài vấn đề cần biết. IDE như IntelliJ hay VS Code có thể báo thiếu import nếu code tham chiếu tới thư mục ngoài sparse set. Build tool như Bazel, Nx hay Turborepo cũng cần biết dependency graph để không đi tìm file không tồn tại. Vì vậy hãy sinh sparse set từ dependency graph của build tool, đừng viết tay.

4. Scalar và các config nên bật

Từ Git 2.38, scalar được tích hợp sẵn trong Git. Thực chất nó là một wrapper bật sẵn các config tốt cho repo lớn:

# Clone mới theo cách của Scalar: blobless + sparse + maintenance
scalar clone git@github.com:company/monorepo.git
# Lưu ý: code nằm trong monorepo/src/

# Hoặc áp dụng cho repo đã clone sẵn
cd ~/work/monorepo
scalar register

# Nếu không muốn dùng scalar, bật tay các config quan trọng:
git config core.fsmonitor true          # built-in fsmonitor (macOS/Windows, Git 2.37+)
git config core.untrackedCache true
git config feature.manyFiles true       # index v4 + untracked cache
git config fetch.writeCommitGraph true
git commit-graph write --reachable --changed-paths
git maintenance start                   # chạy prefetch, gc, commit-graph nền
Enter fullscreen mode Exit fullscreen mode

Trên MacBook M3 của mình, git status giảm từ 6.2 giây xuống còn khoảng 0.3 giây chủ yếu nhờ core.fsmonitor. Git chạy một daemon theo dõi file system event, nhờ vậy không phải quét lại cả cây thư mục mỗi lần. Còn --changed-paths tạo Bloom filter trong commit-graph, giúp git log -- path/to/file nhanh lên rõ rệt.

Dưới đây là cách mình chọn chiến lược theo từng tình huống:

flowchart TD
    A[Cần clone repo lớn] --> B{Dùng cho việc gì?}
    B -->|CI build 1 lần| C[--depth=1 hoặc --filter=tree:0]
    B -->|Developer hằng ngày| D{Làm toàn repo?}
    D -->|Có| E[--filter=blob:none + fsmonitor]
    D -->|Chỉ vài service| F[scalar clone + sparse-checkout set]
    B -->|Cần blame/history sâu| G[Full clone + commit-graph]

Với CI, shallow clone --depth=1 vẫn là cách nhanh nhất cho job chỉ build và test. Nếu job cần tính diff với main, ví dụ để chỉ test các package bị ảnh hưởng, thì dùng --filter=blob:none với fetch-depth: 0 sẽ hợp lý hơn shallow, vì shallow clone hay làm hỏng git merge-base.

Kết luận

Trước khi nghĩ tới chuyện tách repo hay đổi VCS, hãy dùng hết những gì Git đã có sẵn:

  1. Đo trước: chạy script tìm blob lớn và time git status để biết repo chậm vì dung lượng, số file hay history.
  2. Developer dùng --filter=blob:none: dung lượng giảm 5 đến 10 lần mà gần như không mất gì.
  3. Monorepo nhiều team thì thêm sparse-checkout cone mode, và sinh sparse set từ dependency graph của build tool.
  4. Bật core.fsmonitor, feature.manyFiles và git maintenance start, hoặc chạy scalar register cho nhanh. Đây là thay đổi tốn ít công nhất mà hiệu quả nhiều nhất.
  5. Với CI, chọn giữa shallow và treeless tùy job có cần history hay không, đừng dùng full clone theo mặc định.
  6. Chặn blob lớn ngay từ đầu bằng pre-commit hook giới hạn kích thước file, và đưa artifact như model hay dataset ra object storage hoặc Git LFS.

Repo lớn không có gì sai. Vấn đề là dùng công cụ cho repo 50MB để làm việc với repo 50GB. Chỉ cần cấu hình đúng vài lệnh là đủ để team bớt mất thời gian chờ clone mỗi sáng.

Top comments (0)