Lesson 3 of 5 in Power Tools

Hooks

Story: The bureau checks every report before it's filed. Let git do the checking for you.

A hook is a script git runs at a set moment, such as just before it makes a commit. If the script says no, git stops. Teams use hooks to catch mistakes before they become commits: a forgotten TODO, or a message that doesn't follow the team's style.

Where git looks for hooks

Each hook is a file named after the moment it runs. git runs several when you commit, and this team uses two of them:

  • pre-commit runs first, before git makes the commit. It can look at what you've staged.
  • commit-msg runs once the message is written. It can check the message.

By default, git looks for hooks in .git/hooks. Nothing in .git is committed, though, so hooks there stay on one computer. That's why teams commit their hooks in an ordinary folder of the project instead, and everyone points git at it with the core.hooksPath setting:

$ git config core.hooksPath hooks

A relative path like hooks starts at the top folder of the repository, wherever you are inside it. Like user.email, the setting is for this repository only, and it isn't committed: everyone who clones the project turns the hooks on for themselves. With --global, it's for every repository, and a relative path then starts at the top folder of whichever repository you're in.

git config core.hooksPath prints the setting. It prints nothing while the setting isn't there.

$ git config core.hooksPath
hooks

Ready-made hooks

Hooks are usually shell scripts. In this Terminal you never write one: projects come with ready-made hooks. cat hooks/pre-commit shows one, with a comment at the top that says what it checks. This team has two:

File Ready-made hook It stops the commit when
hooks/pre-commit pre-commit/no-todo a file the commit adds or changes has TODO in it
hooks/commit-msg commit-msg/conventional the message's first line doesn't start with a type and a colon

The message style commit-msg asks for is called Conventional Commits. A message starts with a type that says what kind of change it is, then a colon and a space, then what the change does: feat: add a contact page for a new feature, or fix: correct a typo for a bug fix. This team's types are feat, fix, docs, style, refactor, test and chore. The team writes them in lowercase, but Conventional Commits doesn't mind case, so the hook lets Fix: through too. A scope in brackets may follow the type: feat(contact): add a map.

Watching the hooks work

With the hooks on, you stage a new page, and your to-do list by mistake:

$ git add .
$ git commit -m "Add a contact page"
pre-commit: notes.txt has TODO in it.
pre-commit: commit stopped. Finish the work, or unstage what isn't ready.

pre-commit failed, so git made no commit. What you staged is still staged. Take notes.txt out of the staging area and try again:

$ git restore --staged notes.txt
$ git commit -m "Add a contact page"
commit-msg: "Add a contact page" doesn't start with a type.
commit-msg: commit stopped. Start with feat, fix, docs, style, refactor, test or chore and a colon, like "fix: correct a typo".

This time pre-commit passed, and commit-msg stopped the message. A new page is a new feature:

$ git commit -m "feat: add a contact page"
[main 701f19d] feat: add a contact page
 1 file changed, 2 insertions(+)
 create mode 100644 contact.html

A hook that passes prints nothing. Both passed, so git made the commit.

Skipping the hooks: --no-verify

Sometimes you want a commit the hooks would stop. Say you're halfway through a map for the contact page, on a branch of your own, and you have to stop for the day. A commit keeps the unfinished work safe:

$ git switch -c map
Switched to a new branch 'map'
$ echo "<p>TODO: add a map</p>" >> contact.html
$ git commit -am "WIP: contact page"
pre-commit: contact.html has TODO in it.
pre-commit: commit stopped. Finish the work, or unstage what isn't ready.
$ git commit --no-verify -am "WIP: contact page"
[map 97c453d] WIP: contact page
 1 file changed, 1 insertion(+)

--no-verify, or -n for short, makes this one commit without running pre-commit or commit-msg. The hooks stay on for the next commit. Keep it for your own work in progress, and finish the work before you share it.

Hooks are a safety net, not a lock. Anyone can skip them, and someone who never set core.hooksPath doesn't run them at all. Teams that need a rule kept check it again where they share their work.

Which commands run them

