← All articles
Vercel Shallow Clone: Why git log Lies at Build Time
Your page built from git log is empty or wrong on Vercel but perfect locally? The shallow clone truncates — and fabricates — history. Here's the fix.

I have a page on a recent project that's generated at build time by scanning the repo — a commit timeline, some metrics, a per-file "freshness" panel. Locally it was perfect. Deployed, it shipped wrong in two different shapes: half the sections were empty, and one section rendered complete, plausible, and entirely fabricated. No error, no failed build. The culprit is the Vercel shallow clone: git log at build time doesn't see the history you think it sees, and gitignored files don't exist at all. If a page you generate from git history or local data works locally and breaks on Vercel, this is the anatomy of the bug — and a fix that survives more than one deploy.
Why git log breaks in a Vercel build
Vercel doesn't clone your repository the way you did. By default it runs a shallow clone — effectively git clone --depth=10 — so the build machine sees roughly the last ten commits and nothing older (Vercel's build docs cover the build environment; the clone depth surfaces in this vercel/vercel discussion).
That single fact produces three distinct holes, and all of them are invisible until you deploy:
- Repo-wide history is truncated.
git logat build time returns a handful of commits. My commit timeline showed 1 week of activity instead of 20. No error — git happily reports the history it has. - Per-file history is fabricated — this one is worse, and it's the one nobody warns you about.
- Gitignored data simply doesn't exist in CI. Anything in
.gitignore— local telemetry, caches, a data file your build reads — was never pushed, so it was never cloned. The scan returns null and your view renders its designed empty state.
You can detect the degraded environment in one line:
git rev-parse --is-shallow-repository # "true" on Vercel's build machine
The worse failure: shallow git fabricates per-file history
A truncated timeline looks broken, so it gets noticed and fixed. The dangerous hole is that shallow git doesn't just truncate per-file history — it lies about it.
In a shallow clone, the oldest commit is grafted in with no parent. From git's point of view, every file in the tree was created in that one commit. Which means, for every path in your repo:
git log -1 --format=%cI -- src/anything.ts # → the deploy commit's date. For every file.
git rev-list --count HEAD -- src/anything.ts # → 1. For every file.
On my project, a staleness panel derived from those two commands rendered all 32 tracked files as committed today, age 0, commit count 1. So the "stale (≥ 30 days)" counter read a healthy 0 — and could never fire again, no matter how long a file actually rotted. The section looked completely fine. Every value in it was wrong.
That's the pattern to fear: a truncated section fails loudly and gets fixed; a fabricated one fails silently and kills whatever depends on it — staleness checks, churn metrics, "last updated" bylines, recency-ranked lists, sitemap lastmod dates.
| Build-time input | Locally | On Vercel's shallow clone | Failure shape |
|---|---|---|---|
| Files in the repo | ✅ full | ✅ full | none — safe to live-scan |
git log (repo-wide) | ✅ full history | ⚠️ last ~10 commits | sections truncated / empty |
git log -1 -- <path> per file | ✅ real dates | ❌ deploy commit, every path | plausible but fabricated |
| Gitignored local data | ✅ present | ❌ absent | designed empty state renders |
The fix: commit a snapshot of what the build can't see
Deepening the clone helps one hole (see the FAQ), but the durable fix is to split your build-time inputs by one question: can the build machine actually see this?
- In the repo (source files, config, docs) → live-scan at build time, as before.
- Not visible to the build (git history — repo-wide and per-file — plus anything gitignored) → serialize it into a committed snapshot file, e.g.
data/snapshot.json, regenerated locally by a sync script.
Two details make the snapshot trustworthy instead of a new staleness bug:
1. Auto-stage it with a pre-commit hook so it can't rot. A snapshot you have to remember to refresh is a snapshot that's always stale. Wire it into every commit:
# .githooks/pre-commit
#!/bin/sh
node scripts/sync-snapshot.mjs
git add data/snapshot.json
git config core.hooksPath .githooks # hooks don't travel with a clone — run once per machine
2. Key per-file facts by repo-relative path. The snapshot is written on a Mac and read on Linux CI; the repo-relative path is the only join key that's identical on both. Absolute paths will silently match nothing.
Then the build-time scanner detects the degraded environment and falls back per section, independently:
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 keyed by repo-relative path
const metrics = existsSync(LOCAL_DATA) ? scanLocalData() : snapshot.metrics;
Independence matters: shallow git and a missing local file are separate failures, and each section should fall back on its own. I also render which source won (live vs snapshot) in the UI — it turns the next silent failure into a visible one.
Reproduce it locally in 10 seconds
Don't wait for a deploy to find out. A shallow clone of your own repo behaves exactly like Vercel's:
git clone --depth 1 file:///abs/path/to/repo /tmp/shallow && cd /tmp/shallow
# run your build/scan here, then diff its derived values against the deep repo's.
# Identical output = fixed. This is the whole test.
That diff is the entire acceptance test for this class of bug. If the shallow build's output matches the deep one byte-for-byte, you're done.
The sync trap: merge, never overwrite
One more trap, and it's destructive. A naive sync script regenerates the whole snapshot from whatever the current machine can see. Run it on a second machine that lacks the gitignored data — a laptop, a colleague's clone — and it writes metrics: null over good data. One innocent commit wipes the history off the deployed site.
The rule: sync must merge, never overwrite. Any section the current machine can't recompute is preserved byte-for-byte from the existing snapshot, never replaced with an empty value. And write atomically — to a temp file, then rename — so a crash mid-write can't leave a truncated JSON on disk.
This bug bit the same project three times before I treated it as a class: first the repo-wide timeline, then the gitignored metrics file, then the per-file staleness panel. Each time, the previously broken section looked fixed, so the fix was declared complete. The lesson that finally stuck: when one consumer of a poisoned input breaks, grep for every other caller of that input (git log, git rev-list, per-file date lookups) and fix them in the same pass. A shallow clone poisons the whole class, not the one section you noticed.
It's the same genus of bug as Next.js silently hammering your CMS on every request or a serverless database burning through its quota on a low-traffic site: the platform's default is reasonable for the common case and silently wrong for yours, and nothing errors. "Works locally" proves nothing about a build machine with a different filesystem and a different git history. Ask of every build-time input: is it in the repo, and is it in the last 10 commits? If not, snapshot it.
FAQ
Can I just tell Vercel to clone the full history?
Mostly. Setting the environment variable VERCEL_DEEP_CLONE=true makes Vercel clone the full repository instead of the default shallow clone — it's widely used, though it lives in community discussions rather than the official docs, so treat it as a convenience, not a contract. And it only fixes the git holes: gitignored local data still doesn't exist in CI, so a snapshot (or a real datastore) is still needed for that. Deep clones also grow with your repo; the snapshot approach stays O(1) at build time.
How do I detect a shallow clone in my build script?
git rev-parse --is-shallow-repository prints true/false. Branch on it to fall back to snapshot data, and log which source each section used so a regression is visible in the build output.
Why does git log show today's date for every file on Vercel?
Because the shallow clone's oldest commit has no parent, git attributes every file's creation to it. git log -1 -- <path> returns the deploy commit and git rev-list --count -- <path> returns 1 for every path — plausible-looking, uniformly wrong. Anything derived from per-file dates (bylines, staleness, sitemap lastmod) is silently poisoned.
Does this affect "last updated" dates and sitemaps for SEO?
Yes — it's the same fabrication. If your sitemap lastmod or article "updated" dates come from git log -1 -- <path> at build time, every page reports the deploy date on Vercel. Either deep-clone or snapshot the real dates; shipping every-page-updated-today can erode crawlers' trust in your lastmod signals.
The one-line summary: a Vercel build sees ~10 commits and no gitignored files — so live-scan only what's in the repo, snapshot everything else with a merge-never-overwrite sync, and test with a --depth 1 clone before you deploy.
I build and debug this kind of build-pipeline plumbing as part of end-to-end site work — the glamour is in the WebGL, but the reliability is in the pipeline.


