agentleFS
jrjsmrtnjrjsmrtn/project-orchestration-skills/skills/setup-git-hooks/SKILL.md

setup-git-hooks

Skill17 starsChanged 10 days agoMITView on GitHub

Configure two-stage git hooks (pre-commit + pre-push) for quality automation, with either pre-commit or lefthook as the hook manager. Use after bootstrap-project and setup-adrs, when adding quality gates to an existing project, or when standardizing quality automation.

  • Reads credentials
  • Installs packages
  • Commits and pushes

The file

Show its rules and text

We split this skill into the rules it gives an agent

16 rules. About 0 are common, 7 are distinctive, and 9 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈7

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 9

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Very specific:

    After running bootstrap-project and setup-adrs skills

    line 16
  2. Distinctive:

    When adding quality gates to an existing project

    line 17
  3. Distinctive:

    When standardizing quality automation across projects

    line 18
  4. Very specific:

    Before configuring tools, create the single source of truth: docs/reference/quality-configuration.md

    line 43
  5. Very specific:

    Create .editorconfig matching the quality-configuration:

    line 147
  6. Distinctive:

    Test fixture allowlists (generated keys, X.509 elements, mock data)

    line 179
  7. Distinctive:

    Stop words for test data: test, example, sample, dummy, fake, mock, stub

    line 181
  8. Very specific:

    Create .gitleaks.toml if the project has test fixtures or documentation with intentional fake credentials:

    line 187
  9. Very specific:

    Use .gitleaksignore for specific false positives that can't be handled by rules:

    line 213
  10. Very specific:

    Ensure .gitignore covers all secret-bearing files:

    line 222
  11. Distinctive:

    Create hook configuration based on framework choice and technology stack:

    line 274
  12. Very specific:

    Commit the ecosystem lockfile and install from it deterministically — the OpenSSF Scorecard Pinned-Dependencies control, which makes builds …

    line 297
  13. Very specific:

    The fix: put pre-push gates in scripts:, which are not subject to the file-set skip.

    line 433
  14. Distinctive:

    If using multi-remote strategy (private origin + public GitHub/GitLab), add protection hook.

    line 754
  15. Very specific:

    Create .git/hooks/pre-push-remote-check (will be sourced by pre-push):

    line 756
  16. Distinctive:

    Run it on a schedule too, so newly-disclosed CVEs are caught on branches that aren't seeing commits:

    line 816

≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

Setup Git Hooks

Configure two-stage git hooks — fast checks on commit, thorough checks on push — using either pre-commit or lefthook as the hook manager.

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

  1. Technology stack (Elixir, Python, TypeScript, etc.)
  2. Quality tools preference (can use defaults for each ecosystem)
  3. 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.

## Toolchain Versions

Three roles, and conflating them ships defects. Record all three even when two coincide today.

| Role | Version | Declared in | Meaning |
|------|---------|-------------|---------|
| **Floor** | [oldest supported] | [manifest] | the oldest a *consumer* needs — a compatibility claim |
| **Build** | [current] | [CI config] | what CI and releases are actually built with |
| **Inherited** | [if any] | [sub-module manifest] | a floor a dependency forces on you, not one you chose |

Per ecosystem, the floor lives in:

| Ecosystem | Floor | Build version |
|-----------|-------|---------------|
| Go | `go` directive in `go.mod` | `go-version:` in the workflow |
| Rust | `rust-version` (MSRV) in `Cargo.toml` | toolchain file / CI |
| Python | `requires-python` in `pyproject.toml` | `python-version:` in CI |
| Node | `engines.node` in `package.json` | `node-version:` in CI |
| Elixir | `elixir` requirement in `mix.exs` | CI matrix |

**Build with the current version, never the floor.** The floor states what a consumer needs;
building at it means building against the least-patched runtime. Released artifacts embed that
runtime — and if you publish an SBOM, they *record* it — so building at the floor can publish a
known-vulnerable runtime and document the fact in the accompanying BOM.

**Prove the floor separately.** One CI job builds and tests at exactly the declared floor, so the
compatibility claim is verified rather than aspirational. Do **not** run vulnerability scanning
there: an old toolchain has old advisories by definition, which is a reason not to *ship* it, not a
reason to stop supporting it.

**Record the per-call-site intent.** List every place a toolchain version is selected and why it
reads what it reads. A partial fix is the common failure — updating the two obvious workflows and
missing the scheduled scan.