git commit runs both hooks, also with -a and --amend, and when it finishes a merge. git merge and git pull run commit-msg when they make a merge commit. git writes Merge branch ... as that commit's message, which has no type, so this team's hook stops it, and git leaves the merge unfinished.

Say you go back to main, where your contact page commit isn't pushed yet. Meanwhile, a teammate has pushed a fix, so git pull has a merge to make:

$ git switch main
Switched to branch 'main'
Your branch is ahead of 'origin/main' by 1 commit.
  (use "git push" to publish your local commits)
$ git pull
From https://git.example/team/project
   9040a3b..0d54e00  main       -> origin/main
commit-msg: "Merge branch 'main' of https://git.example/team/project" doesn't start with a type.
commit-msg: commit stopped. Start with feat, fix, docs, style, refactor, test or chore and a colon, like "fix: correct a typo".
Not committing merge; use 'git commit' to complete the merge.

You can finish the merge with a message the hook accepts: git commit -m "chore: merge origin/main". Or give it up with git merge --abort and run git pull --rebase instead. A rebase makes no merge commit, so there's no message to check.

git cherry-pick, git revert and git rebase make their own commits without these two hooks. If one of them stops on a conflict and you commit the result with git commit, that commit runs both, like any other. git cherry-pick --continue and git revert --continue finish with a git commit too, so they run both as well. git rebase --continue runs neither.

One of their own commits does run them: for a reword in git rebase -i, git runs both hooks, pre-commit, then commit-msg on the new message. This Terminal doesn't follow a hook that stops a rebase halfway, so while the hooks are on, it refuses a list with a reword line, and nothing changes. To change the newest commit's message, use git commit --amend, which runs both hooks as any commit does.

On your own computer

A hook is any script git can run, so on your own computer you can write your own, or copy one into .git/hooks. git ignores a hook file that isn't executable, so make it executable with chmod +x. There are more hooks too, such as pre-push, which runs before git push. This Terminal runs only ready-made hooks, like this team's two.

Command What it does
git config core.hooksPath <folder> Runs the hooks in that folder, in this repository. A relative path starts at the repository's top folder.
git config core.hooksPath Prints the folder hooks run from, if it's set.
git commit --no-verify Makes this commit without running pre-commit or commit-msg. Short: -n.

New commands

  • git config core.hooksPath
  • git commit --no-verify

Explainer: Hooks check your commits

project: your repo, you are here8cf6c62 Add a README8cf6c6290c87bc Add the home page90c87bcf38c529 Style the headingf38c5299040a3b chore: add the team's hooks9040a3bbf20147 feat: add a contact pagebf2014758df559 WIP: contact page58df559origin/HEADorigin/mainmainHEAD → map
The repository at the end of the Explainer.
Graph as text

project: your repo, you are here

  • HEAD → map at 58df559 "WIP: contact page"

Branches and tags

  • main at bf20147 "feat: add a contact page", tracks origin/main
  • map at 58df559 "WIP: contact page"
  • origin/HEAD at 9040a3b "chore: add the team's hooks"
  • origin/main at 9040a3b "chore: add the team's hooks"

Commits, newest first

  1. 58df559 "WIP: contact page"; parent bf20147; refs: HEAD → map
  2. bf20147 "feat: add a contact page"; parent 9040a3b; refs: main
  3. 9040a3b "chore: add the team's hooks"; parent f38c529; refs: origin/HEAD, origin/main
  4. f38c529 "Style the heading"; parent 90c87bc
  5. 90c87bc "Add the home page"; parent 8cf6c62
  6. 8cf6c62 "Add a README"; no parent

Not shown: origin, teammate.

Challenge 1 of 3

Story: Every report the bureau files passes its checks first. From today, so does yours.

Your team keeps its hooks in the project's hooks folder, but git doesn't run them in your copy yet. Turn them on for this repository. Then commit your new page, contact.html, in a commit the hooks let through.

Unlocks after Rewriting History.

Terminal

Unlocks after Rewriting History.

This Lesson can be played once Rewriting History is complete. Its text and Explainer are open to everyone.