threads / bug / 65092

bug: fetch --shallow-since can produce .git/shallow entries with no backing objects

Subject: bug: fetch --shallow-since can produce .git/shallow entries with no backing objects

## tl;dr

One message between Feb 27, 2026 and Feb 27, 2026.

replies: 0people: 1as markdown or json

Nick Gavalas· Feb 27, 2026, 19:00 UTC · lore
Hi,

I'm not sure if this is a bug or if I'm misunderstanding the intended semantics of `--shallow-since`, but I'm seeing behavior that surprised me and I'd appreciate another pair of eyes on it.

When fetching with `--shallow-since` into an empty repository, I can end up with entries in `.git/shallow` that point to commit objects which were never sent in the pack. The resulting repo looks healthy for read-only operations, but a later attempt to deepen the clone fails with a confusing error.

Here's a minimal reproducer:
    git init --bare server.git
    git clone server.git work
    (
        cd work
        GIT_COMMITTER_DATE="100000000 +0000" git commit --allow-empty -m V
        V=$(git rev-parse HEAD)
        GIT_COMMITTER_DATE="200000000 +0000" git commit --allow-empty -m T
        GIT_COMMITTER_DATE="300000000 +0000" git commit --allow-empty -m S
        git checkout -b feature "$V"
        GIT_COMMITTER_DATE="100000001 +0000" git commit --allow-empty -m Fold
        git checkout -
        GIT_COMMITTER_DATE="400000000 +0000" git merge --no-ff -m M feature
        GIT_COMMITTER_DATE="500000000 +0000" git commit --allow-empty -m want
        git push origin HEAD
    )
    git init --bare client
    git -C client fetch --shallow-since="150000000 +0000" \
        "file://$PWD/server.git" main
    # Check each shallow entry actually exists
    while read oid; do
        git -C client cat-file -e "$oid" \
            && echo "$oid OK" \
            || echo "$oid MISSING"
    done < client/shallow
On my machine (git 2.51.0, and also against recent `next`) this prints:
    79f92113... OK
    bc6015ef... MISSING
The history looks like this (newest at top):
    want     (time=500M)
      |
      M      (time=400M, merge)
     / \
   Fold  S   (time=100M+1 / time=300M)
     \   |
      \  T   (time=200M)
       \ |
        V    (time=100M)

With `--shallow-since=150M`, I'd naively expect the cutoff to exclude `V` and `Fold`, so the shallow boundary would be whichever commits have those as parents. The server does seem to compute both `M` (parent `Fold` is too old) and `T` (parent `V` is too old) as boundaries — both show up in `.git/shallow`.

But the pack only contains `want` and `M`. My guess is that once `M` is treated as a graft point (no parents), the path `M -> S -> T` disappears, so `T` never gets enumerated for the pack — but it was already promised to the client as a shallow boundary.

I noticed the same thing with `--shallow-exclude=feature`, which I think goes through the same codepath. `--depth=N` doesn't exhibit this.

The repo looks healthy for read-only operations, and a plain incremental fetch against the same server also works (the server has the object, so it just silently ignores the shallow line). But if you then try to *deepen* the clone, the server sends back `unshallow <T>` and the client dies:

    # after the reproducer above
    git -C client fetch --shallow-since="100000000 +0000" \
        "file://$PWD/server.git" main
    # -> fatal: error in object: unshallow bc6015ef...

I believe this is fetch-pack.c:receive_shallow_info hitting parse_object() on an object the client never actually received.

Is there an invariant here that I'm missing? Should `.git/shallow` always point at objects the client actually has, or is the client expected to tolerate missing shallow objects?

Happy to provide more detail or a larger real-world reproducer if helpful — I originally hit this on a repo with heavy merge-queue history where about a third of the shallow entries were missing.

Thanks, Nick

← back to recent threads