chore: add pre-commit hooks + pre-push test guard#8018
Conversation
Adds local pre-commit and pre-push automation to catch lint/format issues and test failures BEFORE pushing to CI. ## Pre-commit hooks (.pre-commit-config.yaml) - **black** (mirrors CI: line-length 120, skip-string-normalization) - **ruff** (lint + format: unused imports, undefined names, noqa abuse) - **detect-secrets** (critical for open-source repo with creds in history) - **pre-commit-hooks** (no-commit-to-main, trailing-whitespace, check-yaml/json/toml, debug-statements, merge-conflict detection) ## Pre-push hook (.git/hooks/pre-push.example) - Runs pytest on tests matching changed backend Python files - Smart file→test mapping: exact match → prefix glob → import grep - Timing: ~8s total (~0.2s for 18 tests) - Bypass: SKIP_PRE_PUSH_TEST=1 or git push --no-verify ## Manual run scripts (.github/scripts/) - `run-lint.sh` — Run all lints manually (`--fix`, `--files`) - `run-pre-push.sh` — Simulate pre-push test check ## Timings (M2 MacBook, warm cache) | Hook | Time | |------|------| | black | ~0.3s | | ruff (lint+format) | ~0.5s | | detect-secrets | ~1.2s | | pre-commit-hooks | ~0.1s | | **Pre-commit total** | **~2.1s** | | Pre-push (18 tests) | **~7.9s** | ## Setup ```bash pip install pre-commit detect-secrets pre-commit install # pre-commit hooks cp .git/hooks/pre-push.example .git/hooks/pre-push && chmod +x .git/hooks/pre-push # pre-push ``` Note: CI integration (pre-commit/action@v3) should be added to .github/workflows/lint.yml by a maintainer with workflow scope.
Greptile SummaryThis PR introduces pre-commit hooks, a detect-secrets baseline, and two helper shell scripts to catch formatting/lint issues and test failures before pushing. The tooling infrastructure is well-structured but several of the scripts contain logic errors that prevent them from working correctly out of the box.
Confidence Score: 3/5The pre-commit config and secrets baseline are safe to merge; the two shell scripts have logic errors that make them non-functional in their current state. Both helper scripts contain defects that prevent their core functionality from working:
Important Files Changed
Flowchart%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[git commit] --> B{pre-commit hooks}
B --> C[black format]
B --> D[ruff lint+fix]
B --> E[ruff-format]
B --> F[detect-secrets]
B --> G[pre-commit-hooks\ntrailing-ws, yaml, etc.]
C -->|conflict risk| E
E -->|conflict risk| C
B --> H{all pass?}
H -->|yes| I[commit created]
H -->|no| J[abort + show errors]
K[git push] --> L[pre-push hook]
L --> M[run-pre-push.sh]
M --> N{find MERGE_BASE}
N -->|MERGE_BASE bug: executes ref as command| O[set -e aborts script]
N -->|fixed| P[git diff changed py files]
P --> Q[find matching tests]
Q --> R[pytest --timeout=30]
R -->|pass| S[push proceeds]
R -->|fail| T[push blocked]
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
A[git commit] --> B{pre-commit hooks}
B --> C[black format]
B --> D[ruff lint+fix]
B --> E[ruff-format]
B --> F[detect-secrets]
B --> G[pre-commit-hooks\ntrailing-ws, yaml, etc.]
C -->|conflict risk| E
E -->|conflict risk| C
B --> H{all pass?}
H -->|yes| I[commit created]
H -->|no| J[abort + show errors]
K[git push] --> L[pre-push hook]
L --> M[run-pre-push.sh]
M --> N{find MERGE_BASE}
N -->|MERGE_BASE bug: executes ref as command| O[set -e aborts script]
N -->|fixed| P[git diff changed py files]
P --> Q[find matching tests]
Q --> R[pytest --timeout=30]
R -->|pass| S[push proceeds]
R -->|fail| T[push blocked]
Reviews (1): Last reviewed commit: "chore: add pre-commit hooks + pre-push t..." | Re-trigger Greptile |
| MERGE_BASE="" | ||
| for candidate in main master; do | ||
| if git rev-parse --verify "$REMOTE/$candidate" >/dev/null 2>&1; then | ||
| MERGE_BASE="$($REMOTE/$candidate)" |
There was a problem hiding this comment.
MERGE_BASE assignment uses command substitution instead of string assignment
"$($REMOTE/$candidate)" should be "$REMOTE/$candidate" — the $() syntax runs the value as a shell command. With set -e, this causes the script to exit immediately after git rev-parse confirms the ref exists.
| MERGE_BASE="$($REMOTE/$candidate)" | |
| MERGE_BASE="$REMOTE/$candidate" |
| if git rev-parse --verify "$REMOTE/$candidate" >/dev/null 2>&1; then | ||
| MERGE_BASE="$($REMOTE/$candidate)" | ||
| break | ||
| fi |
There was a problem hiding this comment.
Command-substitution bug makes script non-functional
"$($REMOTE/$candidate)" wraps the ref name (e.g., origin/main) in $(), which tries to execute it as a shell command. That command doesn't exist, so it returns exit code 127. Because set -euo pipefail is active at the top, the script aborts immediately whenever the git rev-parse check succeeds — the exact case the script is supposed to handle. The assigned value is always empty, so the [ -z "$MERGE_BASE" ] guard on line 33 will always exit the script with an error message.
| exit 1 | ||
| fi | ||
|
|
||
| CHANGED_FILES=$(git diff --name-only "$(git merge-base "$REMOTE/main" HEAD 2>/dev/null || echo HEAD)" HEAD -- 'backend/**/*.py' 2>/dev/null || true) |
There was a problem hiding this comment.
$MERGE_BASE computed but never used downstream
The loop populates $MERGE_BASE but the git diff on line 39 hardcodes $REMOTE/main instead of using the variable. If the remote only has master (or any other name), the diff silently falls back to echo HEAD, so the "changed files" list becomes every file in HEAD — running a much larger test set than intended.
| CHANGED_FILES=$(git diff --name-only "$(git merge-base "$REMOTE/main" HEAD 2>/dev/null || echo HEAD)" HEAD -- 'backend/**/*.py' 2>/dev/null || true) | |
| CHANGED_FILES=$(git diff --name-only "$(git merge-base "$MERGE_BASE" HEAD 2>/dev/null || echo HEAD)" HEAD -- 'backend/**/*.py' 2>/dev/null || true) |
| echo "⚠️ pre-commit not installed, running tools directly" | ||
| for f in $FILES; do | ||
| [ "${f##*.}" != "py" ] && continue | ||
| echo " black $f" && black --line-length=120 --skip-string-normalization ${ARGS:+--check} "$f" || true |
There was a problem hiding this comment.
--fix flag has inverted effect on the black invocation
${ARGS:+--check} expands to --check when ARGS is non-empty — i.e., exactly when --fix was passed. So ./run-lint.sh --fix runs black in check-only mode (no changes applied), and omitting --fix runs black in write mode (modifying files). The intended behavior is the reverse.
| echo " black $f" && black --line-length=120 --skip-string-normalization ${ARGS:+--check} "$f" || true | |
| echo " black $f" && black --line-length=120 --skip-string-normalization ${ARGS:-"--check"} "$f" || true |
| # ── Python formatting (mirrors CI: black --line-length 120) ── | ||
| - repo: https://github.com/psf/black | ||
| rev: 24.4.2 | ||
| hooks: | ||
| - id: black | ||
| name: black (format) | ||
| language_version: python3.9 | ||
| args: [--line-length=120, --skip-string-normalization] | ||
| types_or: [python] | ||
|
|
||
| # ── Python linting + import sorting (NOT in CI — free bug finding) ── | ||
| - repo: https://github.com/astral-sh/ruff-pre-commit | ||
| rev: v0.4.4 | ||
| hooks: | ||
| # Lint + auto-fix (unused imports, undefined names, noqa abuse) | ||
| - id: ruff | ||
| name: ruff (lint + fix) | ||
| args: [--fix, --exit-non-zero-on-fix, --target-version, py39] | ||
| types_or: [python] | ||
| # Formatter (complements black, handles things black doesn't) | ||
| - id: ruff-format | ||
| name: ruff-format | ||
| args: [--line-length=120] | ||
| types_or: [python] |
There was a problem hiding this comment.
Running both
black and ruff-format will cause a formatting loop
black and ruff-format are competing formatters. Even though ruff-format aims to be black-compatible, edge cases exist where they disagree on output. When they do, each hook fixes the file differently, causing pre-commit to re-run infinitely (or until the retry limit). The ruff docs explicitly recommend choosing one or the other. Since ruff-format supersedes black and is already configured here, removing the black hook is the cleaner approach.
| # ── Python formatting (mirrors CI: black --line-length 120) ── | |
| - repo: https://github.com/psf/black | |
| rev: 24.4.2 | |
| hooks: | |
| - id: black | |
| name: black (format) | |
| language_version: python3.9 | |
| args: [--line-length=120, --skip-string-normalization] | |
| types_or: [python] | |
| # ── Python linting + import sorting (NOT in CI — free bug finding) ── | |
| - repo: https://github.com/astral-sh/ruff-pre-commit | |
| rev: v0.4.4 | |
| hooks: | |
| # Lint + auto-fix (unused imports, undefined names, noqa abuse) | |
| - id: ruff | |
| name: ruff (lint + fix) | |
| args: [--fix, --exit-non-zero-on-fix, --target-version, py39] | |
| types_or: [python] | |
| # Formatter (complements black, handles things black doesn't) | |
| - id: ruff-format | |
| name: ruff-format | |
| args: [--line-length=120] | |
| types_or: [python] | |
| # ── Python linting + import sorting (NOT in CI — free bug finding) ── | |
| - repo: https://github.com/astral-sh/ruff-pre-commit | |
| rev: v0.4.4 | |
| hooks: | |
| # Lint + auto-fix (unused imports, undefined names, noqa abuse) | |
| - id: ruff | |
| name: ruff (lint + fix) | |
| args: [--fix, --exit-non-zero-on-fix, --target-version, py39] | |
| types_or: [python] | |
| # Formatter — black-compatible, replaces black (mirrors CI line-length) | |
| - id: ruff-format | |
| name: ruff-format | |
| args: [--line-length=120] | |
| types_or: [python] |
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: dcfe21f534
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| MERGE_BASE="" | ||
| for candidate in main master; do | ||
| if git rev-parse --verify "$REMOTE/$candidate" >/dev/null 2>&1; then | ||
| MERGE_BASE="$($REMOTE/$candidate)" |
There was a problem hiding this comment.
Resolve remote refs instead of executing them
In a normal clone where origin/main or origin/master exists, this assignment tries to run the ref name as a shell command (for example origin/main) rather than resolving it. Because the script has set -e, that exits before any changed files or tests are computed, so the pre-push check is unusable for the main advertised case; use git rev-parse "$REMOTE/$candidate" or store the ref name instead.
Useful? React with 👍 / 👎.
| echo "" | ||
|
|
||
| cd backend | ||
| python -m pytest -v --timeout=30 --tb=short $TEST_TARGETS |
There was a problem hiding this comment.
Add pytest-timeout before using --timeout
When matching backend tests are found, this command passes --timeout=30, but the checked backend test environment does not provide that option (python -m pytest --help has no --timeout, and backend/requirements.txt does not install pytest-timeout). As a result the guard exits with an unrecognized-argument error instead of running the selected tests unless contributors happen to have that plugin installed outside the repo.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
7 issues found across 4 files
Tip: instead of fixing issues one by one fix them all with cubic
Re-trigger cubic
kodjima33
left a comment
There was a problem hiding this comment.
Chore (pre-commit hooks + pre-push test guard) — approve only per policy (tooling/chore, not bug fix).
run-pre-push.sh: - P1: Fix command execution bug — use `git rev-parse` instead of bare ref (was trying to execute remote ref as shell command) - P2: Use detected base branch instead of hardcoding origin/main run-lint.sh: - P1: Fix reversed black logic — --check mode now correctly passes --check to black, fix mode omits it - P2: Remove unsupported --fix flag from pre-commit run (pre-commit auto-fixes via individual hooks, not a global flag) - P2: Same --fix removal in all-files mode .secrets.baseline: - P1: Tighten exclude regex patterns with proper anchors (^|/) and (/|$) to prevent overly broad matching (e.g., 'build' no longer matches filenames containing that substring) .pre-commit-config.yaml: - P2: Remove ruff-format hook (conflicts with black — ruff docs state they are not intended to be used interchangeably due to known output deviations; keep black as sole formatter)
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: d9b206ca33
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| # Timing: ~5-30s depending on how many files changed | ||
|
|
||
| set -euo pipefail | ||
| cd "$(dirname "$0")/.." |
There was a problem hiding this comment.
Run the pre-push guard from the repo root
When the script is invoked as documented from the repo root, this cd lands in /workspace/omi/.github, not /workspace/omi. From that directory Git treats the 'backend/**/*.py' pathspec as relative to .github (for example, git -C .github ls-files 'backend/**/*.py' matches 0 files), so backend Python changes are reported as “No backend Python files changed” and no tests run. Change this to the repo root, such as ../.. or git rev-parse --show-toplevel.
Useful? React with 👍 / 👎.
| exit 1 | ||
| fi | ||
|
|
||
| CHANGED_FILES=$(git diff --name-only "$MERGE_BASE" HEAD -- 'backend/**/*.py' 2>/dev/null || true) |
There was a problem hiding this comment.
Include backend root modules in the pre-push diff
This pathspec skips Python files directly under backend/; in this repo backend/main.py and backend/dependencies.py are not matched by git ls-files 'backend/**/*.py'. Changes to those core modules therefore bypass the pre-push test guard even after the script runs from the right directory; include backend/*.py or use a pathspec that covers both depths.
Useful? React with 👍 / 👎.
| find backend/tests/unit backend/tests/integration \ | ||
| -name "test_${MODULE_NAME}*.py" >> "$TEST_TMP" 2>/dev/null || true |
There was a problem hiding this comment.
Normalize prefix-glob test paths before filtering
The prefix-glob branch writes paths like backend/tests/unit/test_foo.py, but the later filter checks [ -f "backend/$t" ], turning those into backend/backend/tests/... and discarding every test found by find. In the case where only a prefix match exists, the guard incorrectly prints “No matching test files found”; emit paths relative to backend/ here or adjust the filter.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
2 issues found across 4 files (changes from recent commits).
Reply with feedback, questions, or to request a fix.
Fix all with cubic | Re-trigger cubic
Both scripts live in .github/scripts/, so $(dirname "$0")/.. resolved to .github/ not the repo root. Changed to ../..
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 91f8731684
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| # Pre-push hook runs pytest on changed files (~10-30s). | ||
| # See: .git/hooks/pre-push |
There was a problem hiding this comment.
Register the pre-push guard as an actual hook
The only tracked pre-push code in this change is the manual .github/scripts/run-pre-push.sh simulator; .git/hooks is not versioned, and this config does not add default_install_hook_types or a local hook staged for pre-push. Per the pre-commit docs, installing a pre-push hook requires --hook-type pre-push or equivalent config, so a contributor following the added setup will get commit hooks but a normal git push will never run the pytest guard.
Useful? React with 👍 / 👎.
| # Validate config files | ||
| - id: check-yaml | ||
| args: [--unsafe] # allow custom tags in workflows | ||
| - id: check-json |
There was a problem hiding this comment.
Exclude or fix existing invalid JSON before enabling checks
When the documented pre-commit run --all-files path or run-lint.sh all-files path runs, this hook processes existing tracked JSON files that are already invalid: app/.vscode/settings.json has a trailing comma and app/web/manifest.json is empty. Because the new config neither excludes nor fixes those files, a clean checkout fails the new lint suite before it reaches a contributor's changes.
Useful? React with 👍 / 👎.
| echo " black (check) $f" && black --check --line-length=120 --skip-string-normalization "$f" || true | ||
| echo " ruff (check) $f" && ruff check --target-version py39 "$f" || true |
There was a problem hiding this comment.
Propagate fallback lint failures
In the supported fallback path where pre-commit is not installed, each direct black/ruff invocation is followed by || true, so the script exits successfully even when the tools report errors or are missing. I ran this path on backend/main.py in the repo and it printed ruff errors followed by ✅ Lint complete with exit 0, which lets contributors or CI wrappers silently accept lint failures.
Useful? React with 👍 / 👎.
run-pre-push.sh: - P2: Add backend/*.py pathspec to catch root-level Python files (backend/**/*.py alone misses backend/main.py etc.) - P2: Strip leading 'backend/' from find output to prevent double-prefix in test target paths - P2: Guard --timeout flag behind pytest-timeout plugin detection, gracefully degrade if plugin not installed pre-push.example (same fixes synced): - Same pathspec, prefix-strip, and timeout-guard fixes run-lint.sh: - P2: Propagate fallback lint failures — replace || true with error tracking and exit non-zero if any tool fails .pre-commit-config.yaml: - P2: Add exclude patterns to check-json for known-invalid JSON (Flutter app JSON, Firebase config, IDE files, lock files)
There was a problem hiding this comment.
1 issue found across 3 files (changes from recent commits).
Tip: Review your code locally with the cubic CLI to iterate faster.
Fix all with cubic | Re-trigger cubic
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 6a04ffcee7
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| MODULE_NAME=$(basename "$f" .py) | ||
|
|
||
| # If the changed file IS a test file, run it directly | ||
| if [[ "$REL_PATH" == tests/*/test_*.py ]]; then |
There was a problem hiding this comment.
Include top-level backend tests in the guard
When the changed file is a test directly under backend/tests (this repo has backend/tests/test_cache_manager.py), REL_PATH becomes tests/test_cache_manager.py, which does not match tests/*/test_*.py; it then falls through to the source-file mapping and the later search only considers tests/unit and tests/integration. A push that edits that existing test file therefore reports no matching tests instead of running the test that changed.
Useful? React with 👍 / 👎.
| cd backend | ||
|
|
||
| # Build pytest args — only include --timeout if plugin is available | ||
| PYTEST_ARGS="-v --tb=short" |
There was a problem hiding this comment.
Detect pytest-timeout with a supported option
In environments that actually have pytest-timeout installed, this probe still returns nonzero because upstream pytest-timeout registers --timeout, --timeout-method, and --session-timeout, but not --timeout-version (checked its pytest_addoption implementation: https://raw.githubusercontent.com/pytest-dev/pytest-timeout/main/pytest_timeout.py). The script therefore always drops the intended 30s limit, so a hanging matched backend test can block the pre-push guard indefinitely; detect the plugin via pytest --help or an import instead.
Useful? React with 👍 / 👎.
Replace broad app/.*\.json with specific patterns: - app/lib/*.g.json (Dart codegen generated) - app/*.arb (App resource bundles, not strict JSON) - Keep .firebase/, .idea/, lock files
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: e4bce4d9d3
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| hooks: | ||
| - id: black | ||
| name: black (format) | ||
| language_version: python3.9 |
There was a problem hiding this comment.
Use the repo Python for the black hook
In a Python 3.11-only dev or CI environment, this pin makes pre-commit create the black hook env with python3.9, but the repo’s backend images use Python 3.11 and the setup instructions only install pre-commit, not a 3.9 interpreter. That causes the first hook install/run to fail before any linting; use the repo Python version or omit language_version so pre-commit uses the installed interpreter.
Useful? React with 👍 / 👎.
|
|
||
| # ── Secret detection (critical for open-source repo with creds in history) ── | ||
| - repo: https://github.com/Yelp/detect-secrets | ||
| rev: v1.4.0 |
There was a problem hiding this comment.
Pin detect-secrets to the baseline version
This hook is pinned to detect-secrets v1.4.0, but the committed baseline is version 1.5.0 and lists v1.5-only plugins such as OpenAIDetector and IPPublicDetector. When the pre-commit hook runs, v1.4.0 initializes plugins from the baseline and errors on the unknown plugin before scanning, so ordinary commits that reach this hook are blocked; pin the hook to v1.5.0 or regenerate the baseline with v1.4.0.
Useful? React with 👍 / 👎.
| ] | ||
| } | ||
| ], | ||
| "results": {}, |
There was a problem hiding this comment.
Record existing secrets in the baseline
The new baseline enables detectors such as PrivateKeyDetector but records no findings, while tracked files like omi/firmware/bootloader/mcuboot/enc-rsa2048-priv.pem and root-rsa-2048.pem contain RSA private-key blocks and are not excluded by these filters. After the hook version issue is fixed, the documented pre-commit run --all-files / run-lint.sh all-files path reports those existing keys on a clean checkout instead of only new leaks; update the baseline or explicitly exclude known test keys.
Useful? React with 👍 / 👎.
Summary
Adds pre-commit and pre-push automation so contributors catch lint/format issues and test failures locally before pushing to CI.
This would have caught the black formatting failure on PR #7954 (which wasted a CI run).
What's included
Pre-commit hooks (
.pre-commit-config.yaml)--line-length 120)mainbreakpoint()/pdbTotal pre-commit time: ~2.1s (warm cache, M2 MacBook)
Pre-push hook (
.git/hooks/pre-push.example)SKIP_PRE_PUSH_TEST=1orgit push --no-verifyManual scripts (
.github/scripts/)run-lint.sh— Run all lints manually (--fix,--files <list>)run-pre-push.sh— Simulate pre-push test check without pushingSetup
CI integration (for maintainer with workflow scope)
Add this step to
.github/workflows/lint.yml:This ensures PRs from contributors without pre-commit installed still get the same checks.
Files added
.pre-commit-config.yaml— Pre-commit hook definitions.secrets.baseline— Initial detect-secrets baseline (excludes known FPs).github/scripts/run-lint.sh— Manual lint runner.github/scripts/run-pre-push.sh— Manual pre-push test simulator.git/hooks/pre-push.example— Pre-push hook template