Skip to content
workspaceguardrails
  • Home
  • Open Source[4]
  • System Policies[0]
  • Git Hooks[21]
  • Anti-Patterns[293]
  • Hook Configs[20]
  • Sandbox Configs[27]
  • Tools & Scripts[14]
  • AI Governance[20]
  • LLM Gateway
  • Static Analysis
  • Playground
  • Integration Guide
  1. Home
  2. Integration Guide
    The Digital and AI Workspace Guardrails Platform2026 ◆ Independent AI Labs
    Skip to content
    workspaceguardrails
    • Home
    • Open Source[4]
    • System Policies[0]
    • Git Hooks[21]
    • Anti-Patterns[293]
    • Hook Configs[20]
    • Sandbox Configs[27]
    • Tools & Scripts[14]
    • AI Governance[20]
    • LLM Gateway
    • Static Analysis
    • Playground
    • Integration Guide
    1. Home
    2. Integration Guide
      The Digital and AI Workspace Guardrails Platform2026 ◆ Independent AI Labs
      Skip to content
      workspaceguardrails
      • Home
      • Open Source[4]
      • System Policies[0]
      • Git Hooks[21]
      • Anti-Patterns[293]
      • Hook Configs[20]
      • Sandbox Configs[27]
      • Tools & Scripts[14]
      • AI Governance[20]
      • LLM Gateway
      • Static Analysis
      • Playground
      • Integration Guide
      1. Home
      2. Integration Guide
        The Digital and AI Workspace Guardrails Platform2026 ◆ Independent AI Labs

        RUNBOOK-HOOKS: Native Git Hook Generation and Operation

        Date: 2026-07-17 Status: Active Type: Runbook


        Purpose

        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:

        • REQ-BOOT-LAYOUT: boot directory layout and PATH resolution used by generated hooks
        • REQ-PORTABILITY: shell portability contract the generated hooks and lib/ comply with
        • REQ-SCAFFOLD-CI: profile-driven generator for consumer .pre-commit-config.yaml files

        Prerequisites

        • A git repository with a .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__.
        • bash 4.3+ on PATH (nameref support). On macOS, generated hooks prefer Homebrew bash 5.x (/opt/homebrew/bin/bash, then /usr/local/bin/bash) over the system bash 3.2.

        Procedures

        1. Install or regenerate hooks

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

        2. Tier behavior

        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.

        3. Migrate from the pre-commit framework

        1. Keep your .pre-commit-config.yaml as-is.
        2. Run ../CI/scripts/cleanup-precommit to remove the framework.
        3. Run ../CI/scripts/generate-hooks to install native hooks.
        4. Remove 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.

        4. Recover from "hook auto-staged a file you did not want"

        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.

        How It Works

        Parsing

        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:.

        Grouping

        Hooks are grouped by stage: pre-commit (default), commit-msg, pre-push. Stages with no hooks produce no file.

        Generated hook header

        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:

        1. quality_exceptions.yaml preflight (every stage, all tiers): hard-fails the hook chain if quality_exceptions.yaml is missing at the project root.
        2. Compliance report preamble (pre-commit only): runs 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)"
        

        Per-hook emission

        For each hook in a stage:

        1. Entry validation: an empty entry is a hard error (generation aborts with a diagnostic). files/exclude patterns containing single quotes are rejected (they would break the generated grep).
        2. File gating (pre-commit only): if 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.
        3. types_or conversion: 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.
        4. Execution: the 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.
        5. Filename passing (pre-commit only): if pass_filenames is true, matching staged filenames are appended as arguments.
        6. Formatter detection: hooks with 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.
        7. Advisory checks: hooks with id equal to check-boot-venv-layout or check-dead-code are non-blocking: failures are surfaced as warnings, never hard-fails.
        8. commit-msg hooks receive the commit message file path as "$1".

        Unstaged-changes gate

        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.

        Configuration Format

        The .pre-commit-config.yaml format is a subset of the pre-commit spec. Only repo: local hooks with language: system are supported.

        Supported Fields

        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

        Unsupported Fields (ignored)

        verbose, require_serial, additional_dependencies, minimum_pre_commit_version, repo (anything other than local).

        Complete Example

        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
        

        Commit Scope Rule

        One concern per commit. A single commit should answer a single "why" question. Examples of legitimate and illegitimate bundling:

        • Renaming a symbol and updating every caller: one concern (the rename).
        • Adding a feature and the test that proves it works: one concern.
        • Deleting a module and the doc references that pointed at it: one concern (the cleanup).
        • Adding a feature AND bumping a dependency AND reformatting an unrelated file: three concerns, three commits.
        • "Big refactor" commits that mix a module split with behavior changes: split them so 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.

        Bootstrapping a New Project with scaffold-ci

        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:

        • Granular overwrite flags: --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.
        • Inspection modes (read-only): --analyze, --diff, --json.
        • Hooks marked 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.
        • Backups (*.scaffold-bak.<epoch>) are written by default; --no-backup suppresses them.