Sharing one hook config across repositories Jump to heading

An organisation with forty repositories usually has forty slightly different .pre-commit-config.yaml files. One pins Ruff from last year, another forgot the secret scanner, a third has a custom hook copied from a fourth with a bug that was fixed there months ago. The pre-commit framework has no “inherit from” mechanism, so consistency has to be built. Two pieces do most of the work: a central repository that publishes your organisation’s own hooks, and a baseline configuration synchronised into every repository by automation, with a clearly marked place for each repository’s additions. This page sets both up, within the pre-commit framework for polyglot repos.

When to use this approach Jump to heading

  • Many repositories use the pre-commit framework and their configurations have drifted.
  • Your organisation has custom hooks — policy checks, naming rules — copied between repositories.
  • A security requirement, such as secret scanning on every commit, must apply everywhere.
  • Hook versions are pinned per repository, as in pinning and autoupdating pre-commit hook versions, and you want those pins coordinated.

Step 1 — Publish organisation hooks from one repository Jump to heading

Move custom hooks into a dedicated repository with a .pre-commit-hooks.yaml manifest. Every repository then references it like any third-party hook source, and fixes reach everyone through normal version updates.

# acme/pre-commit-hooks — .pre-commit-hooks.yaml
- id: forbid-prod-urls
  name: forbid production URLs in code
  entry: scripts/forbid-prod-urls.sh
  language: script
  types: [text]
- id: check-service-manifest
  name: validate service.yaml
  entry: check-service-manifest
  language: python
  files: ^service\.yaml$
# Tag releases so consumers can pin and autoupdate
git tag -a v1.4.0 -m "forbid-prod-urls: ignore test fixtures" && git push origin v1.4.0
Organisation hooks from a central repositoryCustom hooks live in one repository with a manifest and tagged releases. Each consuming repository lists it in its config, pinned to a release. A fix is released once and reaches every repository through the normal autoupdate pull request.acme/pre-commit-hooks.pre-commit-hooks.yamlTagged releasev1.4.0Consumer configsrepo: … rev: frozenautoupdate PRsfix propagatesno more copying scripts between repositories

Step 2 — Define a baseline configuration Jump to heading

Write the hooks every repository must run — whitespace and YAML checks, secret scanning, your organisation hooks — as a baseline file in a central repository. Keep it minimal: anything language-specific belongs in the repositories that need it.

# acme/pre-commit-baseline — baseline.yaml
repos:
  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: 2c9f875913ee60ca25ce70243dc24d5b6415598c  # frozen: v4.6.0
    hooks: [{ id: trailing-whitespace }, { id: end-of-file-fixer }, { id: check-merge-conflict }]
  - repo: https://github.com/gitleaks/gitleaks
    rev: 77c3c6a34b2577d71083442326c60b8fd58926ec  # frozen: v8.18.4
    hooks: [{ id: gitleaks }]
  - repo: https://github.com/acme/pre-commit-hooks
    rev: 5d1e0c7a9f3b2e4d6c8a0b1f3e5d7c9a1b3e5f70  # frozen: v1.4.0
    hooks: [{ id: forbid-prod-urls }]

The pinned hashes are placeholders for illustration; generate real ones with pre-commit autoupdate --freeze in the baseline repository.

Step 3 — Merge baseline and local additions with a sync job Jump to heading

Each repository keeps its own additions in a separate file. A sync job combines the baseline with the local file into the real .pre-commit-config.yaml, which is generated and marked as such.

# sync_precommit.py — baseline + .pre-commit-local.yaml -> .pre-commit-config.yaml
import yaml, urllib.request
base = yaml.safe_load(urllib.request.urlopen(
    "https://raw.githubusercontent.com/acme/pre-commit-baseline/main/baseline.yaml"))
try:
    local = yaml.safe_load(open(".pre-commit-local.yaml")) or {}
except FileNotFoundError:
    local = {}
cfg = {"minimum_pre_commit_version": "3.7.0",
       "repos": base["repos"] + local.get("repos", [])}
with open(".pre-commit-config.yaml", "w") as f:
    f.write("# GENERATED from acme/pre-commit-baseline + .pre-commit-local.yaml — do not edit\n")
    yaml.safe_dump(cfg, f, sort_keys=False)
How a repository's hook config is assembledThe organisation baseline supplies mandatory hooks. The repository's local file adds language and project hooks. The sync job generates the real config from both. CI fails if the generated file was edited by hand or is out of date.two sources, one generated fileOrganisation baselinemandatory hooks, central pins.pre-commit-local.yamlthis repo's additions.pre-commit-config.yamlgenerated — do not editCI drift checkregenerate and comparerepositories own their additions; nobody can quietly drop a baseline hook
# Verification: regenerating produces no diff
python3 sync_precommit.py && git diff --exit-code .pre-commit-config.yaml

Step 4 — Roll baseline changes out with pull requests Jump to heading

When the baseline changes, a job in the baseline repository runs the sync in every consumer and opens a pull request in each. Repository owners review and merge, so a baseline change never lands unreviewed.

# From the baseline repository's CI, for each consumer repository
for repo in $(cat consumers.txt); do
  gh repo clone "$repo" "/tmp/$repo" -- -q --depth 1
  ( cd "/tmp/$repo" && python3 ../sync_precommit.py &&
    git diff --quiet || {
      git switch -c chore/pre-commit-baseline-$(date +%F)
      git commit -am "chore: sync pre-commit baseline"
      git push -u origin HEAD && gh pr create --fill --label tooling
    } )
done

Bulk operations across many repositories are covered further in bulk updating repository settings with the API.

Step 5 — Fail CI on drift Jump to heading

A generated file can still be edited by hand. CI regenerates it and fails if the committed version differs, which also catches a repository that removed a baseline hook.

      - name: pre-commit config is in sync with baseline
        run: python3 sync_precommit.py && git diff --exit-code .pre-commit-config.yaml
Copied configs against a synced baselineCopying configs between repositories produces drift: different pins, missing mandatory hooks, divergent copies of custom scripts. A synced baseline with a drift check keeps mandatory hooks identical everywhere while letting each repository add its own.Copy and editSynced baselinemandatory hooks presentsometimesalwayspin consistencydriftsidenticalcustom hook fixescopied by handone releaserepo-specific hooksanywherelocal filethe drift check is what turns the baseline from a suggestion into a guarantee

Step 6 — Measure coverage across the organisation Jump to heading

Track which repositories have adopted the baseline and which are behind. A short report from the forge’s API is enough.

for repo in $(gh repo list acme --limit 500 --json nameWithOwner --jq '.[].nameWithOwner'); do
  head=$(gh api "repos/$repo/contents/.pre-commit-config.yaml" --jq .content 2>/dev/null | base64 -d 2>/dev/null | head -1)
  case "$head" in "# GENERATED"*) echo "synced    $repo" ;; "") echo "none      $repo" ;; *) echo "manual    $repo" ;; esac
done | sort | uniq -c -w 9

Validation checklist Jump to heading

Frequently Asked Questions Jump to heading

Can a repository opt out of one baseline hook? Jump to heading

Allow it explicitly: support an exclude_baseline: [hook-id] list in the local file, applied by the sync script and visible in review. An explicit, reviewed exception is far better than a silent deletion.

Why not use a single shared config via a URL? Jump to heading

pre-commit reads only the local file and does not support remote includes. Generating the file keeps the framework unchanged and works offline once synced.

How do we handle repositories in different languages? Jump to heading

The baseline holds only language-neutral hooks. Language-specific hooks go in local files, or in optional baseline fragments — baseline-python.yaml, baseline-go.yaml — that a repository opts into by name.