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: 0 everywhere 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
What each job type needs from the cloneA test or build job reads one tree and needs nothing else. A policy job comparing a change against its base needs the merge base and the commits between. A release job needs tags and enough ancestry to describe the current commit.Test and buildone treedepth 1 is correctfastestPolicy and lintmerge basecommits in the rangefetch the base branchReleasetagsdescribe and countfull historymost jobs are the first column, which is why depth one is the default

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
Fetch time by strategy on a repository with eight years of historyA full clone transfers every object ever committed. Fetching the base branch to a bounded depth gives a policy job everything it needs for a fraction of the cost, and a plain depth-one checkout is faster still for jobs that only read the tree.seconds spent fetchingdepth 14 sdepth 1 + base branch9 sdepth 1 + tags7 sfull history63 sthe gap widens every year the repository lives

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.

What a depth-one clone is missingA shallow clone holds only the tip commit, so the merge base with the target branch and every commit between them is absent. Fetching the base branch to a bounded depth restores exactly the range a policy job needs without transferring the rest of history.depth 1 holds only the right-hand circlemainABMbranchMC1C2a bounded fetch of the base branch brings back M — which is all the range needs

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.