Shallow clone vs full history in CI Jump to heading
Checkout actions default to depth one because most jobs need one tree and nothing else. The trouble starts when a job needs slightly more than that — a merge base, a tag, an ancestry check — and fails in a way that does not mention the clone at all. The error is usually about a missing revision, and the reflex is to set fetch-depth: 0, which restores correctness and gives back all the time depth one saved. There is a middle setting, and it is the right one surprisingly often. This recipe covers all three, within CI caching and runner performance.
When to use this approach Jump to heading
- Repository fetch is a visible fraction of pipeline duration.
- A policy or release job fails with an unknown-revision error.
- Someone has set
fetch-depth: 0everywhere to make an error go away. - Your repository has years of history and large objects in it.
- If the repository is small and young, depth does not matter much and the default is fine.
Step 1 — Know what depth one actually gives you Jump to heading
A shallow clone has the tip commit and no ancestry. Everything that depends on ancestry is unavailable, and the failures are specific.
# Inside a shallow checkout
git rev-parse --is-shallow-repository # true
git log --oneline | wc -l # 1
git merge-base origin/main HEAD # fatal: not a valid object name
git describe --tags # fatal: no tag can describe
git rev-list --count HEAD # 1 — not the real commit count Step 2 — Fetch the base branch instead of all history Jump to heading
For the middle case, the requirement is not all history — it is the ancestry shared with the target branch. That is far cheaper.
- uses: actions/checkout@v4
with:
fetch-depth: 0 # start here if unsure, then narrow
# Narrower and usually sufficient:
- uses: actions/checkout@v4
with: { fetch-depth: 1 }
- run: |
git fetch --no-tags --prune --depth=50 origin \
"+refs/heads/${{ github.base_ref }}:refs/remotes/origin/${{ github.base_ref }}" # Verification: the merge base now resolves
git merge-base "origin/${BASE_REF}" HEAD && echo "range available"
git rev-list --count "$(git merge-base origin/"$BASE_REF" HEAD)"..HEAD A depth of fifty covers essentially every pull request; if the branch diverged more than that, deepen on demand rather than fetching everything.
# Deepen only when the merge base is still missing
until git merge-base "origin/$BASE_REF" HEAD >/dev/null 2>&1; do
git fetch --deepen=100 origin "$BASE_REF" || break
done Step 3 — Fetch tags only where they are needed Jump to heading
Tags are a common reason fetch-depth: 0 gets set, and they can be fetched independently of history depth.
# Tags without the rest of history
git fetch --depth=1 origin '+refs/tags/*:refs/tags/*'
# Verification: the version derivation works
git describe --tags --abbrev=0 2>/dev/null || echo "still no tags" # What a release job usually needs
git fetch --tags --unshallow # only in the job that builds the release Step 4 — Fix the failures a shallow clone causes Jump to heading
Three error shapes account for almost all of them, and each has a targeted fix rather than a blanket one.
# "fatal: refusing to merge unrelated histories"
# → the base branch was never fetched; fetch it, do not unshallow
# "fatal: no tag can describe '<sha>'"
# → tags are missing; fetch tags, not history
# "error: pathspec 'origin/main' did not match"
# → the remote-tracking ref does not exist; fetch that branch explicitly
git fetch --no-tags --depth=50 origin '+refs/heads/main:refs/remotes/origin/main' # Verification: assert the preconditions at the top of the job instead of failing later
git rev-parse --verify "origin/$BASE_REF" >/dev/null \
|| { echo "::error::base branch not fetched"; exit 1; } An explicit precondition check turns a confusing failure ten minutes into a job into a clear one at the start. Any job that diffs against a base — a message check, a size check, a path filter — should have one.
Step 5 — Reduce what a full clone costs when you do need one Jump to heading
Some jobs genuinely need everything. Partial clone makes that much cheaper by deferring blob transfer until something reads a file.
# History and trees now, file contents on demand
git clone --filter=blob:none "$REPO_URL" work
# Verification: history operations work, and blobs arrive lazily
cd work && git log --oneline | wc -l && git describe --tags --abbrev=0 # For a job that only walks history and never reads files, go further
git clone --filter=tree:0 --no-checkout "$REPO_URL" meta The trade-offs, including which operations become slow, are covered in speeding up clones with partial clone.
Validation checklist Jump to heading
Frequently Asked Questions Jump to heading
Is it safe to push from a shallow clone? Jump to heading
Yes for ordinary pushes, and the server handles the missing ancestry, but it is easy to produce surprising results with force pushes or rewrites from a shallow clone because local history is incomplete. Jobs that write to the repository should use a deeper fetch.
Why does git rev-list --count HEAD give the wrong number? Jump to heading
Because the clone has no ancestry to count. Any versioning scheme based on commit counts will silently produce one in a shallow job — a classic source of build artefacts labelled 0.0.1 in a project on its fourth year.
Should we unshallow rather than fetching the base branch? Jump to heading
Only if the job needs history beyond the branch point. Unshallowing transfers everything, which is precisely the cost depth one was avoiding, and for the common case of comparing against a base it is unnecessary.
Related Jump to heading
- CI Caching & Runner Performance — the parent topic and the phase breakdown.
- Speeding Up Clones With Partial Clone — the filter options in depth.
- Reusing a Git Mirror on Self-Hosted Runners — removing the fetch cost entirely where you own the machines.