DEV Community

jidonglab
jidonglab

Posted on

actions/checkout fetch-depth: Why Your CI Diff Against main Is Empty

For three weeks my CI pipeline was the fastest it had ever been. It was also not running any tests.

The culprit was actions/checkout fetch-depth, which defaults to 1. My workflow gated every expensive job behind "did any file in this package change?", computed with git diff --name-only origin/main...HEAD. In a shallow, single-branch clone there is no origin/main. The diff errored, my script swallowed the error, the changed-file list came back empty, and every job politely skipped itself. Green checkmarks all the way down.

TL;DR

  • actions/checkout defaults to fetch-depth: 1, which clones exactly one commit and only the ref being built. origin/main does not exist in the runner's repo.
  • Any command that needs history fails: git diff origin/main...HEAD, git describe --tags, nx affected --base=origin/main, Turborepo's --filter=...[origin/main].
  • It fails silently when your script pipes through || true, 2>/dev/null, or uses ${{ github.event.before }}, which is 40 zeros the first time a branch is pushed.
  • Diagnose in one line: git rev-parse --is-shallow-repository prints true.
  • Fix options, cheapest first: on pull_request use fetch-depth: 2 and diff HEAD^1 HEAD; otherwise fetch just the base branch with --depth=50 and deepen until a merge base exists; fetch-depth: 0 works everywhere but pays full-history cost on every run.

Why is my git diff against main empty in GitHub Actions?

Because actions/checkout fetch-depth is 1 by default, and a depth-1 clone contains one commit and one remote-tracking ref: the branch being built. origin/main was never fetched, so git diff origin/main...HEAD exits 128 with unknown revision, not with an empty result. If your shell swallows that exit code, the variable holding your changed files is an empty string, and an empty string reads as "nothing changed."

Drop this into any job and look at the output:

- uses: actions/checkout@v4
- run: |
    git rev-parse --is-shallow-repository   # true
    git log --oneline | wc -l               # 1
    git branch -r                           # origin/my-branch, and nothing else
    git rev-parse origin/main               # fatal: ambiguous argument
Enter fullscreen mode Exit fullscreen mode

One commit. One remote branch. No tags, because tags aren't fetched unless you ask for them. That last part is why git describe --tags in a release job reports No names found on a repo that has 200 tags sitting on GitHub.

Why did it fail silently instead of erroring?

Three habits turn a loud fatal: into a green build.

One: defensive shell. This is the line that cost me three weeks.

CHANGED=$(git diff --name-only origin/main...HEAD -- packages/api || true)
if [ -z "$CHANGED" ]; then echo "no api changes, skipping"; exit 0; fi
Enter fullscreen mode Exit fullscreen mode

|| true was added years ago so the step wouldn't explode on an empty diff. It also eats unknown revision. Same story with 2>/dev/null.

Two: github.event.before. Plenty of workflows diff against the push event's previous SHA:

git diff --name-only ${{ github.event.before }} HEAD
Enter fullscreen mode Exit fullscreen mode

When a branch is created by the push, before is 0000000000000000000000000000000000000000. After a force-push, it can point at a commit the shallow clone never fetched. Either way: bad object, empty list, skipped jobs.

Three: fail-open gating. if: steps.changes.outputs.any == 'true' means an error in the detection step reads exactly like "nothing to do." A gate that fails open is a gate that eventually lies to you.

The tell is not a red X. It is a suspiciously fast pipeline. Mine went from 11 minutes to 40 seconds and I took it as a win.

How do I fix actions/checkout fetch-depth without cloning everything?

Pick the smallest fetch that makes your merge base reachable. Three options, cheapest first.

Option 1: fetch-depth: 2 on pull_request events

For pull_request, GitHub checks out a merge ref whose first parent is the base branch tip and whose second parent is your PR head. Fetch two levels and the diff is free:

- uses: actions/checkout@v4
  with:
    fetch-depth: 2
- run: git diff --name-only HEAD^1 HEAD
Enter fullscreen mode Exit fullscreen mode

No network round trip to fetch main, no merge-base search. This only works on pull_request (and pull_request_target) events, where the merge ref exists. On a plain push build, HEAD^1 is just the previous commit on your branch.

Option 2: fetch only the base branch, then deepen

For push, tag, and release builds, fetch the one ref you actually need and grow it until the histories connect:

- uses: actions/checkout@v4
- name: Make origin/main reachable
  run: |
    git fetch --no-tags --depth=50 origin +refs/heads/main:refs/remotes/origin/main
    depth=50
    until git merge-base origin/main HEAD >/dev/null 2>&1; do
      depth=$(( depth * 2 ))
      [ "$depth" -gt 2000 ] && { echo "no merge base found"; exit 1; }
      git fetch --no-tags --deepen="$depth" origin main
    done
    git diff --name-only "$(git merge-base origin/main HEAD)" HEAD
Enter fullscreen mode Exit fullscreen mode

Two details that matter. --deepen only applies to a shallow repository, which is fine here because checkout left it shallow. And the loop has an upper bound, so a genuinely unrelated history fails loudly instead of fetching your entire repo one doubling at a time.

If you need tags for git describe, add them explicitly rather than reaching for full history:

- uses: actions/checkout@v4
  with:
    fetch-depth: 0
    fetch-tags: true
Enter fullscreen mode Exit fullscreen mode

Option 3: fetch-depth: 0 and move on

- uses: actions/checkout@v4
  with:
    fetch-depth: 0
Enter fullscreen mode Exit fullscreen mode

Every ref, every commit, every time. It is correct, it is one line, and on a small repo you will never notice the cost. On a large one you pay it on every job in every matrix leg.

What does fetch-depth: 0 actually cost?

On my monorepo (roughly 28,000 commits, several hundred MB of history), the checkout step measured like this across a handful of runs:

Setup Checkout step origin/main reachable?
default (fetch-depth: 1) ~6s no
fetch-depth: 2 + HEAD^1 HEAD ~7s n/a, merge ref
depth-1 + targeted base fetch ~9s yes
fetch-depth: 0 ~70s yes

One minute per job does not sound like much until you multiply it by a 6-way matrix and a few hundred runs a week. That is where I landed: fetch-depth: 2 for PR jobs, targeted fetch for push jobs, fetch-depth: 0 only in the release job that needs git describe.

And regardless of which option you pick, delete the || true. Then add the assertion that would have saved me three weeks:

git rev-parse --verify origin/main >/dev/null 2>&1 || {
  echo "::error::origin/main is not in this clone; check fetch-depth"
  exit 1
}
Enter fullscreen mode Exit fullscreen mode

A changed-file gate should crash when it cannot see history. "I found no changes" and "I could not look" are not the same answer, and only one of them deserves a green check.

So why is your CI diff against main empty?

Because actions/checkout fetch-depth defaults to 1: the runner gets a shallow, single-branch clone with one commit and no origin/main to compare against, so git diff origin/main...HEAD fails with unknown revision rather than returning files. Any || true in your script converts that failure into an empty changed-file list, and every job gated on that list skips while the pipeline reports success. Verify it with git rev-parse --is-shallow-repository, then fetch the minimum history you need: fetch-depth: 2 plus git diff HEAD^1 HEAD on pull requests, a targeted git fetch --depth=50 origin main with a deepen loop on pushes, and fetch-depth: 0 only where you truly need every ref.


Written by the developer behind Preterview, an interview prep platform.

Top comments (0)