**Record the maintenance obligation.** Pin the build *line* rather than tracking `latest`, so a new
major release cannot change what you ship without a commit; patch releases within the line still
arrive, which is how runtime security fixes reach you. Nothing automates the major bump — dependency
bots do not manage CI toolchain versions — so name it as a periodic, deliberate act. Its failure
mode is silent.

## 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

Commands with expected output, not reminders — a reader or an agent should be able to run these
and compare. Substitute the ecosystem's own commands; the shape is what matters.

```bash
# Every build-version selector in CI names the same pinned line (expect ONE distinct value):
grep -rhoE '(go|python|node|otp|elixir)-version:[[:space:]]*"?[^"[:space:]]+' .github/workflows/ \
  | tr -d '"' | sort -u

# Only the floor-compatibility job reads the floor from the manifest (expect exactly the jobs
# listed in the per-call-site table above — nothing else):
grep -rn 'version-file' .github/workflows/

# The floor still builds and tests (Go shown; use the ecosystem's pinning mechanism):
GOTOOLCHAIN=go[FLOOR] go build ./... && GOTOOLCHAIN=go[FLOOR] go test ./...

# Vulnerability scanning is clean ON THE BUILD VERSION, not the floor:
[govulncheck ./... | pip-audit | cargo audit | mix deps.audit]

# Local hooks and CI run the same commands (expect the same tool invocations on both sides):
grep -oE '(gofmt|go vet|go test|ruff|mix format|cargo fmt)[^"]*' .lefthook.yml | sort -u
grep -oE '(gofmt|go vet|go test|ruff|mix format|cargo fmt)[^"]*' .github/workflows/ci.yml | sort -u
  • .editorconfig matches Formatting Standards
  • Linter configs match line length and indent

> **Pattern Reference**: See [CONSISTENT-QUALITY-GATES](https://github.com/jrjsmrtn/ai-assisted-project-orchestration/blob/develop/docs/patterns/quality/consistent-quality-gates.md)

### Step 1: Create .editorconfig

Create `.editorconfig` matching the quality-configuration:

```ini
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"

# Test fixtures with intentional fake data
paths = [
    '''test/fixtures/.*''',
    '''test/support/.*''',
]

# Stop words — values containing these are not secrets
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:

# .envrc (committed — no secrets)
PATH_add bin
source_env_if_exists .envrc.local
# .envrc.local (git-ignored — secrets here)
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):

# macOS
port install lefthook  # or: brew install lefthook

# Verify
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:

EcosystemToolChecksPrerequisite
Elixirmix deps.auditKnown CVEs in dependenciesadd {:mix_audit, "~> 2.1", only: [:dev, :test], runtime: false} to mix.exs
Elixirmix hex.auditRetired Hex packagesbuilt into Hex
Pythonpip-auditKnown CVEs (PyPI / OSV)pip install pip-audit (or uv)
Rustcargo auditRustSec advisory DBcargo 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.

EcosystemLockfile (commit it)Frozen/locked install (CI + containers)
Python (uv)uv.lockuv sync --frozen
Elixirmix.lockmix deps.get --check-locked (CI)
RustCargo.lockcargo build --locked
Nodepackage-lock.jsonnpm 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):

# REUSE.toml
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:

# .lefthook.yml
reuse:
  run: reuse lint
# .github/workflows/ci.yml — SHA-pin per harden-github-actions
- uses: fsfe/reuse-action@<sha>   # v5

Commit message honesty (t2)

Enforces the mechanical half of bootstrap-project's Rule 2 — a commit message must not claim more than the commit contains.

The failure it catches: an edit silently does nothing (a pattern that did not match, a file already in the target state) while the message, written beforehand, describes it confidently. The message is then a false record in git log, which is exactly where people look when the code no longer explains itself. The risk is highest when the message is generated, because fluent prose about an intended change reads identically whether or not the change landed.

The rule, narrowly scoped to stay honest: a path in backticks that exists in the working tree but is not staged fails the commit. Backticks mean "I am naming this file"; prose references and non-existent paths are ignored, which keeps false positives near zero.

.githooks/commit-msg — no framework needed, and deliberately dependency-free:

#!/usr/bin/env bash
# Fail when the message names a file in backticks that exists but is not staged.
set -uo pipefail
msg_file="$1"
staged="$(git diff --cached --name-only)"

# Backticked tokens that look like repo-relative paths.
mentioned="$(grep -oE '`[A-Za-z0-9._/-]+`' "$msg_file" 2>/dev/null \
             | tr -d '`' | grep -E '/' | sort -u || true)"

