How do Git's pre-commit, prepare-commit-msg and commit-msg hooks differ during one git commit?
answer
- Three hooks, one commit, fixed order
- One runs before any message exists
- One is a mutator, two are validators
- Its second argument names the message source
- One of the three ignores --no-verify
basics
~20 sThey fire in that order during one git commit: pre-commit inspects the staged content before any message exists, prepare-commit-msg seeds or rewrites the message file before the editor opens, and commit-msg validates the final message. All three can abort by exiting non-zero.
solid answer
~50 sDuring a single `git commit` Git runs three message-phase hooks in sequence. **`pre-commit`** takes no arguments and runs before the log message is obtained — it is where you check the staged content, so it is the hook that says "this diff has a debug print in it". **`prepare-commit-msg`** receives the path to the file holding the draft message, plus a source word (`message`, `template`, `merge`, `squash`, or `commit` followed by an object name) and is meant to *edit that file in place* before the editor opens — prefixing an issue key from the branch name is the classic use. **`commit-msg`** also receives the message file path, but runs after the author has finished writing, so it is the right place to enforce a format and reject a bad one. All three abort the commit on a non-zero exit. The trap: `--no-verify` bypasses `pre-commit` and `commit-msg` but is documented as *not* suppressing `prepare-commit-msg`.
code
bash · 19 lines#!/bin/sh
# .git/hooks/prepare-commit-msg
# $1 = path to the message file, $2 = source (message|template|merge|squash|commit)
msg_file="$1"
source="$2"
case "$source" in
merge|squash) exit 0 ;; # leave generated messages alone
esac
branch=$(git symbolic-ref --short HEAD 2>/dev/null) || exit 0
case "$branch" in
PROJ-*)
key=$(printf '%s' "$branch" | cut -d/ -f1)
printf '[%s] %s' "$key" "$(cat "$msg_file")" > "$msg_file.tmp"
mv "$msg_file.tmp" "$msg_file"
;;
esac
exit 0go deeper
Know the names and the order: pre-commit looks at what is staged, commit-msg looks at the message text. Being able to say which one rejects a badly formatted subject is enough here.
Explain the arguments each hook receives, that prepare-commit-msg exists to rewrite the message file in place, and the --no-verify asymmetry. This is the tier where the details are expected.
Demonstrate judgment about placement and cost: content checks scoped to staged files, message checks as cheap regexes, and awareness that rebase-created commits skip pre-commit entirely.
Own the question of whether commit-message policy should live in a per-developer hook at all, given that it is advisory locally and must be re-asserted wherever the rule is actually binding.
## The sequence inside one commit `git commit` is not a single atomic step; it has a message phase, and three hooks are wired into it in a fixed order. 1. **`pre-commit`** — invoked before Git even obtains the proposed log message. 2. **`prepare-commit-msg`** — invoked right after Git prepares the default message text and before the editor is launched. 3. *(the author edits the message, if an editor is used)* 4. **`commit-msg`** — invoked with the finished message. 5. **`post-commit`** — invoked after the commit object exists, purely for notification. Understanding *why* they sit at those points is what the question is really testing. ## pre-commit: about the content, not the message `pre-commit` takes no parameters. Because it runs before the message exists, it can only look at what is staged. That is exactly what you want for content checks: formatting, forbidden files, leftover debug statements, secrets. Since the commit is built from the index, the hook should read staged content — `git diff --cached --name-only --diff-filter=ACM` for the file list, `git show :path/to/file` for the staged blob — rather than the working tree, which may contain unstaged edits. Git's own shipped sample catches lines with trailing whitespace using `git diff-index --check --cached`. ## prepare-commit-msg: a mutator, not primarily a gate `prepare-commit-msg` receives one to three arguments. The first is always the path of the file that contains the draft message. The second, when present, describes where the message came from: - `message` — a `-m` or `-F` option was given; - `template` — a `-t` option or `commit.template` config supplied it; - `merge` — the commit is a merge, or a `MERGE_MSG` file exists; - `squash` — a `SQUASH_MSG` file exists; - `commit` — `-c`, `-C` or `--amend` was used; a commit object name follows as the third argument. The stated purpose is to edit the message file **in place**. Typical uses are prepending a ticket identifier parsed from the branch name, injecting a checklist into the template, or expanding a scope prefix. Because the merge and squash sources exist, a well-written hook usually returns early for those rather than mangling a generated merge message. Exiting non-zero does abort the commit, but rejecting here is unusual — the human has not written anything yet. ## commit-msg: the validator `commit-msg` receives a single argument, the path of the file holding the proposed message. It runs after the author has written it, which makes it the natural place for a policy such as a Conventional Commits subject, a required issue reference, or a maximum subject length. Like `prepare-commit-msg` it is permitted to rewrite the file in place, so it can normalise as well as reject — for instance appending a trailer. Git's shipped sample detects duplicate `Signed-off-by` trailers and aborts. Note that Git invokes `commit-msg` for `git merge` as well as `git commit`. ## The --no-verify asymmetry This is the detail interviewers probe. `git commit --no-verify` bypasses `pre-commit` and `commit-msg`. It does **not** suppress `prepare-commit-msg`, which Git's documentation states explicitly. The reasoning is coherent: `--no-verify` turns off *verification*, and `prepare-commit-msg` is a message-construction step, not a verification step. A candidate who says "`--no-verify` turns off all the hooks" has memorised a slogan rather than the contract. ## Which hook for which job A useful rule of thumb: if the check is about **what changed**, it belongs in `pre-commit`; if it is about **what the message says**, it belongs in `commit-msg`; if you are **writing** part of the message for the author, it belongs in `prepare-commit-msg`. Putting a message-format check in `pre-commit` cannot work at all, because at that moment no message exists. ## Cost All three sit in the commit path, which developers hit dozens of times a day. Message hooks are cheap — a regex over one small file. Content hooks are the expensive ones, and the standard mitigation is to scope them to the staged file list rather than the whole repository.
- Where would you enforce that every commit message references an issue key, and why not in pre-commit?In `commit-msg`. It receives the path of the finished message file, so it can apply a regex and exit non-zero with an explanation. `pre-commit` runs before Git has obtained a message at all — there is nothing to inspect. If you also want to *supply* the key automatically from the branch name, add a `prepare-commit-msg` hook and keep `commit-msg` as the check.
- Does git commit --amend run these hooks?Yes. An amend is still a commit, so `pre-commit`, `prepare-commit-msg` (with source `commit` and the object name of the commit being amended) and `commit-msg` all run. Commits replayed by `git rebase` are different — they do not go through `pre-commit`, which is why a rebase can land content a fresh commit would have been blocked from creating.
- Why should a prepare-commit-msg hook usually return early when its second argument is merge?Because Git has already generated a structured merge message, and rewriting it destroys information such as the merged branch name. The same applies to `squash`. Editing only the `message` and `template` sources keeps the hook out of the way of Git's own generated text.
saying these in an interview costs you the question
- Claiming --no-verify disables all three commit hooks
- Putting a message-format check in pre-commit
- Thinking prepare-commit-msg validates the final message
- Linting the working tree rather than the staged index
- Assuming rebase replays commits through pre-commit