Git Hooks and Commit Signing: From pre-commit to SSH Signatures
We write pre-commit and commit-msg hooks with real output, share them with the team through core.hooksPath, and try Git 2.54's config-based hooks and git hook list. Then we sign commits with an SSH key, verify them with an allowed signers file and look at what GitHub's Verified badge requires.
Version note: The commands in this post were tested with Git 2.54; SSH signing has been available since Git 2.34 and config-based hooks since Git 2.54.
Guess First
Someone on the team has committed a stray console.log again. To put an end to it, you write a small pre-commit hook in the bookstore repo. If it sees console.log in the staged JavaScript changes, it stops the commit. Then you add a console.log(items); line to src/cart.js on purpose and try it:
$ cat .git/hooks/pre-commit
#!/bin/sh
if git diff --cached -U0 -- 'src/*.js' | grep -q '^+.*console\.log'; then
echo "pre-commit: remove console.log from staged files" >&2
exit 1
fi
$ git add src/cart.js
$ git commit -m "Log cart items"
The script is right, the grep is right, the exit code is right. Does the commit get created? Take one more look before you answer: what is missing is not in the code.
$ git commit -m "Log cart items"
hint: The '.git/hooks/pre-commit' hook was ignored because it's not set as executable.
hint: You can disable this warning with `git config set advice.ignoredHook false`.
[main b68ba03] Log cart items
1 file changed, 1 insertion(+)
The commit was created. Git found the file, but since it is not executable it ignored it and only left a hint. Hooks are ordinary programs; as long as Git can execute them, the language does not matter. In this post we first look at how hooks work, then how to share them with a team, the config-based hooks that came with Git 2.54, and finally how to sign commits with an SSH key.
What Is a Hook and Where Does It Live?
A hook is a script Git calls on its own at certain moments: right before a commit is created, after the commit message is written, before a push, after a merge. Every repo comes with samples:
$ ls .git/hooks
applypatch-msg.sample
commit-msg.sample
fsmonitor-watchman.sample
post-update.sample
pre-applypatch.sample
pre-commit.sample
pre-merge-commit.sample
pre-push.sample
pre-rebase.sample
pre-receive.sample
prepare-commit-msg.sample
push-to-checkout.sample
sendemail-validate.sample
update.sample
To enable a hook, you put a file with the bare name (like pre-commit) in this folder and make it executable. The rule is simple: if a hook starting with pre- exits with a non-zero code, Git cancels the operation. post- hooks run after the work is done and cannot change the result; they are for things like sending a notification or writing a log.
The hooks in this post are client-side hooks, the ones that run on your machine. Server-side hooks such as pre-receive and update run on the server that accepts the push. GitHub.com does not run server-side hooks you write on its servers; there, branch protection, rulesets and CI do the same job.
pre-commit: The Last Check Before a Commit
Let's undo the previous attempt and make the file executable:
$ git reset -q --soft HEAD~1
$ chmod +x .git/hooks/pre-commit
$ git commit -m "Log cart items"
pre-commit: remove console.log from staged files
$ echo $?
1
This time Git ran the hook, the hook exited with 1 and the commit was never created. Your change is still in the staging area; nothing is lost. Notice that the script looks at git diff --cached: you have to check what will go into the commit, not the working tree. Otherwise someone who fixes the file but forgets to stage it can still commit the wrong content.
There is also an escape hatch:
$ git commit --no-verify -m "Log cart items"
[main 15c4139] Log cart items
1 file changed, 1 insertion(+)
$ git reset -q --hard HEAD~1
--no-verify (short: -n) skips the pre-commit and commit-msg hooks. That is not a bug, it is a deliberate design. Hooks are a seat belt that helps the developer, not a firewall. Rules nobody should be able to skip are enforced in CI and on the server side.
commit-msg: Checking the Message
In the post on commit messages we talked about keeping the summary line around 50 characters. The commit-msg hook receives the path of the temporary file holding the message as its first argument, so it can read the message and decide:
$ cat .git/hooks/commit-msg
#!/bin/sh
subject=$(head -n 1 "$1")
if [ ${#subject} -gt 50 ]; then
echo "commit-msg: subject is ${#subject} characters, keep it at 50 or less" >&2
exit 1
fi
$ chmod +x .git/hooks/commit-msg
$ git commit -m "Change the cart total so it multiplies price by quantity"
commit-msg: subject is 56 characters, keep it at 50 or less
$ git commit -m "Multiply price by quantity in cart total"
[main 53f5fc8] Multiply price by quantity in cart total
1 file changed, 1 insertion(+), 1 deletion(-)
Teams that use Conventional Commits also check in the same hook whether the message starts with a prefix like feat: or fix:. If you want to change the message rather than check it, the place for that is the prepare-commit-msg hook, for example to append the ticket number from the branch name.
Sharing Hooks with the Team: core.hooksPath
All good so far, but there is a catch: the .git folder is not part of the repo's content. Your hooks are neither committed nor sent along with a clone. The fix is to keep the hooks in an ordinary folder in the repo and tell Git to read hooks from there:
$ mkdir .githooks
$ mv .git/hooks/pre-commit .git/hooks/commit-msg .githooks/
$ git add .githooks
$ git commit -m "Share pre-commit and commit-msg hooks"
[main 6c6ba8f] Share pre-commit and commit-msg hooks
2 files changed, 11 insertions(+)
create mode 100755 .githooks/commit-msg
create mode 100755 .githooks/pre-commit
$ git config set core.hooksPath .githooks
$ git ls-files -s .githooks
100755 92db1613ed706fae0d0d6276cb52cca556e16ca1 0 .githooks/commit-msg
100755 c032c98e27a8f13e05a4c9243b339d44659fb159 0 .githooks/pre-commit
100755 shows that Git stores the executable bit too. On Windows that bit does not come from the file system, so you may have to set it by hand with git update-index --chmod=+x .githooks/pre-commit.
Now let Alan clone the repo:
$ git clone -q bookstore alan
$ cd alan
$ git config get core.hooksPath; echo $?
1
$ ls .git/hooks | head -3
applypatch-msg.sample
commit-msg.sample
fsmonitor-watchman.sample
The files arrived, the setting did not. That is deliberate too: a setting from the other side that silently runs scripts on your machine during a clone would be an open invitation for a malicious repo. Each developer runs git config set core.hooksPath .githooks once, themselves. A line in the README or in the project's setup script is enough. Tools like Husky use the same core.hooksPath setting behind the scenes.
Config-Based Hooks (Git 2.54)
In the classic model there is a single file per event: one pre-commit. If you want two separate checks, you squeeze both into the same script. Git 2.54 added hooks defined through config: you give each hook a name and say separately which command runs on which event. Several hooks can be attached to one event, and they can come from different config levels.
On Alan's machine, let's define a global secret check that runs in all of his repos, plus two hooks specific to this repo:
$ git config set --global hook.no-secrets.command '! git diff --cached | grep -q "PRIVATE KEY"'
$ git config set --global --append hook.no-secrets.event pre-commit
$ git config set hook.no-console.command .githooks/pre-commit
$ git config set --append hook.no-console.event pre-commit
$ git config set hook.subject-length.command .githooks/commit-msg
$ git config set --append hook.subject-length.event commit-msg
$ git hook list pre-commit
no-secrets
no-console
$ git hook list --show-scope pre-commit
global no-secrets
local no-console
event is a multi-valued setting, which is why we use --append; to run the same hook on both pre-commit and pre-push you just add a second line. command can be a file path or a one-line shell command. Hooks run in the order they are found in the config; the global file is read before the local one, so no-secrets comes first.
$ git commit -m "Debug cart"
pre-commit: remove console.log from staged files
$ git hook run pre-commit; echo $?
pre-commit: remove console.log from staged files
1
$ git hook list pre-push; echo $?
warning: no hooks found for event 'pre-push'
1
git hook run is the quickest way to fire an event and try its hooks without making a commit. If you want to turn off a global hook in a single repo, you don't have to delete its config:
$ git config set hook.no-secrets.enabled false
$ git hook list --show-scope pre-commit
global disabled no-secrets
local no-console
Classic .git/hooks files and core.hooksPath keep working; the hooks from config run first and the file in the hooks folder runs last. Git 2.55 added parallel execution on top of this (hook.<name>.parallel, hook.jobs); in 2.54 hooks run one after another.
Signing Commits with SSH
The Author line in a commit is just text. You can put any address in user.email and commit in anyone's name. A signature proves that the commit was really created by the owner of that key. GPG used to be the only way, and its setup put many people off. Since Git 2.34 you can sign with the SSH key you already have (OpenSSH 8.2p1 or later works, but because ssh-keygen in 8.7 is broken, Git recommends 8.8 or later).
In this example we generate a separate signing key. In real use, give it a passphrase and add it to ssh-agent; the -N "" here is only to keep the experiment quick:
$ ssh-keygen -q -t ed25519 -C "ada@example.com" -N "" -f ~/.ssh/ada_signing
$ git config set gpg.format ssh
$ git config set user.signingkey ~/.ssh/ada_signing.pub
$ git commit -S -m "Require qty in cart total"
[main fee0a05] Require qty in cart total
1 file changed, 1 insertion(+), 1 deletion(-)
$ git log --show-signature -1
error: gpg.ssh.allowedSignersFile needs to be configured and exist for ssh signature verification
commit fee0a053860a9b5245ca0d075df920be75ed36fd
No signature
Author: Ada Lovelace <ada@example.com>
Date: Sat Nov 7 10:08:00 2026 +0300
Require qty in cart total
Signing worked, verifying did not. "No signature" looks misleading, but the commit is signed; Git simply does not know whose key to check the signature against. With GPG that lived in the keyring; with SSH there is an allowed signers file that lists the keys you trust:
$ echo "ada@example.com namespaces=\"git\" $(cat ~/.ssh/ada_signing.pub)" > ~/.ssh/allowed_signers
$ git config set gpg.ssh.allowedSignersFile ~/.ssh/allowed_signers
$ git log --show-signature -1
commit fee0a053860a9b5245ca0d075df920be75ed36fd
Good "git" signature for ada@example.com with ED25519 key SHA256:XPYcjUZkeiPeLZANCSRHUZF5s7P4OJx7rdI47P8grdk
Author: Ada Lovelace <ada@example.com>
Date: Sat Nov 7 10:08:00 2026 +0300
Require qty in cart total
Instead of typing -S on every commit, make signing the default; the same setting exists for annotated tags:
$ git config set commit.gpgSign true
$ git config set tag.gpgSign true
$ git commit -m "Mark books module v1"
[main 28ab277] Mark books module v1
1 file changed, 1 insertion(+)
$ git tag -m "First release" v1.0.0
$ git log --format="%h %G? %GS %s"
28ab277 G ada@example.com Mark books module v1
fee0a05 G ada@example.com Require qty in cart total
53f5fc8 N Multiply price by quantity in cart total
4734313 N Add cart total
14693d0 N Add bookstore skeleton
$ git verify-commit HEAD
Good "git" signature for ada@example.com with ED25519 key SHA256:XPYcjUZkeiPeLZANCSRHUZF5s7P4OJx7rdI47P8grdk
%G? gives the signature status as a single letter: G for good, N for unsigned. We set things at the repo level here; on your own machine it is more practical to do this once with --global. The settings are still called gpg.* because SSH support was added later on top of the same machinery.
The "Verified" Badge on GitHub
To verify locally you needed the allowed signers file; on GitHub the equivalent is the keys you add to your account. In Settings → SSH and GPG keys → New SSH key you choose "Signing Key" as the Key type and paste the public key. If you want to use the same key you authenticate with for signing as well, you have to add it once more on this screen as a Signing Key. From then on, when you push signed commits, GitHub puts a "Verified" badge next to a commit if the signature is valid and the email in the commit is a verified address on your account.
Two more details. GitHub signs the commits you make in its web interface and squash merges with its own key, so they show up as "Verified" too. Vigilant mode in your account settings marks your unsigned commits as "Unverified"; if someone commits with your email, the difference stands out right away. At team level, rulesets can make a branch accept only signed commits.
Common Pitfalls
The first is forgetting the executable bit. As in the opening example, Git only prints a hint and carries on. If you are not sure whether a hook runs, try it directly with git hook run pre-commit.
The second is writing a hook that looks at the working tree. git diff and git diff --cached show different things. The content that goes into the commit is in the index; your check should look there.
The third is a hook that does not say why it stopped you. The no-secrets above is a one-line command and prints nothing when it fails. A hook that blocks a commit without explaining why teaches the team the --no-verify habit in the first week. Print a message and keep the hook fast; a test suite that takes thirty seconds on every commit belongs in CI, not in pre-commit.
The fourth is the signing email. If the address in the allowed signers file, the committer email in the commit and the verified address on your GitHub account don't match, you won't see "Good signature" or "Verified" even if the signature itself is valid. If you use more than one email, check git config get user.email first.
The fifth is rebase and amend. With commit.gpgSign on, every commit you rebase is signed again, this time with your key. If you rebase someone else's signed commits, their signature goes away and yours takes its place.
A Small Experiment
★☆☆ In a test repo of your own, copy .git/hooks/pre-commit.sample to pre-commit, then add a file with trailing whitespace on a line and try to commit. What did the hook say? Read the script and find which Git command does that check.
★★☆ Redefine the no-secrets hook so that it prints no-secrets: private key in staged changes when it fails. Then attach the same hook to the pre-push event with git config set --append and look at the output of git hook list pre-push.
★★★ Sign a commit for a second person (Alan) with a separate key, but put only Ada in the allowed signers file. Which letter does git log --format="%h %G? %GS %s" show for Alan's commit? Find the difference between G, U and N in git help log.
With this post Block A, the core structure, is complete. In the next post we move on to versions: the changes from Git 2.23 to 2.39, the move from checkout to switch and restore, the pull warning, ort, and the release that brought the SSH signing we used here.