Pinning and updating Git submodules safely Jump to heading
A submodule bump is the least reviewable change in Git. The diff is two hexadecimal strings; the actual change β a security patch, a rewrite, a new transitive dependency β is in another repository that the reviewer probably has not cloned. That asymmetry is why dependency updates get approved unread, and it is entirely fixable with configuration and a short habit. This recipe is the day-to-day practice behind Submodule & Dependency Integrity.
When to use this approach Jump to heading
- Your repository has submodules and their bumps are currently approved without discussion.
- A dependency update has landed unnoticed inside an unrelated pull request.
- You need an audit trail of which upstream version was in use when, and why it changed.
- The dependency is signed upstream, so verification is available and should be enforced β see Commit Verification Gates.
- If instead the dependency is vendored rather than a submodule, the equivalent discipline is a recorded digest and drift check.
Step 1 β Make submodule changes visible Jump to heading
Two configuration keys change what a reviewer and a committer actually see. Set them in the repository so everyone gets them.
# Show upstream commit subjects in diffs instead of raw SHAs
git config diff.submodule log
# Surface submodule state in ordinary status output
git config status.submoduleSummary true
# Commit them so the whole team benefits
git config -f .gitconfig-shared diff.submodule log What changed: git diff on a pin bump now prints the upstream commit subjects between the two SHAs, and git status mentions a submodule whose HEAD has moved before you commit it by accident.
# See the difference immediately
git -C vendor/libfoo checkout HEAD~3
git diff # now lists the three upstream commits by subject
git submodule update # put it back Step 2 β Fetch and read the upstream range Jump to heading
Never move the pin and read the change in the same motion. Fetch first, read, then decide.
# 1. Bring upstream history in without touching the pin
git -C vendor/libfoo fetch origin --tags
# 2. What exactly would change?
git -C vendor/libfoo log --oneline HEAD..origin/main
git -C vendor/libfoo diff --stat HEAD..origin/main
# 3. Anything security-relevant in the range?
git -C vendor/libfoo log HEAD..origin/main --grep='CVE\|security\|overflow' -i What changed: nothing in your repository β the pin is untouched. You now know what adopting the new tip would mean.
# Confirm the pin has not moved while you were reading
git status --short vendor/libfoo # expect no output Step 3 β Move the pin in a commit that does nothing else Jump to heading
# 1. Choose an explicit target β a tag, not a branch tip
git -C vendor/libfoo checkout v3.3.0
# 2. Verify it before adopting it
git -C vendor/libfoo verify-tag v3.3.0
git -C vendor/libfoo log -1 --format='%G? %GS' # expect: G <trusted signer>
# 3. Stage only the gitlink
git add vendor/libfoo
git status --short # expect exactly one changed path
# 4. Record the provenance the diff cannot carry
git commit -m "deps: bump libfoo to v3.3.0
Range: b2c3d4e..9a8b7c6 (5 commits)
Notable: fix for oversized frame headers (upstream #482)
Tag: v3.3.0, signature verified against allowed_signers
Risk: streaming decoder is new but not enabled by our config" What changed: the superproject now points at a verified upstream release, and the commit message contains everything a reviewer β or someone reading git log in two years β needs.
git show --stat HEAD # exactly one path changed: the gitlink
git rev-parse HEAD:vendor/libfoo # matches the SHA of v3.3.0 SAFETY WARNING β never bundle a dependency bump with feature work. If the bump introduces a regression, reverting a mixed commit means reverting the feature too, and the bisect that finds the regression will land on a commit that changed twenty files. A pin bump should be a one-path commit that can be reverted in isolation, which is the same argument that governs revert versus reset.
Step 4 β Block accidental pin moves in CI Jump to heading
The most common bad submodule change is not a considered upgrade β it is an unnoticed one, where somebody ran a command inside the submodule and committed everything.
#!/bin/sh
# ci/check-submodule-bump.sh β run on every pull request
set -eu
base="${1:-origin/main}"
# Which gitlinks changed?
links=$(git diff --diff-filter=M --name-only "$base"...HEAD -- '*' \
| while read -r p; do
git ls-tree HEAD -- "$p" | grep -q '^160000' && echo "$p"
done)
[ -n "$links" ] || exit 0 # no dependency change: nothing to enforce
# A bump must be its own commit
changed=$(git diff --name-only "$base"...HEAD | wc -l)
[ "$changed" -eq "$(printf '%s\n' "$links" | wc -l)" ] || {
echo "dependency bump is mixed with other changes β split it out" >&2
exit 1
}
# ...and must document the upstream range
git log "$base"..HEAD --format=%B | grep -qi '^range:' || {
echo "dependency bump must record the upstream range in the commit message" >&2
exit 1
} # Verify the gate behaves on both paths
sh ci/check-submodule-bump.sh origin/main; echo "exit=$?" Validation checklist Jump to heading
Frequently Asked Questions Jump to heading
Why does my checkout keep showing the submodule as modified? Jump to heading
Because the submoduleβs HEAD is not at the SHA the superproject records β usually because a command inside it moved HEAD, or because a branch switch in the superproject changed the expected pin without updating the submodule working tree. Run git submodule update to return it to the recorded SHA, and check what moved it before assuming the pin is wrong.
Should the .gitmodules branch field be set? Jump to heading
Set it only if you intend to use --remote updates, because that is the only thing it affects. The build always uses the gitlink SHA regardless. Leaving it unset makes the pinβs authority obvious to anyone reading the file, which is often the clearer choice for a dependency you upgrade deliberately a few times a year.
How do I pin to a tag rather than a commit? Jump to heading
You cannot β a gitlink stores a commit SHA and nothing else. Check out the tag inside the submodule and commit the resulting SHA, then record the tag name in the commit message so humans know what it corresponds to. That distinction matters because a tag can be moved upstream while the SHA you recorded cannot.
Related Jump to heading
- Submodule & Dependency Integrity β the parent guide: what a pin guarantees, and what it does not.
- Auditing Vendored Dependencies for Tampering β the same discipline for code copied into the repository rather than referenced.
- Converting a Submodule to a Subtree β when the clone-time friction of submodules outweighs their provenance.