Enforcing a minimum Git version Jump to heading
A configuration key an old client does not recognise is not an error β it is ignored. That is the worst possible behaviour for a team standard, because the machine appears configured, the setting is present in the file, and the effect is absent. Add in options that changed defaults between versions and features that simply did not exist, and a mixed-version team produces bugs nobody can reproduce. This recipe sets a floor and checks it where it matters, within Git configuration management at scale.
When to use this approach Jump to heading
- Your configuration depends on keys introduced in a specific version.
- Somebodyβs setting βdoes not workβ and their client turns out to be from 2019.
- Workflows rely on partial clone, sparse checkout, or
rebase --update-refs. - Long-lived machines or corporate images ship an old client.
- If everyone installs from a current package manager and nothing has broken, a documented floor is enough without enforcement.
Step 1 β Derive the floor from what you actually use Jump to heading
Pick the version from your own requirements, not from the newest release.
# Features and the version that introduced them, for the settings in common use:
# 2.23 git switch / git restore
# 2.28 init.defaultBranch
# 2.29 partial clone filters promoted
# 2.30 git push --force-if-includes
# 2.33 the ort merge strategy becomes the default
# 2.34 SSH commit signing (gpg.format=ssh)
# 2.35 zdiff3 conflict style
# 2.38 rebase --update-refs # What does your committed configuration require?
grep -oE '^\s*[a-zA-Z]+\s*=' .gitconfig-team | tr -d ' =' # Verification: the floor you choose must support every key you ship
git config --file .gitconfig-team --list | while IFS='=' read -r k _; do
git config --global --get "$k" >/dev/null 2>&1 || true
done; echo "review the list against the table above" Step 2 β Check it in the bootstrap and the hooks Jump to heading
The earliest possible check is the most useful one.
# A reusable function, sourced by both the bootstrap script and the hooks
git_at_least() {
need=$1
have=$(git --version | awk '{print $3}' | sed 's/[^0-9.].*//')
[ "$(printf '%s\n%s\n' "$need" "$have" | sort -V | head -1)" = "$need" ]
} # .githooks/pre-commit
. "$(git rev-parse --show-toplevel)/scripts/lib.sh"
git_at_least 2.38 || {
cat >&2 <<'MSG'
pre-commit: this repository requires Git 2.38 or newer.
Your hooks and configuration depend on features your client does not have,
so some checks would be silently skipped.
macOS: brew install git (then restart your shell)
Debian: sudo apt install git
Windows: winget upgrade Git.Git
MSG
exit 1
} # Verification: the function is correct at the boundary
git_at_least 2.38 && echo "current client is sufficient" Step 3 β Check it on the server, where it cannot be skipped Jump to heading
Local checks cover people who have hooks installed, which is not everyone.
# A pre-receive hook can read the client version from the push
#!/usr/bin/env sh
# The agent string is advisory but useful; the real enforcement is on behaviour.
while read -r _old _new _ref; do :; done
# Reject pushes that carry artefacts only an old client produces, e.g. a
# missing committer date timezone or an unsupported object format.
exit 0 # More practical: check in CI, where the client is yours
- run: |
need=2.38
have=$(git --version | awk '{print $3}')
[ "$(printf '%s\n%s\n' "$need" "$have" | sort -V | head -1)" = "$need" ] \
|| { echo "::error::CI image ships git $have, below the $need floor"; exit 1; } # Verification: the pipeline image meets the floor
git --version Enforcing a client version server-side is genuinely hard β Git does not reliably report the client version, and inferring it from behaviour is fragile. Treat the local check as the real mechanism and the pipeline check as protection against your own images drifting.
Step 4 β Give people a path, not a wall Jump to heading
A rejection with no instruction produces a workaround.
# Bad: "git too old"
# Good: name the version, the reason, and the command for their platform
cat >&2 <<MSG
This repository needs Git 2.38 or newer (you have $have).
Why: stacked branches rely on 'rebase --update-refs', and commit signing
uses the SSH format. On older clients both are silently ignored.
macOS brew install git && exec \$SHELL
Debian sudo apt install git
Fedora sudo dnf install git
Windows winget upgrade Git.Git
MSG # Verification: someone reading only the error knows what to do next
sh .githooks/pre-commit 2>&1 | head -10 Step 5 β Review the floor when it starts blocking people Jump to heading
A floor is a trade: newer features against the cost of upgrading. Revisit it rather than raising it reflexively.
# What does the team actually run?
# (collected by the bootstrap script's report, or asked once)
for v in 2.34 2.39 2.43 2.47; do printf '%s ' "$v"; done; echo # And which of your settings would a lower floor lose?
git config --file .gitconfig-team --get rebase.updateRefs && echo "needs 2.38"
git config --file .gitconfig-team --get gpg.format && echo "ssh signing needs 2.34" SAFETY WARNING β do not raise the floor as part of an unrelated change. A version bump that arrives inside a feature branch blocks everyone whose client is older the moment it merges, usually without warning, and the first they hear is a rejected commit. Raise it deliberately, announce it, and leave a grace period during which the check warns rather than fails.
Validation checklist Jump to heading
Frequently Asked Questions Jump to heading
Why not just document the required version? Jump to heading
Because the failure mode is silence. A documented requirement is checked by nobody, and the symptom of ignoring it is a setting that appears present and does nothing. A check converts that into a clear message at the moment it matters.
Can we detect the client version server-side? Jump to heading
Not reliably. Git sends a user agent string during the protocol exchange that some servers expose to hooks, but it is advisory, easily absent, and varies by transport. Enforce locally and treat anything server-side as a best-effort signal rather than a gate.
What about tools that bundle their own Git? Jump to heading
They are the usual reason a machine has two versions, and the one on PATH is not necessarily the one an editor or a container uses. Have the check report the resolved path alongside the version, so a surprising result is diagnosable rather than mysterious.
Related Jump to heading
- Git Configuration Management at Scale β the parent topic and the settings a floor protects.
- Bootstrapping a Developer Machine for Git β where the check runs first.
- Auditing Local Git Configuration Drift β finding the machines still below the floor.