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"
How an unsupported setting failsAn old client parses the configuration file, does not recognise the key, and ignores it silently. Nothing errors, the value is visible in the file, and the behaviour it was meant to produce simply never happens.why an old client is worse than a missing settingKey present in the filelooks configuredClient does not recognise itno warningSilently ignoredbehaviour absentthere is no error anywhere in this chain, which is what makes it expensive

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.

Where a version check can actually be enforcedA bootstrap check is the friendliest and easiest to skip. A hook check catches anyone using hooks, which is most of the team. The pipeline check protects the images you control. The server cannot reliably see the client version at all.Where do you want the version floor enforced?at setupBootstrap scriptfriendly, skippableat commit timeHook checkcatches most peoplein the pipelineCI image checkprotects your own imagesat push timeNot reliablethe server cannot tellthere is no way to force this from the server, so make the local path pleasant

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.

Raising a version floor without blocking the teamAnnounce the intended floor and the reason, then run the check as a warning so people see it during ordinary work. Only after a grace period does the check start failing, by which point almost nobody is affected.Announceversion and reasonweek 0Warn onlycheck prints, exits zeroweek 0Most upgradedmeasured, not assumedweek 2Enforcecheck failsweek 3the warning period costs three weeks and prevents a morning of blocked commits

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.