| name | setup-pre-commit |
| description | Configure two-stage pre-commit hooks for quality automation. Use after bootstrap-project and setup-adrs, when adding quality gates to an existing project, or when standardizing quality automation. |
| metadata | {"author":"Georges Martin <jrjsmrtn@gmail.com>","version":"0.1.25"} |
| license | MIT |
Setup Pre-commit Hooks
Configure two-stage pre-commit hooks for quality automation.
When to Use
- After running
bootstrap-project and setup-adrs skills
- When adding quality gates to an existing project
- When standardizing quality automation across projects
Required Inputs
- Technology stack (Elixir, Python, TypeScript, etc.)
- Quality tools preference (can use defaults for each ecosystem)
- Multi-remote strategy (if using private + public remotes)
Two-Stage Strategy
Stage 1: Pre-commit (Fast, <30 seconds)
- File hygiene (whitespace, line endings, merge conflicts)
- Secret detection (critical security gate)
- Code formatting check
Stage 2: Pre-push (Thorough, <3 minutes)
- Linting with strict rules
- Type checking
- Security audit (dependencies)
- Fast tests (exclude slow/integration)
Workflow
Step 0: Create Quality Configuration Reference
Before configuring tools, create the single source of truth: docs/reference/quality-configuration.md
# Quality Configuration
Single source of truth for all quality settings.
## Formatting Standards
| Setting | Value | Applies To |
|---------|-------|------------|
| Indent style | spaces | All files |
| Indent size | [2 or 4] | [Language-specific] |
| Line length | [80/100/120] | All code |
| End of line | lf | All files |
| Final newline | yes | All files |
| Trim trailing whitespace | yes | All files |
## Quality Checks by Stage
| Check | Pre-commit | Pre-push | CI | Notes |
|-------|------------|----------|-----|-------|
| Formatting | Yes | - | Yes | Auto-fix locally |
| Linting (fast) | Yes | - | Yes | Syntax, imports |
| Linting (slow) | - | Yes | Yes | Complex analysis |
| Type checking | - | Yes | Yes | Full type analysis |
| Unit tests | - | Yes | Yes | Fast tests locally |
| Integration tests | - | - | Yes | CI only |
| Security scanning | - | Yes | Yes | Secrets, deps |
| Coverage | - | - | Yes | CI only |
## Validation Checklist
- [ ] .editorconfig matches Formatting Standards
- [ ] Linter configs match line length and indent
- [ ] Pre-commit runs all "Pre-commit: Yes" checks
- [ ] Pre-push runs all "Pre-push: Yes" checks
- [ ] CI runs all checks marked "CI: Yes"
Pattern Reference: See CONSISTENT-QUALITY-GATES
Step 1: Create .editorconfig
Create .editorconfig matching the quality-configuration:
root = true
[*]
indent_style = space
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
charset = utf-8
[*.{ex,exs,js,ts,jsx,tsx,yaml,yml,json,md}]
indent_size = 2
[*.py]
indent_size = 4
[*.{ex,exs,py,js,ts}]
max_line_length = 120
Step 2: Secret Detection Architecture
Secret detection works in three layers:
Layer 1: Global gitleaks config (~/.gitleaks.toml)
- Infrastructure-specific patterns (hostnames, domains, private IPs, custom ports)
- Allowlists for git-ignored files (CLAUDE.local.md, .envrc, vault files)
- RFC 5737/7042 documentation addresses excluded from false positives
Layer 2: Project gitleaks config (.gitleaks.toml, .gitleaksignore)
- Test fixture allowlists (generated keys, X.509 elements, mock data)
- Documentation allowlists (demo credentials in tutorials)
- Stop words for test data: test, example, sample, dummy, fake, mock, stub
Layer 3: Git hooks (pre-commit stage)
gitleaks protect --staged on every commit
- Catches secrets before they enter git history
Create .gitleaks.toml if the project has test fixtures or documentation with intentional fake credentials:
[extend]
useDefault = true
[allowlist]
description = "Project-specific allowlist for test data and documentation"
paths = [
'''test/fixtures/.*''',
'''test/support/.*''',
]
stopwords = [
"test",
"example",
"sample",
"dummy",
"fake",
"mock",
]
Use .gitleaksignore for specific false positives that can't be handled by rules:
# Explain why each entry is not a real secret
path/to/file.ex:rule-id:line-number
Step 3: Sensitive Files in .gitignore
Ensure .gitignore covers all secret-bearing files:
# Secrets and credentials
.env
.env.*
!.env.example
.envrc
.envrc.local
*.pem
*.key
credentials.json
secrets.yaml
# Claude Code local files
CLAUDE.local.md
.envrc and .envrc.local (direnv) are treated like .env — they may contain or derive secrets (e.g., Keychain lookups). If a project needs a committed .envrc for non-secret PATH setup, use .envrc.local for the secret overrides and source it from .envrc:
PATH_add bin
source_env_if_exists .envrc.local
export API_KEY="$(security find-internet-password -w -s example.com -a user 2>/dev/null)"
Step 4: Install Hook Framework
Preferred: lefthook (fast, no Python dependency, parallel execution):
port install lefthook
lefthook version
Alternative: pre-commit (Python-based, wider ecosystem of third-party hooks):
pipx install pre-commit
pre-commit --version
Step 5: Create Configuration
Create hook configuration based on framework choice and technology stack:
Dependency vulnerability auditing
The pre-push stage runs a dependency vulnerability audit per ecosystem — this satisfies the
OpenSSF Scorecard Vulnerabilities check and the Best Practices Badge's dependency-vulnerability
criterion. The tools are not interchangeable; know what each checks:
| Ecosystem | Tool | Checks | Prerequisite |
|---|
| Elixir | mix deps.audit | Known CVEs in dependencies | add {:mix_audit, "~> 2.1", only: [:dev, :test], runtime: false} to mix.exs |
| Elixir | mix hex.audit | Retired Hex packages | built into Hex |
| Python | pip-audit | Known CVEs (PyPI / OSV) | pip install pip-audit (or uv) |
| Rust | cargo audit | RustSec advisory DB | cargo install cargo-audit |
For Elixir, run both: mix hex.audit flags retired/deprecated packages, while mix deps.audit
(via the mix_audit package) flags packages with known CVEs — different failure modes, both needed.
Elixir/BEAM advisories are increasingly issued by the Erlang Ecosystem Foundation, now a CVE
Numbering Authority (CNA) for Hex.pm and the BEAM ecosystem under the EEF Ægis initiative — so
mix deps.audit is only as good as the advisory feed it consumes, which the EEF now curates.
Lockfile and frozen installs
Commit the ecosystem lockfile and install from it deterministically — the OpenSSF Scorecard Pinned-Dependencies control, which makes builds reproducible and tamper-evident.
| Ecosystem | Lockfile (commit it) | Frozen/locked install (CI + containers) |
|---|
| Python (uv) | uv.lock | uv sync --frozen |
| Elixir | mix.lock | mix deps.get --check-locked (CI) |
| Rust | Cargo.lock | cargo build --locked |
| Node | package-lock.json | npm ci |
Add a pre-push/CI check that the lockfile is present and committed, and flag a manifest that declares only lower-bound (>= / ~>) constraints with no committed lock — anyone installing from sdist/wheel without the lock then resolves loosely. In a Containerfile, avoid COPY mix.lock* (optional glob): it silently masks a missing lockfile.
License compliance — REUSE (public projects)
For public open-source projects, make licensing machine-readable and enforced, not just prescribed in prose. Provides the SPDX data an SBOM/CRA review expects. Scaffold a LICENSES/ directory (one SPDX license text each), inline SPDX-FileCopyrightText + SPDX-License-Identifier headers, and a REUSE.toml to bulk-annotate docs and correctly attribute vendored/upstream fixtures (kept byte-pristine yet attributed):
version = 1
[[annotations]]
path = "docs/**"
SPDX-FileCopyrightText = "2026 Georges Martin <jrjsmrtn@gmail.com>"
SPDX-License-Identifier = "MIT"
[[annotations]]
path = "vendor/upstream/**"
SPDX-FileCopyrightText = "Upstream authors"
SPDX-License-Identifier = "Apache-2.0"
Enforce reuse lint in both stages — a git hook and a CI job:
reuse:
run: reuse lint
- uses: fsfe/reuse-action@<sha>
Lefthook — Elixir Configuration
pre-commit:
parallel: true
commands:
gitleaks:
run: gitleaks protect --staged --verbose
format-elixir:
run: mix format --check-formatted
glob: "*.{ex,exs}"
format-markdown:
run: dprint check
glob: "*.md"
pre-push:
parallel: true
commands:
compile:
run: mix compile --warnings-as-errors
credo:
run: mix credo --strict
glob: "*.{ex,exs}"
test:
run: mix test --exclude slow --exclude integration
dialyzer:
run: mix dialyzer
deps-audit:
run: mix deps.audit
hex-audit:
run: mix hex.audit
Lefthook — Python Configuration
pre-commit:
parallel: true
commands:
gitleaks:
run: gitleaks protect --staged --verbose
ruff-format:
run: ruff format --check .
glob: "*.py"
ruff-check:
run: ruff check .
glob: "*.py"
format-markdown:
run: dprint check
glob: "*.md"
pre-push:
parallel: true
commands:
mypy:
run: mypy .
pip-audit:
run: pip-audit
test:
run: pytest -m "not slow and not integration"
Lefthook — Multi-Language Configuration (Elixir + Rust NIFs)
pre-commit:
parallel: true
commands:
gitleaks:
run: gitleaks protect --staged --verbose
format-elixir:
run: mix format --check-formatted
glob: "*.{ex,exs}"
format-rust:
run: cargo fmt -- --check
root: "native/{nif_name}/"
format-markdown:
run: dprint check
glob: "*.md"
pre-push:
parallel: true
commands:
compile:
run: mix compile --warnings-as-errors
credo:
run: mix credo --strict
test-elixir:
run: mix test
dialyzer:
run: mix dialyzer
deps-audit:
run: mix deps.audit
hex-audit:
run: mix hex.audit
clippy:
run: cargo clippy -- -D warnings
root: "native/{nif_name}/"
test-rust:
run: cargo test
root: "native/{nif_name}/"
cargo-audit:
run: cargo audit
root: "native/{nif_name}/"
Alternative: pre-commit Framework — Elixir
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.5.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-json
- id: check-merge-conflict
- id: detect-private-key
- repo: https://github.com/gitleaks/gitleaks
rev: v8.18.2
hooks:
- id: gitleaks
- repo: local
hooks:
- id: mix-format
name: mix format
entry: mix format --check-formatted
language: system
files: \.exs?$
pass_filenames: false
- id: mix-credo
name: mix credo
entry: mix credo --strict
language: system
files: \.exs?$
pass_filenames: false
stages: [pre-push]
- id: mix-dialyzer
name: mix dialyzer
entry: mix dialyzer
language: system
pass_filenames: false
stages: [pre-push]
- id: mix-deps-audit
name: mix deps.audit
entry: mix deps.audit
language: system
pass_filenames: false
stages: [pre-push]
- id: mix-hex-audit
name: mix hex.audit
entry: mix hex.audit
language: system
pass_filenames: false
stages: [pre-push]
- id: mix-test-fast
name: mix test (fast)
entry: mix test --exclude slow --exclude integration
language: system
pass_filenames: false
stages: [pre-push]
Alternative: pre-commit Framework — Python
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.5.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-json
- id: check-merge-conflict
- id: detect-private-key
- repo: https://github.com/gitleaks/gitleaks
rev: v8.18.2
hooks:
- id: gitleaks
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.1.9
hooks:
- id: ruff
args: [--fix]
- id: ruff-format
- repo: local
hooks:
- id: mypy
name: mypy
entry: mypy .
language: system
types: [python]
pass_filenames: false
stages: [pre-push]
- id: pip-audit
name: pip-audit
entry: pip-audit
language: system
pass_filenames: false
stages: [pre-push]
- id: pytest-fast
name: pytest (fast)
entry: pytest -m "not slow and not integration"
language: system
pass_filenames: false
stages: [pre-push]
Step 6: Install Hooks
lefthook install
pre-commit install
pre-commit install --hook-type pre-push
ls -la .git/hooks/pre-commit .git/hooks/pre-push
Step 7: Optional - Multi-Remote Protection
If using multi-remote strategy (private origin + public GitHub/GitLab), add protection hook.
Create .git/hooks/pre-push-remote-check (will be sourced by pre-push):
#!/bin/bash
remote="$1"
current_branch=$(git symbolic-ref --short HEAD 2>/dev/null)
public_remotes="github gitlab"
for pub_remote in $public_remotes; do
if [[ "$remote" == "$pub_remote" ]]; then
if [[ "$current_branch" != "main" ]]; then
echo "ERROR: Only push 'main' branch to public remote '$remote'"
echo "Current branch: $current_branch"
exit 1
fi
echo "Pushing to public remote - running security scan..."
gitleaks detect --source . --verbose || exit 1
fi
done
Step 8: Initial Run
lefthook run pre-commit
pre-commit run --all-files
git add .lefthook.yml
git add .gitleaks.toml .gitleaksignore
git commit -m "chore: add pre-commit hooks configuration"
Step 9: Enforce dependency audits in CI
Pre-push hooks are local and bypassable (git push --no-verify). For real enforcement, run the
same dependency audit server-side in a dedicated CI job — this is what the OpenSSF Scorecard
Vulnerabilities check expects. Add an audit job (Elixir shown; mirror for pip-audit / cargo audit):
audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@<sha>
- uses: erlef/setup-beam@<sha>
with: { otp-version: "27", elixir-version: "1.18" }
- run: mix deps.get
- run: mix deps.audit
- run: mix hex.audit
Run it on a schedule too, so newly-disclosed CVEs are caught on branches that aren't seeing commits:
on:
schedule:
- cron: "0 6 * * 1"
Harden this job (SHA-pin the actions, permissions: contents: read) with harden-github-actions.
Outputs
This skill creates:
Validation
Verify successful setup:
ls -la .git/hooks/pre-commit .git/hooks/pre-push
lefthook run pre-commit
lefthook run pre-push
time lefthook run pre-commit
time lefthook run pre-push
Troubleshooting
Hook not running?
lefthook install
pre-commit install --force
pre-commit install --hook-type pre-push --force
Bypassing hooks (emergency only)
git commit --no-verify -m "message"
git push --no-verify
Updating hook versions (pre-commit only)
pre-commit autoupdate
git add .pre-commit-config.yaml
git commit -m "chore: update pre-commit hook versions"
Related Skills
setup-adrs - establishes the development-practices ADR that documents these quality gates
validate-quality-config - Validate configuration consistency
harden-github-actions - harden the CI audit job (SHA-pin actions, least-privilege permissions)