Date: 2026-07-17 Status: Active Type: Runbook
workspace-ci uses .pre-commit-config.yaml as its hook configuration format
but does NOT use the pre-commit Python framework. Instead,
scripts/generate-hooks reads the YAML and
generates native bash scripts in .git/hooks/. This removes the pre-commit
framework's stashing behavior (which temporarily removes files from disk,
breaking live dev servers) while keeping the familiar configuration format.
Related contracts:
lib/ comply with.pre-commit-config.yaml files.pre-commit-config.yaml at its root.quality_exceptions.yaml at the project root (mandatory; generated hooks
hard-fail without it). Copy
templates/quality_exceptions.template.yaml
and replace __PROJECT_NAME__./opt/homebrew/bin/bash, then /usr/local/bin/bash)
over the system bash 3.2.# From any project that has a .pre-commit-config.yaml:
../CI/scripts/generate-hooks
# Or via Makefile:
make install-hooks
make install-hooks runs scripts/cleanup-precommit
first (if present), then scripts/reinstall-hooks.
reinstall-hooks refuses when the generated hooks are root-owned:
root-owned hooks are only ever written by root, so regeneration runs
as root via sudo make -C projects/CI install-hooks CONSUMER=<repo>.
There is no unseal/re-lock cycle; root ownership is the complete
invariant.
This reads .pre-commit-config.yaml and writes one file per configured
stage: .git/hooks/pre-commit, .git/hooks/commit-msg,
.git/hooks/pre-push.
Useful flags: --config PATH (alternate config), --output-dir PATH
(alternate hooks dir), --dry-run (print to stdout, write nothing).
generate-hooks resolves the consumer's tier from the workspace-root
project_enforcement.yaml registry (autocreated from a template if
missing). A project with tier = vendored gets no hooks: the generator
logs and exits 0. Other tiers proceed normally.
.pre-commit-config.yaml as-is.../CI/scripts/cleanup-precommit to remove the framework.../CI/scripts/generate-hooks to install native hooks.pre-commit from pyproject.toml dependencies.cleanup-precommit removes ~/.cache/pre-commit/, the
.git/hooks/pre-commit.legacy backup, and uninstalls the pre-commit
package via pip and uv pip. It does NOT remove
.pre-commit-config.yaml unless called with --remove-config.
Some checks (notably ci_check_unstaged) auto-stage files they detect as
unstaged or untracked. If a commit attempt pulled in a file you did not
intend to commit, and git reset / git rm --cached are blocked by the
local git-guard:
# Unstage a brand-new file without removing it from the working tree
git update-index --force-remove <path>
# For a modified-but-tracked file, restore the index entry from HEAD
git update-index --cacheinfo 100644,$(git rev-parse HEAD:<path>),<path>
If the auto-staged file is a build/test artefact (.coverage,
__pycache__/, etc.), add it to .gitignore so the hook stops
re-capturing it.
Never bypass the hook with git commit --no-verify: it is blocked by the
git-guard, and the hook is almost certainly catching a real problem.
generate-hooks invokes
lib/parse_precommit_config.awk to
extract hook records from YAML. Each hook becomes one record with fields
separated by the \034 (FS) character:
id FS name FS entry FS stage FS pass_filenames FS always_run FS files FS exclude FS args FS types_or
A types: field is accepted as an alias for types_or:.
Hooks are grouped by stage: pre-commit (default), commit-msg,
pre-push. Stages with no hooks produce no file.
Every generated hook starts with:
#!/usr/bin/env bash # or Homebrew bash 5.x path on macOS
# AUTO-GENERATED by <ci-rel>/scripts/generate-hooks: do not edit.
set -euo pipefail
_ROOT="$(git rev-parse --show-toplevel)"
cd "$_ROOT"
source "${_ROOT}/<ci-rel>/lib/ci.sh"
_boot_path="$(ci_resolve_boot_path "$_ROOT")"
[[ -n "$_boot_path" ]] && export PATH="${_boot_path}:$PATH"
<ci-rel> is the relative path from the consumer repo to the CI repo.
The ci_resolve_boot_path call prepends boot bin/ directories (walking
up via moon.yml::project.inherited_boot_dirs) so hooks resolve tools
from the nearest boot dir first.
Two prefaces are always emitted:
quality_exceptions.yaml is missing at
the project root.scripts/compliance-report and
prints the result. Advisory: it never blocks the commit.For pre-commit, the staged file list is captured once:
_STAGED="$(git diff --cached --name-only --diff-filter=ACMR)"
For each hook in a stage:
entry is a hard error (generation
aborts with a diagnostic). files/exclude patterns containing
single quotes are rejected (they would break the generated grep).files regex is set and
always_run is not true, the hook runs only when staged files match
the regex. exclude filters matches via grep -vE.types_or (or types) entries are converted
to extension regexes (python -> \.(py)$, rust -> \.(rs)$,
javascript -> \.(js|mjs|cjs)$, jsx, ts, tsx, css, json,
markdown, yaml, toml) and merged into files (both must match
if files was already set). Unknown types log a warning and are
skipped.entry command runs with args appended. bash -c
in entries is rewritten to "$BASH" -c so child shells use the same
bash as the hook shebang.pass_filenames is true,
matching staged filenames are appended as arguments.id equal to ruff-format,
ruff, or ci-lint are treated as formatters. After execution, if
files were modified (git diff --quiet fails), the changes are
auto-staged with git add -A and the hook exits 1 with
"Re-run: git commit". The second commit run passes cleanly. This
two-pass pattern replaces stashing.id equal to check-boot-venv-layout
or check-dead-code are non-blocking: failures are surfaced as
warnings, never hard-fails."$1".The check-unstaged hook (lib/checks_core.sh
ci_check_unstaged), when configured, fails the commit if unstaged or
untracked files exist: it prints the diff, auto-stages everything with
git add -A, and exits 1 with "Re-run: git commit". Nothing is ever
temporarily removed from disk.
The .pre-commit-config.yaml format is a subset of the pre-commit spec.
Only repo: local hooks with language: system are supported.
| Field | Default | Description |
|---|---|---|
id |
(required) | Hook identifier |
name |
"" |
Display name (printed during execution) |
entry |
(required) | Shell command to execute |
language |
system |
Must be system (only supported value) |
stages |
[pre-commit] |
When to run: pre-commit, commit-msg, pre-push |
pass_filenames |
true |
Append matching staged filenames as arguments |
always_run |
false |
Run even if no staged files match files regex |
files |
"" |
Regex filter: only run when staged files match |
exclude |
"" |
Regex exclude: skip files matching this pattern |
args |
[] |
Extra arguments appended to entry |
types_or |
[] |
File type filters converted to extension regex |
types |
[] |
Alias for types_or |
verbose, require_serial, additional_dependencies,
minimum_pre_commit_version, repo (anything other than local).
repos:
- repo: local
hooks:
# --- Gate: unstaged changes ---
- id: check-unstaged
name: Block Unstaged Changes
entry: "bash -c 'source ../CI/lib/checks.sh && ci_check_unstaged'"
language: system
pass_filenames: false
always_run: true
# --- Formatters (modify files, trigger re-commit) ---
- id: ruff-format
name: ruff format
entry: uv run ruff format --config ../CI/ruff.toml .
language: system
pass_filenames: false
always_run: true
- id: ruff
name: ruff lint
entry: uv run ruff check --config ../CI/ruff.toml --fix .
language: system
pass_filenames: false
always_run: true
# --- Type checker ---
- id: mypy
name: mypy
entry: uv run mypy --config-file ../CI/mypy.toml src
language: system
pass_filenames: false
always_run: true
# --- Shared checks ---
- id: block-sensitive-files
name: Block Sensitive Files
entry: "bash -c 'source ../CI/lib/checks.sh && ci_block_sensitive_files'"
language: system
pass_filenames: false
always_run: true
- id: check-banned-words
name: Check Banned Words
entry: "bash -c 'source ../CI/lib/checks.sh && ci_check_banned_words'"
language: system
pass_filenames: false
always_run: true
- id: check-file-length
name: Check File Length (max 512 lines)
entry: "bash -c 'source ../CI/lib/checks.sh && ci_check_file_length'"
language: system
pass_filenames: false
always_run: true
# --- Commit message checks ---
- id: check-commit-message
name: Check Commit Message Format
entry: "bash -c 'source ../CI/lib/checks.sh && ci_check_commit_message \"$1\"' --"
language: system
stages: [commit-msg]
# --- Pre-push checks ---
- repo: local
hooks:
- id: ci-check-push
name: Pre-push Quality Gate (lint + type + test + coverage)
entry: "bash -c 'make check-push'"
language: system
stages: [pre-push]
pass_filenames: false
One concern per commit. A single commit should answer a single "why" question. Examples of legitimate and illegitimate bundling:
git blame and git bisect stay useful.When in doubt: would a reviewer want to git revert the behavior change
but keep the refactor? If yes, they belong in separate commits.
Instead of hand-writing .pre-commit-config.yaml, Makefile, and config
defaults, use scripts/scaffold-ci to
generate them from a declarative ci-profile.yaml. The full contract is
in REQ-SCAFFOLD-CI and
SPEC-SCAFFOLD-CI. Quick start:
# 1. Copy the template into your project
cp ../CI/templates/ci-profile.template.yaml myproject/ci-profile.yaml
# 2. Edit: set project name, tier, languages, trim hooks as needed
# 3. Generate CI integration files
make -C ../CI scaffold-ci ARGS="--consumer myproject --yes"
# 4. Install native git hooks
cd myproject && make install-hooks
Key behaviors:
--force-precommit, --force-makefile,
--force-configs, --force-all (each with an --apply-* alias). A
customised Makefile is never overwritten automatically; use
--append-makefile to add missing targets.--analyze, --diff, --json.mandatory: true in
config/required_hooks.yaml are
auto-appended to their stage if not explicitly listed.--dry-run prints what would be written without touching disk.*.scaffold-bak.<epoch>) are written by default;
--no-backup suppresses them.