unstaged=""
while IFS= read -r pathname; do
    [ -n "$pathname" ] || continue
    [ -e "$pathname" ] || continue                      # not a real path: prose, ignore
    grep -qxF "$pathname" <<<"$staged" && continue      # staged: fine
    unstaged="${unstaged}${pathname}"$'\n'
done <<<"$mentioned"

if [ -n "$unstaged" ]; then
    echo "✗ commit message names files it does not change:" >&2
    printf '    %s\n' $unstaged >&2
    echo "  Stage them, or drop the backticks if you are only referring to them." >&2
    exit 1
fi

Install with git config core.hooksPath .githooks (or wire it as lefthook's commit-msg).

Prove it fails before trusting it. A check that has only ever passed is unproven:

# Planted bad: name a real, unstaged file in backticks.
other=$(git ls-files | grep -v "$(git diff --cached --name-only | head -1)" | head -1)
printf 'chore: test\n\nTouches `%s`.\n' "$other" > /tmp/m
.githooks/commit-msg /tmp/m; echo "exit=$?   # must be 1"

# Planted good: same message, prose instead of backticks.
printf 'chore: test\n\nTouches %s.\n' "$other" > /tmp/m
.githooks/commit-msg /tmp/m; echo "exit=$?   # must be 0"

What it does not catch, stated so its silence is not read as approval: a message that overclaims without naming a path — "fixed both call sites" when one edit no-op'd. No hook catches that. The practice is to re-read the message against git diff --cached rather than against your intent, and the hook only removes the most mechanical slice of the problem.

lefthook: pre-push gates belong in scripts:, not commands:

A repository with more than one remote silently loses its pre-push gates on every remote after the first. Measured on lefthook 2.1.10, pushing one branch to three remotes: the first ran lint, format and 284 test assertions; the second ran them again; the third ran nothing and printed a clean summary —

│  luacheck (skip) no matching push files
│  stylua-check (skip) no matching push files
│  test (skip) no matching push files

Why. lefthook derives the pre-push file set from @{push}, which git resolves per branch, not per remote — it does not vary with the remote named on the command line. The first push advances @{push}, so every later remote computes an empty diff, and commands: with an empty file set are skipped. Git hands the hook the correct remote refs on stdin; lefthook does not use them.

Narrowed by reproduction: with no upstream configured both pushes ran; the skip appeared only once an upstream existed and the first push advanced it.

Two fixes that do not work, recorded so they are not retried:

  • skip_empty: false — not a recognised key. lefthook 2.1.10 accepts commands env exclude fail_text files follow glob group interactive jobs only parallel piped priority root run scripts skip stage_fixed tags use_stdin. Setting it changes nothing, silently.
  • a files: override — the skip is decided before lefthook consults it.

The fix: put pre-push gates in scripts:, which are not subject to the file-set skip.

pre-push:
  parallel: false
  scripts:
    "gates.sh":
      runner: bash

with the checks in .lefthook/pre-push/gates.sh, each guarded so a missing tool fails rather than passes quietly.

This is also the more honest shape. Pre-push gates are typically whole-repo checks — a full test run, a type check, a dependency audit — that never needed a file set, so scoping them to changed files was buying nothing and costing correctness. Keep glob: for pre-commit commands:, where staged-file scoping is the point.

Verify it, do not assume it. Clone the repo, add two bare local remotes, set an upstream, commit, and push to both — the gate must run twice:

git init -q --bare /tmp/R1.git && git init -q --bare /tmp/R2.git
git clone -q . /tmp/probe && cd /tmp/probe
git remote add R1 /tmp/R1.git && git remote add R2 /tmp/R2.git
git push -q R1 HEAD && git push -q R2 HEAD
git branch --set-upstream-to=R1/$(git branch --show-current)
git commit -q --allow-empty -m "probe"
git push R1 HEAD && git push R2 HEAD    # both must run the gate

Applies to lefthook only. pre-commit (the framework) runs stages: [pre-push] hooks per invocation and does not derive files from @{push}.

A DCO sign-off hook is not an agent's to satisfy

A project using the Developer Certificate of Origin enforces it with a commit-msg hook that rejects any commit lacking a Signed-off-by: trailer. If you are an assistant working in such a repository, that rejection is not a lint failure to route around.

Never run git commit -s. Never write the trailer. The DCO is an attestation by a person that they have the right to submit the work under the project's licence. An agent adding it asserts something it has no standing to assert, in the maintainer's name, and records that claim permanently in a public history — which is precisely where a future licence audit will read it as a human's word.

Hand the work back unsigned. Stage it, or commit to a scratch branch, say plainly that it needs a sign-off, and let the human run git commit -s or git rebase --signoff <base> after reading the diff. Losing a few seconds is the whole cost; the alternative is a false attestation that is expensive to unwind once pushed.

Co-Authored-By: and Assisted-by: are different in kind — they record tool assistance and assert no right to submit, so an agent may add those freely.

Write the rule down where an agent will meet it, not only in CONTRIBUTING.md, which an agent may never open. The project's CLAUDE.md (or equivalent agent instructions) is the file that is actually loaded, and the two should agree:

### Never sign off on the human's behalf

Do not run `git commit -s`. Do not write a `Signed-off-by:` trailer. When the commit-msg
hook blocks you for a missing sign-off, hand the work back unsigned and say so.

The hook cannot tell a human from an agent, so this remains an instruction rather than an assertion — the gap worth knowing about. A stronger guard compares the committer identity against the signer, or refuses when an agent's environment marker is present.

Lefthook — Elixir Configuration

# .lefthook.yml
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
  # SINGLE-REMOTE SHAPE. With more than one remote, move these into `scripts:` —
  # `commands:` are skipped on every remote after the first. See "pre-push gates
  # belong in scripts:, not commands:" above.
  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

# .lefthook.yml
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
  # SINGLE-REMOTE SHAPE. With more than one remote, move these into `scripts:` —
  # `commands:` are skipped on every remote after the first. See "pre-push gates
  # belong in scripts:, not commands:" above.
  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)

