fix(hooks): bake absolute interpreter paths into generated hooks#157
Merged
Conversation
Generated git hooks (5 stages: pre-commit, pre-push, commit-msg,
pre-merge-commit, pre-rebase) and agent hooks (cursor, opencode)
previously called `ac-guard run` / `python3 -m ac_guard.action_guard`
directly, requiring those commands on $PATH. In typical Python
projects ac-guard lives only in `.venv/bin/`, so callers that don't
activate the venv (Claude Code's PreToolUse hook, generic CI runners,
IDEs that don't auto-source venv) hit `command not found` and the
hook silently broke the workflow.
Resolution: at install time, bake the absolute path of the relevant
interpreter into the rendered hook scripts. Hooks then exec these
absolute paths with zero runtime lookup or PATH dependency.
Why this is safe:
- Hook files are not committed to the repo (.gitignore'd or under
.git/hooks/); `ac-guard install` regenerates them on every machine.
- venv changes always coincide with reinstalling ac-guard (the package
itself lives in the venv), which the user already follows with
`ac-guard install` / `update` to refresh artifacts.
- Therefore baked paths cannot become stale relative to the venv.
Coverage:
- generator/core.py: new `_resolve_ac_guard_executable()` (probe
`Path(sys.executable).parent / "ac-guard"` -> `shutil.which` ->
raise GeneratorError). Refusing to bake a bogus path beats
silently producing a broken hook.
- 5 git hook templates use `exec "{{ ac_guard_executable }}"`.
- cursor.j2 and opencode.j2 use the existing `python_executable`
context already injected by adapters/_render.py:101 for
claude_code.j2 — single source of truth.
- claude_code.j2 unchanged (its os.execv-to-baked-sys.executable
fallback already solves this for Python hooks).
- cli/install.py: install_command and update_command both echo
`Hooks linked to: <abs path>` so users see which install the
hooks point at (matters when both global and venv installs exist).
Tests:
- New tests/integration/test_hook_venv_fallback.py: pre-commit and
cursor hooks execute successfully with PATH=/usr/bin:/bin
(no ac-guard / python on PATH).
- New TestResolveAcGuardExecutable: covers fast path, fallback path,
and the unresolvable -> GeneratorError contract.
- Updated cursor/opencode/git-hook content assertions to require an
absolute path (`/...`) rather than the bare command names that the
pre-fix hooks used.
- Snapshots regenerated (cursor.j2, opencode.j2) — pinned via the
existing _PINNED_PYTHON fixture so they stay reproducible across
hosts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Five Windows-specific issues surfaced after CI ran on the previous commit. All were in the test layer's POSIX assumptions or in the resolver missing the `.exe` console-script suffix: 1. _resolve_ac_guard_executable() now probes both `ac-guard` and `ac-guard.exe` in `Path(sys.executable).parent`. Windows console_scripts ship as `<name>.exe` in `Scripts\`, so the bare- name fast path always missed and forced an unnecessary shutil.which() round-trip (and broke the unit test that validated the fast path). 2. Test assertions like `'exec "/' in content` were POSIX-only; Windows bakes `D:\...\.venv\Scripts\ac-guard.exe`. Replaced with regex extraction + `os.path.isabs(...)` checks across: - tests/unit/test_adapters/test_hooks.py (Cursor + OpenCode) - tests/unit/test_generator/test_core.py (git hooks) - tests/integration/test_action_guard_e2e.py (cursor hook) - tests/integration/test_hook_venv_fallback.py (all) 3. TestResolveAcGuardExecutable fixtures now build `ac-guard.exe` / `python.exe` on Windows so shutil.which (which consults PATHEXT) actually finds them in the fallback test. 4. The minimal-PATH execution tests in test_hook_venv_fallback are marked `skipif sys.platform == "win32"` — the rendered hooks use POSIX shebangs (`#!/bin/bash`) and cannot be invoked directly via subprocess on Windows. Git itself routes hook execution through git-bash, which is outside the contract this regression covers. 5. Inline `import os, re` calls in three test files hoisted to module top-level imports for consistency with the surrounding style. No production semantics changed — only Windows packaging awareness in the resolver and platform-portable test scaffolding. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Generated git hooks (5 stages) and agent hooks (cursor, opencode)
previously called
ac-guard run/python3 -m ac_guard.action_guarddirectly, requiring those commands on
$PATH. In typical Pythonprojects ac-guard lives only in
.venv/bin/, so callers that don'tactivate the venv (Claude Code's PreToolUse hook, generic CI runners,
IDEs that don't auto-source venv) hit
command not foundand thehook silently broke the workflow.
Fix: at install time, bake the absolute path of
ac-guardandpython3into the generated hook scripts. Hooks thenexectheseabsolute paths with zero runtime lookup or PATH dependency.
This was self-discovered while running
git commitfrom insideClaude Code on this very repo — Claude Code's shell doesn't
auto-source the venv, so the existing
.git/hooks/pre-committhrew
ac-guard: command not found.Why this is safe
Hook files are not committed (
.gitignore/.git/hooks/) — they'reregenerated by
ac-guard installon every machine. venv changesalways coincide with reinstalling ac-guard (the package itself lives
in the venv), which the user already follows with
ac-guard installor
update. Therefore baked paths cannot become stale relative tothe venv.
Coverage
generator/core.py: new_resolve_ac_guard_executable()— probePath(sys.executable).parent / "ac-guard"→shutil.whichfallback→ raise
GeneratorError. Refusing to bake a bogus path beatssilently producing a broken hook.
exec "{{ ac_guard_executable }}".cursor.j2andopencode.j2use the existingpython_executablecontext already injected by
adapters/_render.py:101forclaude_code.j2— single source of truth.claude_code.j2unchanged (itsos.execv-to-baked-sys.executablefallback already solves this for Python hooks).
cli/install.py:install_commandandupdate_commandboth printHooks linked to: <abs path>so users can see which install thehooks point at — important when both global and venv installs exist.
update_commandautomatically picks up path changes because itre-runs the same
_run_generator_pipelineas install (noupdate-specific code needed).
Test plan
uv run pytest tests/— 1214/1214 green (was 1205 before; +9from new tests)
uv run lint-imports— both contracts keptuv run pre-commit run --all-files— all hooks passtests/integration/test_hook_venv_fallback.py:PATH=/usr/bin:/binTestResolveAcGuardExecutable: fast path,PATH fallback, and the unresolvable→
GeneratorErrorcontract.ac-guard install --agent claude-codethenPATH=/usr/bin:/bin .git/hooks/pre-commit—Stage: pre-commit — PASSED, exit 0.Plan
.claude/plans/action-guard-melodic-backus.mdin the local working tree (not committed; the file lives outside the
repo under
~/.claude/plans/).🤖 Generated with Claude Code