--- tags: devtools2022 --- # Git Hooks **TL;DR**: **Scripts** that run automatically every time a particular **event** occurs in a Git repository. **Use case**: - Encourage commit policy - Alter project env - Implement CI workflow - Linting/running tests ### .git/hooks ![](https://i.imgur.com/vSdedd4.png) > To “install” a hook, all you have to do is remove the `.sample` extension with the rest of filename to be the **same**. There are **many** types, and some accepts arguments while some don't. You can read the full documentation [here](https://git-scm.com/docs/githooks). [This article groups the hooks better](https://git-scm.com/book/en/v2/Customizing-Git-Git-Hooks) based on different sections such as committing workflow hooks, email scripts, etc. **Exit value**: If exit value is **not 0**, then the hook will stop the workflow. ### Scope and Language Hooks are **local** to any given Git repository, and they are **not** copied over to the new repository when you run git clone. Typical git hooks are regular `bash` scripts but you can make any scripts as long as you modify the `shebang` line: ```python #!/usr/bin/env python import sys, os commit_msg_filepath = sys.argv[1] # stdout with open(commit_msg_filepath, 'w') as f: f.write("# Please include a useful commit message!") ``` If you want to **share** hooks, do: - **Store** your hooks in the actual project directory (above the .git directory). - You can **edit** them like any other version-controlled file. To **install** the hook after `clone`, you can either: 1. Create a symlink to it in `.git/hooks`, OR 2. Copy and paste it into the `.git/hooks` directory whenever the hook is updated. > The former is obviously better. ``` ln -sf [SOURCE_HOOK_DIR] ./git/hooks ``` ## Local Hooks (Client Side) ### `pre-commit` **Usage**: **inspect** the snapshot that is about to be committed: - Run some automated tests - Lint the code There's **no argument** expected. For example, we can run some tests and lint the code: ```bash #!/bin/sh echo "Linting the project...." git stash -q --keep-index npm run lint status=$? git stash pop -q exit $status ``` > Note: `$?` is the **exit status** of the last executed command. Add the following config in `.eslintrc.json`: ```json { "extends": ["next/core-web-vitals", "eslint:recommended"], "rules": { "no-unused-vars": [ "error", { "vars": "all", "args": "after-used", "ignoreRestSiblings": false } ] } } ``` Upon successfull commit, we have: ![](https://i.imgur.com/afAYHc9.png) If the commit is unsuccessful, we have: ![](https://i.imgur.com/4u49gWh.png) #### Run test Suppose we have a toy file to test called `calculator.js`: ```js function add(numbers) { return numbers .split(",") .map((x) => parseInt(x)) .reduce((a, b) => a + b); } function subtract(numbers) { return numbers .split(",") .map((x) => parseInt(x)) .reduce((a, b) => a - b); } exports.add = add; exports.subtract = subtract; ``` Save it under `./scripts/calculator.js`. We would like to test it using [`jest`](https://jestjs.io). Firstly, simply install `jest`: ``` npm install --save-dev jest npm install --save-dev eslint-plugin-jest ``` To prevent ESLint from complaining when you're writing the test file, add the following in `.eslintrc.json`: ```json "plugins": ["jest"], "env": { "jest/globals": true } ``` To run tests with `jest`, we need to create a test file, `calculator.test.js` inside a directory called `./tests/` (this is just to be neat). ```js const calculator = require("../scripts/calculator"); test("adds 1 + 2 to equal 3", () => { expect(calculator.add("1,2")).toBe(3); }); test("subtract 3 - 1 - 3 equal -1", () => { expect(calculator.subtract("3,1,3")).toBe(-1); }); ``` Then in `package.json`, we add the `test` under scripts: ```json "scripts": { "dev": "next dev", "build": "next build", "start": "next start", "lint": "next lint", "test": "jest" }, ``` We can do simply `npm test`: ![](https://i.imgur.com/Pb3GLtq.png) Now all that's left is to connect it to git pre-commit hook: ```bash #!/bin/sh echo "Running Lint and Test...." git stash -q --keep-index npm test status=$? git stash pop -q When you pass the linting and test stage, the commit is made ``` ![](https://i.imgur.com/2lGNByu.png) Note that since git hooks are local, this hook will **not** automatically be added for others who clone your repo. If you'd like to pass the hook around, you need to **store** the hook script somewhere, e.g: `./scripts/pre-commit`, then add a script in `package.json`: ```json "scripts": { "prestart": "cp scripts/pre-commit .git/hooks/ && chmod +x .git/hooks/pre-commit && echo 'Pre-commit hook installed'", ... } ``` > Note: here we assume that we want to copy this once before `npm start` is called. For more details about npm scripts, see [here](https://docs.npmjs.com/cli/v8/using-npm/scripts). ### `prepare-commit-msg` **Usage**: prepare-commit-msg hook is called after the pre-commit hook to **populate** the text editor with a commit message. There are **three** arguments that might be passed: 1. Temp file name that contains the message 2. Type of commit `-m, -F, -t`, etc 3. The SHA1 hash of the commit, only given if `--amend` opt is given. Let's try setting it up with some standard echo: ```bash #!/bin/sh echo "# Please include a useful commit message!" > $1 ``` > `$1` is the **first** command-line argument passed to the shell script, which is the file that should contain the commit message to be printed. ![](https://i.imgur.com/oCRQjTA.png) One thing to keep in mind when using `prepare-commit-msg` is that it **runs** even when the user passes in a message with the `-m` option of git commit. - This means that the above script will automatically insert the echoed string **without** letting the user edit it. ### `commit-msg` **Usage**: called **after** the user enters a commit message. This is an **appropriate** place to **warn** developers that their message doesn’t adhere to your team’s standards. **Argument**: name of the file that contains the message. Paste the following code in `.git/hooks/commit-msg`: ``` #!/bin/bash bug_regex_commit_format='(^T[0-9]{3,5}[:] )|^((fix|feat|build|chore|ci|docs|perf|test|refactor): )' error_msg="Commit not allowed, message should have Ticket number or Feature while committing code" if ! grep -iqE "$bug_regex_commit_format" "$1"; then echo "$error_msg" >&2 echo "Below are sample commits" echo " T12345: message" echo " feat: message" exit 1 else echo "Commit is successful" fi ``` When you typed a wrong commit message, it will abort: ![](https://i.imgur.com/eRiBw3F.png) When successful: ![](https://i.imgur.com/ASnLlNZ.png) Of course there are great [commit linting tools](https://commitlint.js.org/) out there and there's no point reinventing the wheel, but knowing how it works from scratch is useful sometimes should you want to customise certain things unique to your project. ### `post-commit` **Usage**: called after `commit-msg`, but it can’t change the outcome of the git commit operation, so it’s used primarily for **notification** purposes: - Get the new commit's SHA-1 hash - Get all of its info It has **no parameters**. For example, create this file at `.git/hooks/post-commit` ```bash #!/usr/bin/env python from subprocess import check_output # Get the git log --stat entry of the new commit log = check_output(['git', 'log', '-1', '--stat', 'HEAD'], encoding='utf-8') print(log) ``` ![](https://i.imgur.com/eLEIqZX.png) Note that this is not to be confused with `post-receive` hook, and is usually not used to notify others. The post-receive hook gets called at the **server** side after a **successful** push operation, making it a good place to perform notifications. > For many workflows, this is a **better** place to trigger notifications than `post-commit` because the changes are available on a public server instead of residing only on the user’s local machine. ## Server Side Hooks These hooks reside in server repositories. Popular server side hooks let you react to different stages of the git `push` process. ### Output The output from server-side hooks are **piped** to the client’s console, so it’s very easy to send messages back to the developer, but they don't return control until execution is finished (will hang there). > Be careful about executing long-running ops. ### `prereceive` and `postreceive` The pre-receive hook is triggered **once** every time somebody uses git `push` to `push` commits to the repository (before anything is done when `push` is triggered), and post-receive is executed once a successful `push` is made. > It should always reside in the remote repository that is the destination of the push, not in the originating repository. **Usage**: The hook runs before any references are updated, so this is a good place to enforce any kind of development policy that we want: * Check who made the push * Commit messages not good * Didn't contain good changes in the commit, etc There's **no parameter** (no CLI argument), but each ref being pushed is passed to the script on a separate **line** in `stdin`. ```python #!/usr/bin/env python import sys import fileinput # Read in each ref that the user is trying to update for line in fileinput.input(): print "pre-receive: Trying to push ref: %s" % line # Abort the push # sys.exit(1) ``` The **post-receive** hook gets called after a **successful** push operation, making it a **good** place to perform notifications. The script takes no parameters, but is sent the same information as pre-receive **via standard input**. Since we live in modern days, there's no point in writing server-side post-receive hooks from scratch. There are plenty of [examples online](https://github.com/Libermentix/git-post-receive), and if you use remote repo servers like GitHub, simply use their [webhook](https://docs.github.com/en/developers/webhooks-and-events/webhooks/creating-webhooks#setting-up-a-webhook) feature. > This is also another good [simple example](https://gist.github.com/nonbeing/f3441c96d8577a734fa240039b7113db#file-post-receive). This is also a good tutorial to [build hooks from scratch](https://www.digitalocean.com/community/tutorials/how-to-use-git-hooks-to-automate-development-and-deployment-tasks) if you manage your own remote repo so that you can automatically **deploy**. ### `update` This hook works similarly like `pre-receive` but is called separately for **each ref** that was pushed (each commit). > A [Git reference](https://docs.github.com/en/rest/git/refs) ( git ref ) is a file that contains a Git commit SHA-1 hash. When referring to a Git commit, you can use the Git reference, which is an easy-to-remember name, rather than the hash. **It accepts 3 args**: 1. The name of the ref being updated 2. The old object name stored in the ref 3. The new object name stored in the ref You can play around by trying simple printouts: ```python #!/usr/bin/env python import sys branch = sys.argv[1] old_commit = sys.argv[2] new_commit = sys.argv[3] print "Moving '%s' from %s to %s" % (branch, old_commit, new_commit) # Abort pushing only this branch # sys.exit(1) ``` ### Github Action and Webhook We cannot run server-side hooks on GitHub for obvious security reasons. The alternative to that is WebHooks and GithubAction (called after **push** is done). Github actions **does not** run on your personal machine, but runs after you have pushed your changes to github. Then, github basically **spins** up virtual environments and does whatever tests needed. This makes sense for package development, to make sure your package can be installed on multiple operating systems. You can run other **unit** tests at this stage on those systems as well. > But for certain tests that only make sense on your local system (say functions to generate database connections) github actions will not make sense.