← Tất cả bài viết
Vercel Shallow Clone: Vì Sao git log Sai Lúc Build
Trang build từ git log trống rỗng hoặc sai trên Vercel dù local chạy hoàn hảo? Shallow clone cắt cụt — và bịa ra — lịch sử git. Đây là cách sửa tận gốc.

Một dự án gần đây của tôi có một trang được sinh ra lúc build bằng cách quét repo — timeline commit, vài chỉ số, một panel "độ tươi" theo từng file. Ở local mọi thứ hoàn hảo. Deploy lên thì hỏng theo hai kiểu khác nhau: một nửa số section trống rỗng, còn một section thì render đầy đủ, trông rất hợp lý, và sai toàn bộ. Không lỗi, không fail build. Thủ phạm là shallow clone của Vercel: git log lúc build không thấy lịch sử như bạn nghĩ, còn file bị gitignore thì hoàn toàn không tồn tại. Nếu trang bạn sinh từ lịch sử git hoặc dữ liệu local chạy đúng ở local mà vỡ trên Vercel, đây là giải phẫu con bug đó — kèm cách sửa sống sót qua nhiều hơn một lần deploy.
Vì sao git log vỡ trong build của Vercel
Vercel không clone repo theo cách bạn vẫn clone. Mặc định nó chạy shallow clone — thực chất là git clone --depth=10 — nên máy build chỉ thấy khoảng 10 commit gần nhất và không gì cũ hơn (docs build của Vercel mô tả môi trường build; độ sâu clone được xác nhận trong thảo luận vercel/vercel này).
Một sự thật đó đẻ ra ba lỗ hổng riêng biệt, và cả ba đều vô hình cho đến khi deploy:
- Lịch sử toàn repo bị cắt cụt.
git loglúc build trả về một nhúm commit. Timeline của tôi hiện 1 tuần hoạt động thay vì 20 tuần. Không lỗi gì cả — git vui vẻ báo cáo đúng phần lịch sử nó có. - Lịch sử từng file bị BỊA ra — cái này tệ hơn, và không ai cảnh báo bạn.
- Dữ liệu gitignore đơn giản là không tồn tại trong CI. Bất cứ thứ gì nằm trong
.gitignore— telemetry local, cache, file dữ liệu build đọc vào — chưa bao giờ được push, nên chưa bao giờ được clone. Hàm quét trả về null và view render đúng cái empty state được thiết kế sẵn.
Phát hiện môi trường "khuyết tật" này chỉ cần một dòng:
git rev-parse --is-shallow-repository # "true" trên máy build của Vercel
Kiểu hỏng tệ hơn: shallow git bịa lịch sử từng file
Timeline bị cắt cụt trông hỏng, nên sẽ được để ý và sửa. Lỗ hổng nguy hiểm là shallow git không chỉ cắt lịch sử từng file — nó nói dối về lịch sử đó.
Trong shallow clone, commit cũ nhất bị ghép vào không có cha. Dưới mắt git, mọi file trong cây đều được tạo ra trong đúng một commit đó. Nghĩa là, với mọi path trong repo:
git log -1 --format=%cI -- src/anything.ts # → ngày của commit deploy. Với MỌI file.
git rev-list --count HEAD -- src/anything.ts # → 1. Với MỌI file.
Trên dự án của tôi, panel staleness dựa trên hai lệnh đó render cả 32 file được theo dõi là commit hôm nay, tuổi 0, số commit 1. Thế là bộ đếm "cũ (≥ 30 ngày)" đọc một con số 0 khỏe mạnh — và không bao giờ có thể nhảy nữa, bất kể file thực sự mục nát bao lâu. Section trông hoàn toàn ổn. Mọi giá trị trong đó đều sai.
Đó là pattern đáng sợ: section bị cắt cụt vỡ to tiếng và được sửa; section bị bịa vỡ trong im lặng và giết chết mọi thứ phụ thuộc vào nó — kiểm tra staleness, chỉ số churn, dòng "cập nhật lần cuối", danh sách xếp theo độ mới, lastmod trong sitemap.
| Input lúc build | Ở local | Trên shallow clone của Vercel | Kiểu hỏng |
|---|---|---|---|
| File nằm trong repo | ✅ đầy đủ | ✅ đầy đủ | không — quét trực tiếp an toàn |
git log (toàn repo) | ✅ đủ lịch sử | ⚠️ ~10 commit cuối | section cụt / trống |
git log -1 -- <path> từng file | ✅ ngày thật | ❌ commit deploy, mọi path | hợp lý nhưng bịa |
| Dữ liệu local bị gitignore | ✅ có | ❌ không tồn tại | empty state render như thiết kế |
Cách sửa: commit một snapshot cho những gì build không thấy
Clone sâu hơn vá được một lỗ (xem FAQ), nhưng cách sửa bền là chia input lúc build theo đúng một câu hỏi: máy build có thực sự thấy thứ này không?
- Nằm trong repo (source, config, docs) → quét trực tiếp lúc build, như cũ.
- Máy build không thấy (lịch sử git — toàn repo và từng file — cộng mọi thứ gitignore) → serialize vào một file snapshot được commit, ví dụ
data/snapshot.json, tái sinh ở local bằng một script sync.
Hai chi tiết khiến snapshot đáng tin thay vì thành một bug staleness mới:
1. Tự động stage bằng pre-commit hook để nó không thể mục. Snapshot mà bạn phải nhớ refresh là snapshot luôn luôn cũ. Gắn nó vào mọi commit:
# .githooks/pre-commit
#!/bin/sh
node scripts/sync-snapshot.mjs
git add data/snapshot.json
git config core.hooksPath .githooks # hook không đi theo clone — mỗi máy chạy một lần
2. Key dữ liệu từng file bằng path tương đối so với gốc repo. Snapshot được ghi trên máy Mac và đọc trên CI Linux; path tương đối repo là khóa join duy nhất giống hệt nhau ở cả hai. Path tuyệt đối sẽ lặng lẽ không match gì cả.
Rồi scanner lúc build phát hiện môi trường khuyết tật và fallback theo từng section, độc lập nhau:
const isShallow =
execSync('git rev-parse --is-shallow-repository').toString().trim() === 'true';
const timeline = isShallow ? snapshot.timeline : scanGitLog();
const fileFacts = isShallow ? snapshot.files : scanPerFileHistory(); // Map key theo path tương đối repo
const metrics = existsSync(LOCAL_DATA) ? scanLocalData() : snapshot.metrics;
Tính độc lập quan trọng: shallow git và file local bị thiếu là hai kiểu hỏng riêng, mỗi section phải tự fallback. Tôi còn render nguồn nào thắng (live hay snapshot) ngay trong UI — biến lần hỏng-trong-im-lặng tiếp theo thành hỏng-nhìn-thấy-được.
Tái hiện tại local trong 10 giây
Đừng đợi deploy mới biết. Một shallow clone chính repo của bạn hành xử y hệt clone của Vercel:
git clone --depth 1 file:///abs/path/to/repo /tmp/shallow && cd /tmp/shallow
# chạy build/scan của bạn ở đây, rồi diff các giá trị sinh ra với repo đầy đủ.
# Output giống hệt = đã sửa xong. Toàn bộ bài test là vậy.
Cái diff đó là toàn bộ acceptance test cho cả lớp bug này. Output của build shallow khớp từng byte với bản deep là xong.
Cái bẫy của sync: merge, không bao giờ overwrite
Còn một cái bẫy nữa, và nó mang tính phá hoại. Script sync ngây thơ sẽ tái sinh toàn bộ snapshot từ những gì máy hiện tại thấy được. Chạy nó trên máy thứ hai không có dữ liệu gitignore — một cái laptop, clone của đồng nghiệp — và nó ghi metrics: null đè lên dữ liệu tốt. Một commit vô tội xóa sạch lịch sử khỏi site đang chạy.
Quy tắc: sync phải merge, không bao giờ overwrite. Section nào máy hiện tại không tính lại được thì giữ nguyên từng byte từ snapshot cũ, không bao giờ thay bằng giá trị rỗng. Và ghi file kiểu atomic — ghi ra file tạm rồi rename — để crash giữa chừng không thể để lại một file JSON cụt trên đĩa.
Con bug này cắn cùng một dự án ba lần trước khi tôi coi nó là một lớp bug: đầu tiên là timeline toàn repo, rồi file metrics bị gitignore, rồi panel staleness từng file. Lần nào section hỏng trước đó cũng trông như đã sửa xong, nên fix được tuyên bố hoàn tất. Bài học cuối cùng mới thấm: khi một consumer của input nhiễm độc vỡ, grep mọi caller khác của input đó (git log, git rev-list, mọi chỗ tra ngày theo file) và sửa cùng một lượt. Shallow clone đầu độc cả lớp, không phải mỗi cái section bạn tình cờ thấy.
Nó cùng họ với bug Next.js âm thầm gọi CMS ở mọi request hay database serverless đốt sạch quota dù site ít traffic: mặc định của platform hợp lý cho trường hợp phổ biến và sai trong im lặng cho trường hợp của bạn, và không có gì báo lỗi. "Chạy đúng ở local" không chứng minh gì về một máy build có filesystem khác và lịch sử git khác. Hỏi mọi input lúc build: nó có nằm trong repo không, và có nằm trong 10 commit cuối không? Không thì snapshot nó.
FAQ
Có thể bảo Vercel clone đủ lịch sử luôn không?
Gần như có. Đặt biến môi trường VERCEL_DEEP_CLONE=true khiến Vercel clone toàn bộ repo thay vì shallow clone mặc định — được dùng rộng rãi, nhưng nó sống trong thảo luận cộng đồng chứ không phải docs chính thức, nên hãy coi là tiện ích chứ không phải cam kết. Và nó chỉ vá các lỗ về git: dữ liệu local bị gitignore vẫn không tồn tại trong CI, nên vẫn cần snapshot (hoặc một datastore thật) cho phần đó. Deep clone cũng phình theo repo; cách snapshot giữ chi phí build ở mức O(1).
Phát hiện shallow clone trong build script thế nào?
git rev-parse --is-shallow-repository in ra true/false. Rẽ nhánh trên đó để fallback sang snapshot, và log ra section nào dùng nguồn nào để regression hiện rõ trong output của build.
Vì sao git log hiện ngày hôm nay cho mọi file trên Vercel?
Vì commit cũ nhất của shallow clone không có cha, git quy việc tạo ra mọi file cho chính nó. git log -1 -- <path> trả về commit deploy và git rev-list --count -- <path> trả về 1 cho mọi path — trông hợp lý, sai đồng loạt. Mọi thứ suy ra từ ngày theo file (byline, staleness, lastmod sitemap) đều bị đầu độc trong im lặng.
Bug này có ảnh hưởng ngày "cập nhật lần cuối" và sitemap cho SEO không?
Có — cùng một kiểu bịa. Nếu lastmod trong sitemap hoặc ngày "updated" của bài viết lấy từ git log -1 -- <path> lúc build, mọi trang trên Vercel đều báo ngày deploy. Hoặc deep clone, hoặc snapshot ngày thật; ship kiểu trang-nào-cũng-mới-cập-nhật-hôm-nay có thể làm crawler mất niềm tin vào tín hiệu lastmod của bạn.
Tóm gọn một dòng: build trên Vercel thấy ~10 commit và không thấy file gitignore nào — nên chỉ quét trực tiếp thứ nằm trong repo, snapshot mọi thứ còn lại với sync merge-không-overwrite, và test bằng clone --depth 1 trước khi deploy.
Tôi xây và debug đúng loại "hệ ống nước" build pipeline này trong các dự án làm site trọn gói — phần hào nhoáng nằm ở WebGL, còn phần đáng tin nằm ở pipeline.


