Originally published at blog.mphomphego.co.za on Oct 03, 2019
The Story
TL; DR
Native Git-hooks are very useful but have some shortcomings around installation, maintainability, and re-usability. But there's a great alternative...
TS; RE
Git-Hooks is a great way to execute custom actions triggered by various git events on the local machine, to identify bugs/issues before committing and pushing our work for review. Ideally, these hooks run on every commit to automatically point out issues/bugs in
the code, for example missing semicolons, trailing whitespace, docstrings
violations, Python module sorting, etc.
For the longest time, I have been using git-hooks mainly for pre-commit
and prepare-commit-msg
. You can find my deprecated git-hooks here
pre-commit
: A script is written inbash/shell
that gets called by agit commit
, and then execute linting depending on the file being staged. Exiting this hook with a non-zero status will abort the commit, which makes it extremely useful for last-minute quality checks.prepare-commit-msg
: A script called bygit commit
that automatically prepends the commit message with the Jira ticket link based on the branch name.
Githooks are great for pointing out any issues before committing any form of work and submitting for review, this allows the reviewer to direct their focus on the architecture of a change than wasting time on trivial style nitpicks.
IMHO, they have some shortcomings around installation, maintainability, and re-usability, let me try to explain.
-
Installation:
Git hooks normally live inside the
.git/hooks
folders of local repositories. They can contain one executable per trigger event, such aspre-commit
orprepare-commit-msg
, which is run automatically by various git commands. It sounds magical, so you'd think what's the problem with this?The
.git
folder is excluded from version control (i.e., You won't be able to track its life-cycle), it only lives on your machine. To get a hook in place, you either have to copy an executable file with the trigger’s name into the.git/hooks
folder or symlink one into it. It becomes cumbersome sharing hooks across various libraries or repositories and manually making changes to suit each repository. -
Maintainability:
The next problem is that you can have one of these files per event. You can then write a long Bash script (see my pre-commit, for instance) to execute multiple actions for one trigger or split the actions to several individual files, and then create the main entry point to run the additional functionalities in the divided files. It is fully feasible, but it is tedious to maintain
-
Re-usability:
The last point concerns the re-usability, both for the setup and the hooks. Doing so manually, you need to get the hook script and copy it or symlink it in each repository you want to use. You would also like to see your setup move with you when switching workstations/systems, without them going through the above-mentioned setup for each project.
You may also want to have these hooks executed for all same type of projects, for example, of updating Python dependencies or executing flake8 checks before each commit, etc. You would have to copy and create your hook for each project automatically, with an out-of-the-box setup, which over time became painful and tedious for me to use which led me to use my hooks less.
With all that said, do not get me wrong, I love the concept of Git hooks, and they can be very useful in several ways. However, portability and maintenance is an issue.
This post will detail why you should probably consider ditching your native git-hooks (Assuming you currently use them), reasons stated above. But nature abhors a vacuum, so we would need to replace native git-hooks with an alternative. Enters pre-commit.
The How
According to the website:
Pre-commit is a framework for managing and maintaining multi-language pre-commit hooks, which is far more flexible and feature-rich than native git-hooks.
pre-commit
makes it easier for the developer to specify a list of hooks they want and pre-commit
manages the installation and execution of any hook written in any language before every commit, in a completely isolated environment and does not require root
access.
Why Do You Need It
- Gently enforced best consistent practices between all team members or contributors.
- No need to fight with the reviewer about code formatting (PEP-8 violations), pre-commit can enforce it using the same coding standards across your team.
- Improved code quality == More green pipelines on your CI.
- Human time is expensive i.e. less time spent reviewing and quoting (PEP-8) coding standards.
The Walkthrough
Installation
Setting up pre-commit is straight forward, Run (alternatively read the docs.):
pip install pre-commit
Configuration
Once the package is installed you would need to configure pre-commit, so that it knows what you want - right!
Follow these simple steps and you should be good to go:
-
cd
to any repository you want to install pre-commit hooks. - create a file named
.pre-commit-config.yaml
(alternatively you can generate a template by running:pre-commit sample-config > .pre-commit-config.yaml
- If you opted for the option to create
.pre-commit-config.yaml
manually then copy and paste the following code to it.
# See https://pre-commit.com for more information # See https://pre-commit.com/hooks.html for more hooks repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v2.0.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer - id: check-yaml - id: check-added-large-files - repo: https://github.com/psf/black rev: 19.3b0 hooks: - id: black
Note: pre-commit
(hooks) are not restricted to one programming language.
You can add all kinds of hooks - linting, automatic formatting, safety checks, tests, custom scripts the list is endless alternatively you can use the official hooks.
Hooks Installation
To have pre-commit run every time you commit, run;
pre-commit install
Now every time you execute git commit
, pre-commit
will run the hooks defined above.
Note: git commit -n
bypasses/disables pre-commit
hooks, This is not advised consider benefits gained vs time.
Alternatively, if one would like to run pre-commit
manually from time to time.
Running pre-commit run --all-files
will run all hooks against current changes in your repository or,
If you wish to execute an individual hook use pre-commit run <hook_id>.
Example:
-
pre-commit run black
, this will execute black on the current working directory. Black is the uncompromising Python code formatter.
If like me you rather prefer to have pre-commit hooks
installed every time your clone or create a new repository. Run;
mkdir -p ~/.git-template
-
git config --global init.templateDir ~/.git-template
: This tells git to copy everything in ~/.git-templates to your project.git/
directory when you rungit init
-
pre-commit init-templatedir ~/.git-template
: This will install the current hooks into the template directory and ensure that every time you clone or create a repository pre-commit hooks will be installed.
Custom Hooks
It's easy to create your custom hooks and share them with the community.
Since I deprecated my native git-hooks, I have been constantly updating (WIP) my custom hooks which you are more than welcome to check out and contributions are welcome.
Alternatively see my .pre-commit-config.yaml
########################################################################################## # # # Pre-commit configuration file # # # ########################################################################################## --- default_language_version: python: python3 repos: ####################################### Various Checks ############################### # Various checks - repo: https://github.com/pre-commit/pre-commit-hooks rev: v2.3.0 hooks: - id: check-ast name: check-ast description: Simply check whether files parse as valid python. - id: check-builtin-literals name: check-builtin-literals description: Require literal syntax when initializing empty, or zero Python builtin types. - id: check-docstring-first name: check-docstring-first description: Checks for a common error of placing code before the docstring. - id: check-added-large-files name: check-added-large-files description: Prevent giant files from being committed. - id: check-merge-conflict name: check-merge-conflict description: Check for files that contain merge conflict strings. - id: check-symlinks name: check-symlinks description: Checks for symlinks which do not point to anything. - id: check-yaml name: check-yaml description: Attempts to load all yaml files to verify syntax. - id: check-toml name: check-toml description: Attempts to load all TOML files to verify syntax. - id: debug-statements name: debug-statements description: Check for debugger imports and py37+ breakpoint() calls in python source. - id: detect-private-key name: detect-private-key description: Checks for the existence of private keys. - id: end-of-file-fixer name: end-of-file-fixer description: Makes sure files end in a newline and only a newline. - id: trailing-whitespace name: trailing-whitespace description: Trims trailing whitespace - id: requirements-txt-fixer name: requirements-txt-fixer description: Sorts entries in requirements.txt - repo: local hooks: # Python minor syntax related checks # https://github.com/Pike/pygrep - id: python-check-mock-methods name: python-check-mock-methods description: Prevent common mistakes of `assert mck.not_called()`, `assert mck.called_once_with(...)` and `mck.assert_called`. language: pygrep entry: > (?x)( assert .*\.( not_called| called_ )| \.assert_( any_call| called| called_once| called_once_with| called_with| has_calls| not_called )($|[^(\w]) ) types: [python] - id: python-no-eval name: python-no-eval description: 'A quick check for the `eval()` built-in function' entry: '\beval\(' language: pygrep types: [python] - id: python-no-log-warn name: python-no-log-warn description: 'A quick check for the deprecated `.warn()` method of python loggers' entry: '(?<!warnings)\.warn\(' language: pygrep types: [python] - id: python-use-type-annotations name: python-use-type-annotations description: 'Enforce that python3.6+ type annotations are used instead of type comments' entry: '# type(?!: *ignore *($|#))' language: pygrep types: [python] # Python security check # https://bandit.readthedocs.io/en/latest/ - id: bandit name: bandit description: Find common security issues in your Python code using bandit entry: bandit args: [ '-ll', '--ini', 'setup.cfg', '--recursive', ] language: python types: [python] # Vulture # https://github.com/jendrikseipp/vulture - id: vulture name: vulture description: Find dead Python code entry: vulture args: [ "--min-confidence", "90", "--exclude", "*env*", "docs/", ".", ] language: system types: [python] # Jira Ticket Link Prepender - repo: https://github.com/mmphego/prepend-jira-ticket-link rev: master hooks: - id: prepend-jira-link description: Appends ticket number and link below commit message based on the branch name ####################################### Linters ###################################### - repo: local hooks: # Flake8 Linter # https://flake8.pycqa.org/en/latest/ - id: flake8 name: flake8 description: Python style guide enforcement entry: flake8 args: ["--config=setup.cfg"] additional_dependencies: [ flake8-2020, # flake8 plugin which checks for misuse of `sys.version` or `sys.version_info` flake8-blind-except, # A flake8 extension that checks for blind except: statements flake8-bugbear, # A plugin for flake8 finding likely bugs and design problems in your program. # Contains warnings that don't belong in pyflakes and pycodestyle. flake8-builtins, # Check for python builtins being used as variables or parameters. flake8-comprehensions, # It helps you write a better list/set/dict comprehensions. flake8-copyright, # Adds copyright checks to flake8 flake8-deprecated, # Warns about deprecated method calls. dlint, # Dlint is a tool for encouraging best coding practices and helping ensure we're writing secure Python code. flake8-docstrings, # Extension for flake8 which uses pydocstyle to check docstrings # flake8-eradicate, # Flake8 plugin to find commented out code flake8-license, pandas-vet, # A Flake8 plugin that provides opinionated linting for pandas code flake8-pytest, # pytest assert checker plugin for flake8 flake8-variables-names, # flake8 extension that helps to make more readable variables names flake8-tabs, # Tab (or Spaces) indentation style checker for flake8 pep8-naming, # Check PEP-8 naming conventions, plugin for flake8 ] language: python types: [python] # MyPy Linter # https://mypy.readthedocs.io/en/latest/ - id: mypy name: mypy description: Optional static typing for Python 3 and 2 (PEP 484) entry: mypy args: ["--config-file", "setup.cfg"] language: python types: [python] # PyDocstyle # https://github.com/PyCQA/pydocstyle - id: pydocstyle name: pydocstyle description: pydocstyle is a static analysis tool for checking compliance with Python docstring conventions. entry: pydocstyle args: ["--config=setup.cfg", "--count"] language: python types: [python] # YAML Linter - id: yamllint name: yamllint description: A linter for YAML files. # https://yamllint.readthedocs.io/en/stable/configuration.html#custom-configuration-without-a-config-file entry: yamllint args: [ '--format', 'parsable', '--strict', '-d', "{ extends: relaxed, rules: { hyphens: {max-spaces-after: 4}, indentation: {spaces: consistent, indent-sequences: whatever,}, key-duplicates: {}, line-length: {max: 90}}, }" ] language: system types: [python] additional_dependencies: [yamllint] # Shell Linter # NOTE: Hook requires shellcheck [installed]. - id: shellcheck name: shellcheck (local) language: script entry: scripts/shellcheck.sh types: [shell] args: [-e, SC1091] additional_dependencies: [shellcheck] # Prose (speech or writing) Linter - id: proselint name: proselint description: An English prose (speech or writing) linter entry: proselint language: system types: [ rst, markdown ] additional_dependencies: [proselint] ################################### Code Format ###################################### - repo: local hooks: # pyupgrade # Upgrade Python syntax - id: pyupgrade name: pyupgrade description: Automatically upgrade syntax for newer versions of the language. entry: pyupgrade args: ['--py3-plus'] language: python types: [python] additional_dependencies: [pyupgrade] # Sort imports # https://github.com/timothycrosley/isort - id: isort name: isort description: Library to sort imports. entry: isort args: [ "--recursive", "--settings-path", "setup.cfg" ] language: python types: [python] # Manifest.in checker # https://github.com/mgedmin/check-manifest - id: check-manifest name: check-manifest description: Check the completeness of MANIFEST.in for Python packages. entry: check-manifest language: python types: [python] # pycodestyle code format # https://pypi.python.org/pypi/autopep8/ - id: autopep8 name: autopep8 description: A tool that automatically formats Python code to conform to the PEP 8 style guide. entry: autopep8 args: [ '--in-place', '--aggressive', '--aggressive', '--global-config', 'setup.cfg', ] language: python types: [python] # Python code format # https://github.com/psf/black/ - id: black name: black description: The uncompromising Python code formatter entry: black args: [ '--line-length', '90', '--target-version', 'py36' ] language: python types: [python] ################################### Test Runner ########################################## - repo: local hooks: - id: tests name: run tests description: Run pytest entry: pytest -sv language: system types: [python] stages: [push]
Assuming both pre-commit and pre-push are installed (pre-commit install && pre-commit install -t pre-push
), it will:
During Commit Stage
- Check whether files parse as valid Python.
- Require literal syntax when initializing empty, or zero Python builtin types.
- Checks for a common error of placing code before the docstring.
- Prevent giant files from being committed.
- Check for files that contain merge conflict strings.
- Checks for dead symlinks.
- Check for
yaml
syntax. - Check for
TOML
syntax. - Check for debugger imports and py37+ breakpoint() calls in python source.
- Checks for the existence of private keys.
- Makes sure files end in a newline and only a newline.
- Trims trailing whitespace.
- Sorts entries in
requirements.txt
. - Prevent common mistakes of
assert mck.not_called()
,assert mck.called_once_with(...)
andmck.assert_called
.mck.called_once_with(...)` - Check if
eval()
built-in function is used. - Check if deprecated
.warn()
method of python loggers. - Enforce that python3.6+ type annotations are used instead of type comments.
- Find common security issues in your Python code using
bandit
. - Append JIRA ticket number and link below commit message, if branch name contains ticket number.
- Python style guide enforcement using
flake8
. - Optional static typing for Python 3 and 2 (PEP 484)
- Static analysis tool for checking compliance with Python docstring conventions.
- A linter for YAML files.
- A static anaylsis tool that automatically finds bugs in your shell scripts.
- An English prose (speech or writing) linter.
- Automatically upgrade syntax for newer versions of the language.
- Sort imports.
- Check the completeness of
MANIFEST.in
for Python packages. - Automatically format Python code to conform to the PEP 8 style guide. (Optional)
- The uncompromising Python code formatter.
During Push Stage
- Runs also pytest with verbose and no-capture flag.
You should see something like this in the command line while committing/pushing:
bash
check-ast............................................(no files to check)Skipped
check-builtin-literals...............................(no files to check)Skipped
check-docstring-first................................(no files to check)Skipped
check-added-large-files..................................................Passed
check-merge-conflict.....................................................Passed
check-symlinks.......................................(no files to check)Skipped
check-yaml...............................................................Passed
check-toml...............................................................Passed
debug-statements.....................................(no files to check)Skipped
detect-private-key.......................................................Passed
end-of-file-fixer........................................................Passed
trailing-whitespace......................................................Passed
requirements-txt-fixer...............................(no files to check)Skipped
python-check-mock-methods............................(no files to check)Skipped
python-no-eval.......................................(no files to check)Skipped
python-no-log-warn...................................(no files to check)Skipped
python-use-type-annotations..........................(no files to check)Skipped
bandit...............................................(no files to check)Skipped
flake8...............................................(no files to check)Skipped
mypy.................................................(no files to check)Skipped
pydocstyle...........................................(no files to check)Skipped
yamllint.............................................(no files to check)Skipped
shellcheck (local).......................................................Passed
proselint............................................(no files to check)Skipped
pyupgrade............................................(no files to check)Skipped
isort................................................(no files to check)Skipped
check-manifest.......................................(no files to check)Skipped
autopep8.............................................(no files to check)Skipped
black................................................(no files to check)Skipped
Conclusion
If you’ve followed along this far, you should be able to see the different ways and benefits that pre-commit hooks can help automate some of your tasks. They can help you deploy your code, or help you maintain quality standards by rejecting non-conformant changes or commit messages. Go here, further reading on how to use pre-commit
in your CI environment.
Reference
- [Podcast] Keep Your Code Clean Using pre-commit with Anthony Sottile - Episode 178
- [Manual] pre-commit
- [Blog] Githooks: auto-install hooks
- [Blog] Pre-commit is awesome
- [Blog] Automate Python workflow using pre-commits: black and flake8
- [Tutorial] How To Use Git Hooks To Automate Development and Deployment Tasks
- [Blog] Automatically check your Python code for errors before committing.
Top comments (0)