Verifying submodule commits are reachable upstream Jump to heading
A submodule is a pointer: the parent repository records a commit hash, and Git fetches that commit from the submodule’s remote when someone clones. Nothing checks, at commit time, that the remote actually has that commit. A developer commits inside the submodule, forgets to push it, updates the pointer in the parent and pushes the parent. Their own checkout works perfectly. Everyone else’s clone fails with “reference is not a tree”, CI goes red, and the only copy of the missing commit is on one laptop. A quieter version happens when the pointed-to commit lived on a branch that was later force-pushed or deleted. This page adds the checks that catch both, within submodule and dependency integrity.
When to use this approach Jump to heading
- Your repository uses submodules, and their pointers change through pull requests.
- You have seen “fatal: reference is not a tree” or “did not contain” errors in fresh clones.
- Submodule repositories allow branch deletion or force-pushes, so previously reachable commits can disappear.
- You want a pointer to be accepted only if it names a commit on a branch the submodule’s owners consider stable — for the update process itself, see pinning and updating Git submodules safely.
Step 1 — Make Git refuse to push an unpushed submodule commit Jump to heading
The cheapest fix is on the developer’s machine. push.recurseSubmodules=check makes git push in the parent refuse if any submodule commit it points to has not been pushed to the submodule’s remote.
git config --global push.recurseSubmodules check
# or push the submodule commits automatically before the parent
git config --global push.recurseSubmodules on-demand check fails loudly and lets the developer decide. on-demand pushes the submodule for them, which is convenient but can push work-in-progress commits to a shared remote. Most teams prefer check.
# Verification: commit in the submodule, update the pointer, try to push the parent
git -C vendor/lib commit --allow-empty -m "local only"
git add vendor/lib && git commit -m "bump lib"
git push # expect: "The following submodule paths contain changes that can not be found on any remote" Step 2 — Check reachability in CI, against the real remote Jump to heading
Local configuration helps, but CI is the gate. For every submodule pointer that changed in the pull request, fetch the submodule from its remote and confirm the commit exists there.
#!/bin/sh
# ci/check-submodule-reachable.sh <base> <head>
set -eu
base=$1 head=$2
git diff --submodule=short "$base" "$head" --raw |
awk '$1 ~ /^:160000/ || $2 ~ /^160000/ {print $NF}' | # mode 160000 = gitlink
while read -r path; do
sha=$(git ls-tree "$head" "$path" | awk '{print $3}')
url=$(git config -f .gitmodules --get "submodule.$path.url")
tmp=$(mktemp -d)
git -C "$tmp" init -q
if git -C "$tmp" fetch -q --depth=1 "$url" "$sha" 2>/dev/null; then
echo "ok $path @ ${sha%${sha#???????}}"
else
echo "MISSING $path @ $sha not fetchable from $url"; fail=1
fi
rm -rf "$tmp"
done Fetching a specific commit by hash requires the server to allow it; most hosted forges do for reachable commits. A fetch that fails is exactly the signal you want.
Step 3 — Require the commit to be on a stable branch Jump to heading
Reachable is not enough if the commit sits on a feature branch that will be deleted next week. Check that the pointed-to commit is an ancestor of one of the submodule’s protected branches.
# Inside the loop, after a successful fetch
git -C "$tmp" fetch -q "$url" "+refs/heads/main:refs/remotes/origin/main" "+refs/heads/release/*:refs/remotes/origin/release/*"
if ! git -C "$tmp" for-each-ref --format='%(refname)' refs/remotes/origin |
while read -r r; do git -C "$tmp" merge-base --is-ancestor "$sha" "$r" && exit 0; done; then
echo "UNSTABLE $path @ $sha is not on main or a release branch"; fail=1
fi Step 4 — Audit existing pointers across history Jump to heading
New pointers are checked from now on, but old ones may already point at commits that have since disappeared — which breaks checking out old tags and bisecting. A periodic audit walks the pointers at every release tag.
for tag in $(git tag --list 'v*'); do
git ls-tree -r "$tag" | awk '$1=="160000"{print $4, $3}' |
while read -r path sha; do
url=$(git show "$tag:.gitmodules" | git config -f - --get "submodule.$path.url" || true)
git ls-remote "$url" >/dev/null 2>&1 || { echo "$tag $path: remote gone ($url)"; continue; }
tmp=$(mktemp -d); git -C "$tmp" init -q
git -C "$tmp" fetch -q --depth=1 "$url" "$sha" 2>/dev/null || echo "$tag $path: $sha missing"
rm -rf "$tmp"
done
done Missing commits found this way can sometimes be recovered from a developer’s clone or a CI cache and pushed back to a dedicated refs/keep/* namespace in the submodule’s repository, so history becomes buildable again without polluting branches.
Step 5 — Protect the commits you depend on Jump to heading
The root cause of disappearing commits is a submodule repository that allows rewriting the branches you point at. Protect those branches against force-pushes and deletion, and consider a mirror you control, as described in mirroring third-party repositories for resilience.
Validation checklist Jump to heading
Frequently Asked Questions Jump to heading
Why not just use git submodule update --init in CI as the check? Jump to heading
It does fail on a missing commit, but CI runners often have cached clones or mirrors that contain commits the real remote does not. Fetching into an empty repository from the URL in .gitmodules tests what a new contributor will experience.
Does this work when submodules use relative URLs? Jump to heading
Yes, if you resolve them first. A URL starting with ../ is relative to the parent’s remote URL; compute the absolute URL from git remote get-url origin before fetching.
What about submodules pointing at private repositories? Jump to heading
The CI job needs read credentials for them, which it already needs to clone recursively. Use a read-only deploy key or token scoped to exactly those repositories.
Related Jump to heading
- Submodule & Dependency Integrity — the parent topic.
- Detecting Submodule URL Tampering in Pull Requests — the other half of reviewing submodule changes.
- Converting a Submodule to a Subtree — removing the reachability problem altogether.
- Pinning and Updating Git Submodules Safely — the update workflow these checks guard.