Skip to content

fix(hooks): bake absolute interpreter paths into generated hooks#157

Merged
ikroal merged 2 commits into
mainfrom
fix/hook-template-venv-fallback
May 5, 2026
Merged

fix(hooks): bake absolute interpreter paths into generated hooks#157
ikroal merged 2 commits into
mainfrom
fix/hook-template-venv-fallback

Conversation

@ikroal
Copy link
Copy Markdown
Owner

@ikroal ikroal commented May 5, 2026

Summary

Generated git hooks (5 stages) 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.

Fix: at install time, bake the absolute path of ac-guard and
python3 into the generated hook scripts. Hooks then exec these
absolute paths with zero runtime lookup or PATH dependency.

This was self-discovered while running git commit from inside
Claude Code on this very repo — Claude Code's shell doesn't
auto-source the venv, so the existing .git/hooks/pre-commit
threw ac-guard: command not found.

Why this is safe

Hook files are not committed (.gitignore / .git/hooks/) — they're
regenerated by ac-guard install 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
or update. 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 fallback
    → 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 print
    Hooks linked to: <abs path> so users can see which install the
    hooks point at — important when both global and venv installs exist.
  • update_command automatically picks up path changes because it
    re-runs the same _run_generator_pipeline as install (no
    update-specific code needed).

Test plan

  • uv run pytest tests/ — 1214/1214 green (was 1205 before; +9
    from new tests)
  • uv run lint-imports — both contracts kept
  • uv run pre-commit run --all-files — all hooks pass
  • New core regression tests/integration/test_hook_venv_fallback.py:
    • pre-commit hook executes with PATH=/usr/bin:/bin
    • cursor hook executes with the same stripped PATH
    • opencode TS plugin embeds an absolute python path constant
  • New unit tests TestResolveAcGuardExecutable: fast path,
    PATH fallback, and the unresolvable→GeneratorError contract.
  • Real-world dogfood: ran ac-guard install --agent claude-code then PATH=/usr/bin:/bin .git/hooks/pre-commit
    Stage: pre-commit — PASSED, exit 0.

Plan

.claude/plans/action-guard-melodic-backus.md
in the local working tree (not committed; the file lives outside the
repo under ~/.claude/plans/).

🤖 Generated with Claude Code

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>
@ikroal ikroal added this to the M5: PR Report & Polish milestone May 5, 2026
@ikroal ikroal added the bug Something isn't working label May 5, 2026
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>
@ikroal ikroal merged commit 25bc9f6 into main May 5, 2026
14 checks passed
@ikroal ikroal deleted the fix/hook-template-venv-fallback branch May 5, 2026 05:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant