From: Nick Gavalas Date: Fri, 27 Feb 2026 19:00:07 GMT Subject: bug: fetch --shallow-since can produce .git/shallow entries with no backing objects Message-ID: 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 ` 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