# .lefthook.yml
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
  # SINGLE-REMOTE SHAPE. With more than one remote, move these into `scripts:` —
  # `commands:` are skipped on every remote after the first. See "pre-push gates
  # belong in scripts:, not commands:" above.
  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

# .pre-commit-config.yaml
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

# .pre-commit-config.yaml
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
lefthook install

# OR pre-commit
pre-commit install
pre-commit install --hook-type pre-push

# Verify installation
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
# Prevent pushing non-main branches to public remotes

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

        # Run additional security checks for public push
        echo "Pushing to public remote - running security scan..."
        gitleaks detect --source . --verbose || exit 1
    fi
done

Step 8: Initial Run

# lefthook — run all pre-commit hooks
lefthook run pre-commit

# OR pre-commit — run against all files
pre-commit run --all-files

# Fix any issues found, then commit the configuration
git add .lefthook.yml  # or .pre-commit-config.yaml
git add .gitleaks.toml .gitleaksignore  # if created
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):

# .github/workflows/ci.yml (excerpt)
  audit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@<sha> # v4
      - uses: erlef/setup-beam@<sha> # v1
        with: { otp-version: "27", elixir-version: "1.18" }
      - run: mix deps.get
      - run: mix deps.audit   # known CVEs (needs :mix_audit)
      - run: mix hex.audit    # retired packages

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"   # weekly

Harden this job (SHA-pin the actions, permissions: contents: read) with harden-github-actions.

Outputs

This skill creates:

  • docs/reference/quality-configuration.md (single source of truth)
  • .editorconfig (editor formatting)
  • .lefthook.yml or .pre-commit-config.yaml (two-stage hook configuration)
  • .gitleaks.toml (project-specific allowlist, if needed)
  • .gitleaksignore (specific false positives, if needed)
  • .git/hooks/pre-commit (installed by hook framework)
  • .git/hooks/pre-push (installed by hook framework)
  • .git/hooks/pre-push-remote-check (optional, for multi-remote)
  • {:mix_audit, ...} added to mix.exs deps (Elixir CVE-audit prerequisite)
  • Dependency audit enforced in a CI audit job (not hook-only) + scheduled re-run
  • Ecosystem lockfile committed; frozen/locked installs in CI + containers
  • (Public projects) REUSE: LICENSES/ + REUSE.toml + reuse lint hook and CI job

Validation

Verify successful setup:

# Check hooks are installed
ls -la .git/hooks/pre-commit .git/hooks/pre-push

# Test Stage 1 (pre-commit)
lefthook run pre-commit          # or: pre-commit run --all-files

# Test Stage 2 (pre-push)
lefthook run pre-push            # or: pre-commit run --all-files --hook-stage pre-push

# Verify timing
time lefthook run pre-commit     # Should be <30s
time lefthook run pre-push       # Should be <3min

Troubleshooting

Hook not running?

# lefthook
lefthook install

# pre-commit
pre-commit install --force
pre-commit install --hook-type pre-push --force

Bypassing hooks (emergency only)

git commit --no-verify -m "message"  # Skip pre-commit
git push --no-verify                  # Skip pre-push

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)

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.