Elevate Your Git Workflow: A Guide to Using pre-commit¶
Every developer has pushed a commit they immediately regretted: a trailing whitespace violation that failed the linter, a file left with Windows line endings, a secret accidentally included in a configuration file, or a Go source file that was never formatted with gofmt. These are the kinds of issues that are trivial to catch but easy to forget under deadline pressure. Pre-commit hooks are the last line of defense between your editor and your repository, and pre-commit is the framework that makes managing them across multiple languages and projects practical.
This post covers what pre-commit is, why you should consider it in your development workflow, how to get it running on your machine, how to run it in CI, and how to use your own hook repository.
What Is pre-commit?¶
pre-commit is a framework for managing and running Git hooks. A Git hook is a script that Git executes at specific points in the workflow. The pre-commit hook runs just before a commit is finalized, which makes it the ideal place to catch formatting violations, run linters, validate configurations, and check for secrets. If any hook fails, the commit is rejected and the developer sees exactly what needs to be fixed.
Without a framework, managing hooks directly means writing shell scripts, copying them from project to project, and keeping them in sync across a team. pre-commit replaces all of that with a single declarative configuration file, a plugin system that downloads hooks from Git repositories, and a consistent interface that works for any language or tool.
The framework runs hooks against only the files staged for the current commit by default. This keeps hook execution fast, even in large repositories. You can also run hooks explicitly against all files, which is useful during initial setup or in CI.
The pre-commit Framework and the pre-commit Hook
The term "pre-commit" is overloaded. The pre-commit hook is a Git concept: a script that runs before every commit. The pre-commit framework, from pre-commit.com, is the tool that manages those scripts. Throughout this post, "pre-commit" refers to the framework unless stated otherwise.
Why Use It?¶
Catch Issues Before They Reach CI¶
CI pipelines, like GitHub Actions, are invaluable, but they are a slow feedback loop for static analysis. A linting failure that could have been caught early on your laptop instead burns several minutes of CI time and forces a context switch back to a problem you thought was done. Pre-commit hooks close that loop at the source.
Consistent Standards Across the Team¶
When hooks are version-controlled in a .pre-commit-config.yaml file, every contributor who runs pre-commit install gets the same checks at commit time. Standards stop being a verbal agreement and start being an enforced constraint. New contributors onboard to the same quality bar automatically.
Multi-Language, One Tool¶
A single pre-commit configuration can run gofmt on Go files, terraform fmt on Terraform files, shellcheck on shell scripts, and markdown-link-check on Markdown documentation, all from the same framework without requiring separate tooling for each language.
Runs Only on Changed Files¶
By default, pre-commit runs each hook only against the files staged in the current commit, not the entire repository. This means hooks stay fast even as the codebase grows.
Installation¶
pre-commit is a Python-based tool distributed via pip. It also has native packages for popular package managers.
Install with pip (Python 3.8 or later required):
On Debian and Ubuntu, you can also install from the system package manager:
Verify the installation:
Getting Started¶
Create the Configuration File¶
At the root of your repository, create a file named .pre-commit-config.yaml. This file declares which hook repositories to pull from and which hooks within each repository to run.
Here is a minimal example that covers a few common use cases:
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: 3e8a8703264a2f4a69428a0aa4dcb512790b2c8c # v6.0.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-added-large-files
Each repo entry specifies:
repo: the URL of a Git repository that contains hook definitions-
rev: the tag or commit hash to pin. A tag likev6.0.0works, but a full commit hash is even better because tags are mutable and can be moved. The same reasoning applies here as with pinning GitHub Actions to commit hashes. If you use a branch name or any other moving reference,pre-commitwill warn you and the reference will never be updated automatically:[WARNING] The 'rev' field of repo 'https://github.com/pre-commit/pre-commit-hooks' appears to be a mutable reference (moving tag / branch). Mutable references are never updated after first install and are not supported. See https://pre-commit.com/#using-the-latest-version-for-a-repository for more details. Hint: `pre-commit autoupdate` often fixes this.Run
pre-commit autoupdateto let the tool resolve the latest tag for each configured repository and rewriterevin your config file. -
hooks: the list of hook IDs from that repository to enable
Install the Git Hook¶
After creating the configuration file, run the following command from the root of your repository:
This installs a .git/hooks/pre-commit script that invokes the pre-commit framework every time you run git commit. The first time hooks run, pre-commit downloads and caches each hook repository. You will see an info message for each:
Subsequent runs use the local cache, so they are fast.
Run Hooks Manually¶
You can run all configured hooks against the currently staged files at any time:
To run against every file in the repository regardless of staged status:
To run a single hook by its ID:
What Happens on Commit¶
When you run git commit, pre-commit intercepts the operation and runs every configured hook against the staged files.
flowchart LR
A([git commit]) --> B[pre-commit\nintercepts]
B --> C[Run each hook\nagainst staged files]
C --> D{All hooks pass?}
D -- Yes --> E([Commit created])
D -- No --> F[Commit blocked\nReport issues]
F --> G[Fix and\ngit add]
G --> A The output looks like this:
Trim Trailing Whitespace.................................................Passed
Fix End of Lines.........................................................Passed
Check Yaml...............................................................Passed
Check for added large files..............................................Passed
If a hook fails, the commit is blocked and you see what went wrong:
Trim Trailing Whitespace.................................................Failed
- hook id: trailing-whitespace
- exit code: 1
- files were modified by this hook
docs/guide.md
Many hooks automatically fix what they find (for example, stripping trailing whitespace or reformatting code). After a hook modifies a file, stage the change and commit again.
Skipping Hooks in Exception Cases
If you need to make a commit that bypasses hooks for an exception, you can pass --no-verify to git commit. Use this sparingly. The value of pre-commit hooks comes from running them consistently, and bypassing them should be the exception, not the workflow.
GitHub Desktop
GitHub Desktop runs pre-commit hooks when you commit through the UI, so your hooks work without any additional setup. However, the icon in the commit panel exposes a Bypass Commit Hooks option that has the same effect as --no-verify on the command line. If your team uses GitHub Desktop, remind contributors that this option exists and should be reserved for the same exceptional circumstances as --no-verify. Relying on it regularly defeats the purpose of enforcing hooks.
Running pre-commit in CI¶
Running pre-commit hooks locally catches most issues before they reach the repository, but enforcing the same checks in CI ensures that no commit slips through without passing all hooks, regardless of whether the contributor installed pre-commit locally.
Why Run Pre-commit in CI?¶
Running pre-commit in your CI pipeline is not just a backup for developers who forgot to install their hooks. There are several reasons to treat it as a first-class part of your workflow:
-
The
--no-verifyproblem. Developers can bypass local hooks withgit commit --no-verify. This is occasionally valid (work in progress, bootstrapping a new machine), but when it becomes habitual it quietly erodes the value of your hooks. CI has no--no-verifyflag. If the hooks run in CI, the guarantee holds regardless of what anyone does locally. -
A single source of truth. Your
.pre-commit-config.yamlbecomes the centralized, definitive rulebook for formatting, linting, and static analysis across the entire repository. All contributors, all operating systems, all editors: one config, one standard. -
Guaranteed consistency. CI does not care whether a contributor is on macOS with Homebrew or Windows with
pip. It does not care whether their IDE reformats on save. Every pull request is evaluated against the same checks, with the same tool versions, in the same environment. -
Easy maintenance. Instead of writing separate workflow steps to run
gofmt,golangci-lint,shellcheck, andterraform fmtindividually, a singlepre-commit run --all-filesstep handles everything declared in your config. Adding a new tool is a one-line change to.pre-commit-config.yaml, not a workflow rewrite.
GitHub Actions¶
The following workflow runs all pre-commit hooks against every file on every push and pull request to the main branch. It uses Python to install pre-commit directly, which works for any hook that does not require additional system tools.
name: Pre-commit
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
contents: read
jobs:
pre-commit:
name: Pre-commit
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: '3.x'
- name: Install pre-commit
run: pip install pre-commit
- name: Run pre-commit
run: pre-commit run --all-files
Hooks that require external tools
If your hooks depend on tools like golangci-lint, terraform, or shellcheck, you need to install those tools in the workflow before running pre-commit. Add the appropriate setup steps before the Run pre-commit step. For example, to include Go-based hooks:
Exit Codes¶
pre-commit run --all-files exits with code 0 when all hooks pass, and with a non-zero code when any hook fails or modifies a file. This maps cleanly to the pass/fail semantics that CI systems expect.
Defining Your Own Hooks¶
When the community hook index does not have what you need, or when you want checks that are specific to your project or organization, pre-commit gives you two options: local hooks that run tools already on the machine, and hook repositories that package hooks for reuse across projects.
Local Hooks¶
The quickest way to add a project-specific hook is the local repo type. Local hooks use language: system, which means pre-commit invokes whatever tool is already installed on the machine. The hook definitions live directly in .pre-commit-config.yaml, so there is no separate repository to create or publish.
repos:
- repo: local
hooks:
- id: gofmt
name: gofmt
language: system
entry: gofmt -l -w
types: [go]
- id: goimports
name: goimports
language: system
entry: goimports -w
types: [go]
pre-commit passes the list of matched files as arguments to the entry command, so gofmt -l -w receives only the staged Go files, not the entire tree.
Local hooks are a good fit when:
- The tool is already a project dependency (the Go toolchain, a Node package, etc.)
- The check is specific to this repository and unlikely to be shared elsewhere
- You want to get something running in minutes without a separate repository
The tradeoff is that every developer and every CI runner must have the required tools installed and on PATH. If goimports is not installed, the hook fails.
Hook Repositories¶
If you want to share hooks across multiple projects or teams, or if you want pre-commit to manage the tool installation for you, you can publish a dedicated hook repository. A hook repository needs two things: a .pre-commit-hooks.yaml file at the root that declares the hooks, and the scripts or binaries the hooks invoke.
The .pre-commit-hooks.yaml Format¶
This file lists one or more hook definitions:
- id: my-linter
name: My Linter
description: Runs my-linter on all source files.
entry: hooks/my-linter.sh
language: script
files: \.go$
Each hook definition supports the following fields:
| Field | Required | Description |
|---|---|---|
id | Yes | Unique identifier used in .pre-commit-config.yaml. |
name | Yes | Human-readable display name shown in hook output. |
description | No | Short description of what the hook does. |
entry | Yes | Path to the script or command to run, relative to the repository root. |
language | Yes | How pre-commit should invoke the hook (script, python, node, etc.). |
files | No | Regex pattern; the hook runs only on files whose paths match. |
exclude | No | Regex pattern; matching files are excluded even if files matches. |
require_serial | No | When true, the hook runs once for all files rather than in parallel. |
A Minimal Shell Hook¶
The simplest hook is a shell script invoked with language: script. Here is a hook that checks whether Go source files are formatted with gofmt:
.pre-commit-hooks.yaml:
- id: gofmt
name: gofmt
description: Run 'gofmt' to format Go code.
entry: hooks/gofmt.sh
language: script
files: \.go$
exclude: vendor\/.*$
hooks/gofmt.sh:
#!/usr/bin/env bash
set -euo pipefail
unformatted=$(gofmt -l "$@")
if [ -n "$unformatted" ]; then
echo "The following files are not formatted with gofmt:"
for f in $unformatted; do
echo " $f"
done
gofmt -w "$@"
exit 1
fi
Make the script executable:
Shell Scripts and Windows
A .sh script with language: script requires a POSIX shell to run, so it works on macOS and Linux but will fail on Windows unless the user has Git Bash, WSL, or a similar environment configured.
If you need the hook to work on Windows without any extra setup, skip the shell script and use language: system with a direct binary entry instead (the same approach used for local hooks):
- id: gofmt
name: gofmt
description: Run 'gofmt' to format Go code.
entry: gofmt -l -w
language: system
types: [go]
language: system passes the staged files directly to the binary, which works on any operating system where the binary is on PATH. The tradeoff is that the tool must already be installed; pre-commit cannot manage the environment for you.
Referencing Your Hook Repository¶
Once your repository is published, anyone can reference it in their .pre-commit-config.yaml:
The first time a contributor runs pre-commit install or pre-commit run, the framework clones the hook repository at the specified rev and caches it locally in ~/.cache/pre-commit. Subsequent runs use the cache and are fast.
Testing Hooks Locally Before Publishing
During development, you can point rev at a branch while iterating. Note that pre-commit will emit a warning about the mutable reference and will not update it automatically. Before publishing, switch to a pinned tag or commit hash.
repos:
- repo: https://github.com/your-org/your-pre-commit-hooks
rev: your-feature-branch # For development only; pin before merging.
hooks:
- id: gofmt
Once the branch is tagged and published, run pre-commit autoupdate to resolve the latest tag and rewrite rev in your config automatically.
Local vs. Hook Repository¶
| Criteria | Local (repo: local) | Hook Repository |
|---|---|---|
| Setup effort | None: hooks live in your config | Requires a separate Git repository |
| Tool installation | Tools must be pre-installed | pre-commit can manage environments |
| Shareable across projects | No | Yes |
| Works offline after first use | Yes | Yes (cached after first clone) |
| Best for | Quick, project-specific checks | Reusable tooling shared across repos |
References¶
- pre-commit.com: Official documentation, hook index, and supported languages.
- pre-commit/pre-commit-hooks: The standard hook library maintained by the
pre-commitproject, includingtrailing-whitespace,end-of-file-fixer,check-yaml, and many others. - Supported hooks: A searchable directory of community-maintained hook repositories.