TT Lab
Get started
Learn Learning paths Courses

Git in Practice

The Hook That Tells You and the Hook That Stops You Live in Different Places

Continue in TT Lab

One-line summary

Client-side hooks (commit-msg, pre-commit, pre-push) give fast feedback, and server-side hooks and CI do enforcement. A client-side hook is bypassed with the single word --no-verify and does not even come along with a clone, so you must not hang rules on it expecting enforcement.

Why this is needed

A team acquires rules. Commit subjects in a fixed format, no committing files over 100 KB, no direct pushes to main. If you only write them in a document, they are not followed, and if you point them out in review, the commits have already piled up, so fixing them is a hassle.

If you put a script in .git/hooks/, git runs it at specific moments. Places such as right before a commit, right after you write the message, and right before a push. If the exit code is not 0, that action stops.

But .git/hooks/ has one big problem. The .git directory does not come along with a clone. However well I set it up, it is not on other people's machines. So every team attached guidance like "run the install script", and nobody ran it.

How it works

Since git 2.9, there is a core.hooksPath setting. It is a setting that changes the place where hooks are looked up.

git config core.hooksPath .githooks

Now .githooks/ is an ordinary directory that the repository tracks, so the hook scripts are committed and shared. However, the core.hooksPath setting itself is in .git/config, so each person still has to turn it on once. Putting that one line in the README or a bootstrap script is today's convention.

The commonly used three hooks are these.

Hook When What it receives If it blocks
pre-commit Right before the commit is created Nothing (it looks at the index itself) The commit does not happen
commit-msg After the message has been written The message file path ($1) The commit does not happen
pre-push Right before the push The remote name and address ($1,$2) + the list of refs on standard input The push does not happen

There is one trap in pre-commit. You must look at the index, not the working tree. What gets committed is the content that went into the index, and the working tree can change all it wants afterward. If you write a secret into a file and git add it, and then fix the file clean, a hook that reads the working tree catches nothing and the secret is committed as it is. You read the index content with git show :<경로> or git cat-file -p <blob>.

pre-push receives on standard input, one line per ref, 로컬참조 로컬해시 원격참조 원격해시 (local ref, local hash, remote ref, remote hash). You use it by blocking if there is a line where 원격참조 (the remote ref) is refs/heads/main.

What client-side hooks cannot stop

There are three things.

First, --no-verify. git commit --no-verify skips pre-commit and commit-msg, and git push --no-verify skips pre-push. This is not a flaw but a design — a client-side hook is a tool that I turn on, on my machine, and it must be possible to turn it off in an emergency.

Second, someone who has not turned on the setting. core.hooksPath must be turned on by each person.

Third, commits that come in by another route. If you edit directly in a web UI, if another tool pushes it in, or if you use a client that does not support hooks, they are not run in the first place.

So the place that actually enforces a rule is the server. That means the server-side pre-receive and update hooks, the hosting service's branch protection, and CI checks. There is nothing like --no-verify there. Instead the feedback is slow — you get a rejection only after you have already stacked commits and pushed.

To sum up, you hang the same rule in two places. A client-side hook is the side that tells you in 30 seconds, and the server is the side that stops it in the end. If you hang it only on the client, it gets bypassed, and if you hang it only on the server, people find out only after pushing every time.

What it looks like in the field

What you will do in the next lab

You put hooks in the repository with core.hooksPath and build the subject rule and the big-file and secret blocking yourself. You reproduce why a hook that only looks at the working tree gets bypassed, by creating a state in which the secret is only in the index, and then get past all three hooks with the single word --no-verify. You take a clone and confirm that the hook files come along but the setting does not, and then try, with pre-push, blocking a direct push to main.

The official documentation is githooks, git-config and Pro Git 8.3 Git Hooks.