Bootstrapping a developer machine for Git Jump to heading
Onboarding documents rot faster than anything else in a repository, because nobody reads them twice. The version that survives is a script: it runs, it says what it did, and when something changes the change is a pull request rather than an edit to a page nobody opens. This recipe builds that script for the Git side of a developer machine β version, identity, signing, hooks and configuration β with a verification after every step, within Git configuration management at scale.
When to use this approach Jump to heading
- New joiners spend their first morning discovering configuration by failing.
- Machines are rebuilt often enough that setup happens regularly.
- Commit signing, hooks or line endings depend on per-machine settings.
- Your onboarding documentation contains a list of commands to copy.
- If the project needs no configuration beyond a clone, do not add a script β add nothing.
Step 1 β Check the client before configuring it Jump to heading
Several settings used later simply do not exist on older clients, and failing early with a clear message beats failing later with a confusing one.
#!/usr/bin/env sh
# scripts/bootstrap β from a fresh machine to a working clone. Safe to re-run.
set -eu
need=2.30.0
have=$(git --version | awk '{print $3}')
lowest=$(printf '%s\n%s\n' "$need" "$have" | sort -V | head -1)
[ "$lowest" = "$need" ] || {
echo "bootstrap: git $have is too old; $need or newer is required." >&2
echo " macOS: brew install git" >&2
echo " Debian: sudo apt install git" >&2
exit 1
}
echo "git $have β ok" # Verification: the check rejects an old client and accepts a current one
printf '2.30.0\n2.25.1\n' | sort -V | head -1 # expect 2.25.1 β too old Step 2 β Establish identity without guessing Jump to heading
Guessing an email from a system username produces commits nobody can attribute. Prompt instead, and only when it is missing or wrong.
domain=acme.com
email=$(git config --global --get user.email 2>/dev/null || echo '')
case "$email" in
*"@$domain") : ;;
*)
printf 'Work email (@%s): ' "$domain"
read -r email
case "$email" in *"@$domain") : ;; *) echo "bootstrap: expected an @$domain address" >&2; exit 1 ;; esac
git config --global user.email "$email"
;;
esac
[ -n "$(git config --global --get user.name || echo '')" ] || {
printf 'Full name: '; read -r name; git config --global user.name "$name"
}
echo "identity: $(git config --global --get user.name) <$(git config --global --get user.email)>" # Verification: a commit made now carries the right author
git -C "$(git rev-parse --show-toplevel)" commit --allow-empty -m 'chore: bootstrap check' \
&& git log -1 --format='%an <%ae>' && git reset --hard HEAD~1 Step 3 β Configure signing, if the project requires it Jump to heading
key=$HOME/.ssh/id_ed25519_signing
if [ ! -f "$key" ]; then
ssh-keygen -t ed25519 -C "$(git config --global --get user.email) signing" -f "$key"
echo "Add this public key to your account as a SIGNING key:"
cat "$key.pub"
fi
git config --global gpg.format ssh
git config --global user.signingkey "$key.pub"
git config --global commit.gpgsign true
git config --global tag.gpgsign true # Verification: a signed commit verifies against the allowed-signers file
git config --global gpg.ssh.allowedSignersFile "$PWD/.github/allowed_signers"
git commit --allow-empty -m 'chore: signing check' && git log --show-signature -1 | head -3
git reset --hard HEAD~1 Keeping a signing key separate from the authentication key is deliberate; the reasoning, and the allowed-signers model, are in GPG vs SSH commit signing.
Step 4 β Apply repository configuration and install hooks Jump to heading
root=$(git rev-parse --show-toplevel)
git -C "$root" config --local include.path ../.gitconfig-team
# Prove the include actually resolved, rather than trusting that it did
test "$(git -C "$root" config --get merge.conflictStyle)" = "zdiff3" \
|| { echo "bootstrap: team config did not apply β check include.path" >&2; exit 1; }
git -C "$root" config --local core.hooksPath .githooks
chmod +x "$root"/.githooks/* 2>/dev/null || true # Verification: a hook actually runs
git -C "$root" commit --allow-empty -m 'test' 2>&1 | head -3
git -C "$root" reset --hard HEAD~1 SAFETY WARNING β a bootstrap script runs with the new joinerβs full permissions and is the first thing they execute from your repository, usually without reading it. Keep it short enough to read, avoid piping it from a URL into a shell, and never have it download and run anything else. The trust a new colleague extends to a setup script is the same trust an attacker would like to borrow.
Step 5 β Make it idempotent and self-reporting Jump to heading
The script will be run repeatedly, on machines in various states. Re-running must be safe and must say what it found.
cat <<REPORT
bootstrap complete
git $(git --version | awk '{print $3}')
identity $(git config --get user.name) <$(git config --get user.email)>
signing $(git config --get commit.gpgsign) ($(git config --get gpg.format))
team config $(git config --get merge.conflictStyle)
hooks $(git config --get core.hooksPath)
REPORT # Verification: running it twice changes nothing and reports the same state
sh scripts/bootstrap > /tmp/run1.txt
sh scripts/bootstrap > /tmp/run2.txt
diff /tmp/run1.txt /tmp/run2.txt && echo "idempotent" Validation checklist Jump to heading
Frequently Asked Questions Jump to heading
Should the script install Git itself? Jump to heading
Better not. Installing software touches the machine far beyond the repository, needs elevated permissions, and differs per platform and per corporate policy. Detect and instruct: a clear message naming the required version and the usual install command is more useful and much less risky.
Where should the script live? Jump to heading
In the repository it configures, so it changes with the project. A separate dotfiles repository is fine for personal preferences, but project setup belongs to the project β otherwise the script and the thing it configures drift apart.
What about developers who already have everything set up? Jump to heading
They run it, it finds everything in order, and it prints the report. That is the point of idempotence: the script is as useful as a status check as it is for setup, and running it is the first thing to try when something behaves oddly.
Related Jump to heading
- Git Configuration Management at Scale β the parent topic and what belongs where.
- Shipping a Team gitconfig With includeIf β the configuration this script includes.
- Enforcing a Minimum Git Version β making the version check a rule rather than a suggestion.