Create your own pre-commit hook
Tags: tutorial, git, hook, python
Published: 2022-02-23 15:31:49

In this tutorial we are going to create a very basic git hook, using the awesome package [`pre-commit`](https://pre-commit.com/), created by the one and only [Anthony Sottile](https://github.com/asottile) 👏👏.

The hook will be very basic. It won't have unit-tests nor will be very functional for a real project. Although it will show you the first steps you need to take in order to create something incredible.

> I recommend you using a virtual environment to follow this tutorial.

## Tutorial Index
1. [Create the structure of the project](#1)
2. [Create the functionality](#2)
3. [Turn it into a python package](#3)
4. [Turn it into a hook](#4)
5. [Test it](#5)

## 1. Create the structure of the project <a name="1"></a>
Create the following project structure:

```
.
├── test-the-hook-in-this-folder
└── the-hook
    ├── print_arguments
    │   ├── __init__.py
    │   └── main.py
    ├── setup.cfg
    └── setup.py
```

* `test-the-hook-in-this-folder`: Folder in which we will test our hook by the end of the tutorial (we won't use it until step 5).
* `the-hook`: Folder that contains all the files required for the package.
* `print_arguments`: This is our python package (and also a spoiler of what our hook will do).
* `__init__.py`: Turn the folder that contains it into a package.
* `main.py`:  Here is where the logic of the hook will be.
* `setup.py`: Necessary for building our package.
* `setup.cfg`: Describe our package.

## 2. Create the functionality <a name="2"></a>
> We will work in the `the-hook` folder for steps 2 to 4.

For creating the functionality, we only need to edit the `main.py` file:

```py
# print_arguments/main.py
import argparse


def print_arguments(arguments: list[str]):
    for argument in arguments:
        print(argument)


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("filenames", nargs="*")
    args = parser.parse_args()

    print_arguments(args.filenames)


if __name__ == "__main__":
    main()
```

Yes, our hook will print the arguments we pass to it. We can test its functionality:

```cli
python print_arguments/main.py arg1 arg2 arg3
```

Output:
```
arg1
arg2
arg3
```

## 3. Turn it into a python package <a name="3"></a>
To do this, we need to edit our 2 setup files.

**setup.py:**
```py
# setup.py
from setuptools import setup

setup()
```

**setup.cfg:**
```cfg
# setup.cfg
[metadata]
name = print-arguments
description = print the arguments you pass
version = 0.1.0
author = Jorge Alvarado
author_email = alvaradosegurajorge@gmail.com
license = MIT
url = https://jorgealvarado.me

[options]
packages = find:

[options.entry_points]
console_scripts =
    print-arguments = print_arguments.main:main
```

Important points:
* `packages = find:`: It help us find our `print_arguments` package when packaging.
* `console_scripts`: This is our entry point, with this we kind of expose our function to the world. The name of our single entry point, in this case `print-arguments`, will be used by `pre-commit`.

> You can choose other values for the rest of the points.

Install the package with:

```
pip install .
```

> Make sure you are in `the-hook` folder when running that command.

## 4. Turn it into a hook <a name="4"></a>
For our hook to work we need our project to be a git repository. So let's do that by running the following commands (make sure you are in the `the-hook` folder):

Initialize the git repository:

```
git init
```

Add every file and commit them:

```
git add .; git commit -m "Create the package"
```

Now the hook part. First let's install `pre-commit`:

```
pip install pre-commit
```

Create a `.pre-commit-hooks.yaml` file inside the `the-hook` folder and edit it:

```yaml
# .pre-commit-hooks.yaml
- id: some-id
  name: some-name
  description: some description
  entry: print-arguments
  language: python
```

* `id`: The id of our hook. We will need it for testing and in the future if someone wants to use our hook, the id will be required.
* `name`: The name of the hook, it is what is shown during hook execution.
* `description` (optional): Description of the hook.
* `entry`: The entry point, the executable to run. It has to match with the entry point name defined inside our `setup.cfg`.
* `language`: The language of the hook, it tells `pre-commit` how to install the hook.

> 👀 See a [complete list](https://pre-commit.com/#new-hooks) of the options.

To complete this step, we need to do a commit of our hook stuff:

```
git add .; git commit -m "Create a hook"
```

## 5. Test it <a name="5"></a>
Time to use the `test-the-hook-in-this-folder` folder. Change directory to that folder, once there, let's create a very little git project:

Create some files
```
touch a.py b.py
```

Initialize the git repository:
```
git init
```

Commit the files:
```
git add .; git commit -m "Create the project"
```

Finally, let's test our hook:

```
pre-commit try-repo ../the-hook some-id --verbose --all-files
```

Output:
```
some-name................................................................Passed
- hook id: some-id
- duration: 0.08s

a.py
b.py
```

Notice:
* We got `some-name` in the output, because that's what we defined in our `.pre-commit-hooks.yaml` file.
* We used `some-id` (defined in the `.pre-commit-hooks.yaml` file as well) in the command to refer to our hook (we could have multiple hooks).
* `a.py` and `b.py` were printed. That's because we used the flag `--all-files` in our command (If we didn't, the hook execution would have been skipped, because there are no new/edited files for git).

> Remember that you need to be in the `test-the-hook-in-this-folder` folder for the command just used to work.

If we now add a new file to our test folder:

```
touch c.py
```

We track it with git:

```
git add c.py
```

And then we run the command without the `--all-files` flag:

```
pre-commit try-repo ../the-hook some-id --verbose
```

We get:
```
some-name................................................................Passed
- hook id: some-id
- duration: 0.07s

c.py
```

As you can see, only `c.py` was printed this time, because is the only new/edited file for git, so our hook was run only against that file. This is the behavior you will normally want.

---

Now you know how to create a pre-commit hook 🪝! Go and make cool things with this knowledge 😁.

Let me know if you create a hook or something. [This is one](https://github.com/jalvaradosegura/version-checker) I created a few weeks ago.