Hooks You Will Actually Use.
Stop a Bad Commit Before It Exists, and Know What a Hook Cannot Stop

Somebody commits a .env file with a real password in it. Nobody notices until it is pushed.
You can write a rule for that in a README. You can mention it in review. Or you can make Git refuse the commit before it exists.
A hook is a script that Git runs at a fixed moment, such as just before a commit is created. If the script exits with an error, Git stops.
We will write two hooks, make them part of the repository so a teammate gets them, and then find out exactly how to get around them, because that decides what hooks are for. This is the result, and it does not promise more than it shows:
$ git commit -m "Add env"
pre-commit: refusing to commit a .env file.
What a hook is, and where it lives#
Every repository starts with a .git/hooks folder full of examples:
$ 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
Git ignores anything ending in .sample. To turn one on, you make a file with the hook's name and no extension: pre-commit, not pre-commit.sh.
The two we will write:
pre-commitruns before Git asks for a commit message. It can look at what is staged. A non-zero exit stops the commit.commit-msgruns after you write the message. Git gives it the path of the file that holds the message.
A pre-commit hook that checks one thing#
A hook is just a script. This one stops a .env file from being added:
#!/bin/sh
# Stop a .env file from being added to a commit.
if git diff --cached --name-only --diff-filter=AM | grep -q "\.env$"; then
echo "pre-commit: refusing to commit a .env file."
exit 1
fi
git diff --cached --name-only lists the files in the staging area. --diff-filter=AM keeps only those that are Added or Modified. Without that part, the hook has a bug we hit in our test: a commit that removes .env from Git also lists .env, so the hook blocked the very commit that fixed the problem.
$ git commit -m "Stop tracking env"
pre-commit: refusing to commit a .env file.
With --diff-filter=AM, the same commit went through:
[main 6aa0a43] Stop tracking env
1 file changed, 1 deletion(-)
delete mode 100644 .env
Windows: who runs it?#
The first line, #!/bin/sh, is the shebang: it names the program that runs the file. Windows has no /bin/sh, and Git for Windows brings its own. Git runs hooks with that bundled shell, so the hook works no matter which terminal you start Git from. We committed from Git Bash and from PowerShell, and in both the hook ran and refused.
The executable bit is the other thing everybody asks about. On Linux and macOS, a hook must be executable or Git skips it. On Windows, there is no such bit on the file system. In our test, chmod -x on a hook in .git/hooks changed nothing: ls -l still showed -rwxr-xr-x, and the hook still ran. So on Windows, you do not need chmod for the hook to run for you.
It matters when a teammate on Linux or macOS gets your hook through the repository. That comes next.
Make everybody get the hooks#
.git/hooks is inside .git, and .git is never part of a clone. A fresh clone has the samples and none of your hooks. We check, in a clone of the same project:
$ git config core.hooksPath
$ ls -A .git/hooks | grep -c sample
14
The setting prints nothing, so it is not set. To share the hooks, we move them into a folder that is tracked, .githooks, and tell Git to look there:
$ git config core.hooksPath .githooks
$ git config core.hooksPath
.githooks
Two details:
- The setting is per clone. It lives in
.git/config, which is not shared either. Every person runs that one command once, or a setup script does. In our test, a teammate's fresh clone had the.githooksfolder, and a commit with a.envin it went straight through, because the setting was not there yet. - Record the executable bit in Git. The files are
100644by default on Windows. Make them100755, so a teammate on Linux or macOS gets runnable scripts:
$ git ls-files -s .githooks/pre-commit
100644 d9a4c991b07dbd9b526df46c0956c7b5ac972df2 0 .githooks/pre-commit
$ git update-index --chmod=+x .githooks/pre-commit
$ git ls-files -s .githooks/pre-commit
100755 d9a4c991b07dbd9b526df46c0956c7b5ac972df2 0 .githooks/pre-commit
git add --chmod=+x <file> does the same thing when the file is new. After a commit and a push, the teammate runs the one config command, and:
$ git commit -m "Add env from clone with hooksPath"
pre-commit: refusing to commit a .env file.
To run a hook by hand without committing, git hook run pre-commit runs the same script and exits with its status.
A commit-msg hook#
The second hook reads the message file Git passes as $1. This one keeps the first line short:
#!/bin/sh
# commit-msg gets the message file as $1. Keep the first line short.
if [ "$(head -n 1 "$1" | wc -c)" -gt 51 ]; then
echo "commit-msg: first line is longer than 50 characters."
exit 1
fi
wc -c counts the newline too, which is why the limit is 51.
$ git commit -m "This subject line is much longer than fifty characters in total"
commit-msg: first line is longer than 50 characters.
$ git commit -m "Short line"
[main 958c11f] Short line
1 file changed, 1 insertion(+)
Keep hooks like these quick and narrow. A hook that takes a minute will be skipped by everyone, and it will be right to be.
Skip it, knowingly#
Sometimes the hook is wrong, or the file really is fine. --no-verify skips the pre-commit and commit-msg hooks for that one commit:
$ git commit --no-verify -m "Add env anyway"
[main bf37352] Add env anyway
1 file changed, 1 insertion(+)
create mode 100644 .env
-n is the short form. Git did not ask for a reason.
What hooks cannot enforce#
Keep this part in mind.
- They are optional for the person committing.
--no-verifyis one flag, and Git does not ask why. - They are not delivered by
git clone. Without thecore.hooksPathcommand in each clone, they do not run. We saw a commit with a.envgo through in a fresh clone. - They run on one machine. A hook cannot know what happens in another clone or on someone else's computer.
So a hook is a courtesy to the person committing: it tells them early, while the fix is cheap. It is not a gate. A check that must hold for the whole team has to run on the server, in CI or in branch protection, where nobody can pass a flag to skip it. This article does not test either of those.
The practical rule: put the fast, friendly checks in hooks and the checks you cannot live without in CI. Run the same script in both if you can.
Try it#
Here is the whole loop in six commands. The .githooks folder is already in the repository, with a pre-commit hook that refuses a .env file, and one is already staged. Type them one at a time.
Type these commands in order, in a practice folder:
- Tell Git to run hooks from the tracked .githooks folder. Try
git config core.hooksPath .githooks. This is stored in your clone's .git/config, so every clone needs it once. - Check that the setting took. Try
git config core.hooksPath. It prints.githooks. - Commit with a .env file staged and watch the hook refuse. Try
git commit -m "Add env". Git printspre-commit: refusing to commit a .env file.and creates no commit. - Decide the file is fine and skip the hooks for this one commit. Try
git commit --no-verify -m "Add env anyway".-nis the short form. Git does not ask why. - Record the pre-commit script as executable, so teammates on Linux and macOS can run it. Try
git update-index --chmod=+x .githooks/pre-commit.git add --chmod=+x .githooks/pre-commitdoes the same. - Confirm the mode. Try
git ls-files -s .githooks/pre-commit. The mode column shows100755, where100644means not executable.
Two things worth trying in the terminal: run step 3 before step 1 and see what the commit does, and use git commit -n in step 4.
Challenge: one more file type
Change the pre-commit hook so it also refuses a .pem file, because a private key is no better than a password. Removing a .pem file from Git must still work.
Expected result: with core.hooksPath set, git commit -m "Add key" with secret.pem staged prints pre-commit: refusing to commit a .env or .pem file. and makes no commit. After --no-verify has let the file in, git rm secret.pem followed by git commit -m "Stop tracking key" goes through.
Answer
#!/bin/sh
# Stop a .env or .pem file from being added to a commit.
if git diff --cached --name-only --diff-filter=AM | grep -Eq "\.(env|pem)$"; then
echo "pre-commit: refusing to commit a .env or .pem file."
exit 1
fi
-E turns on extended patterns, so (env|pem) works. Keep --diff-filter=AM, or the commit that removes the key is refused too.
What we learned#
- A hook is a script named after a Git moment (
pre-commit,commit-msg) with no extension. A non-zero exit stops the action. - Git for Windows runs hooks with its bundled shell, from Git Bash or PowerShell. The executable bit does not exist on the file system, but record it with
git update-index --chmod=+xfor teammates elsewhere. .git/hooksis not cloned. Put hooks in a tracked folder and rungit config core.hooksPath .githooksonce per clone.git commit --no-verify(or-n) skipspre-commitandcommit-msg.- A hook is a friendly early check on one machine. Anything the whole team must obey belongs in CI or on the server.
A hook that cannot be skipped is not a hook. It is a server.
Comments require JavaScript.