Skip to content

feat: add crash-safe prepare-commit-msg hooks - #13

Merged
getdownandcode merged 4 commits into
mainfrom
feat
Sep 23, 2026
Merged

getdownandcode merged 4 commits into
mainfrom
feat

Conversation

@getdownandcode

Copy link
Copy Markdown
Owner

Summary

  • Add crash-safe prepare-commit-msg integration with a four-second timeout, atomic message-file replacement, and unconditional success from the hook launcher.
  • Generate only for empty or template sources; preserve explicit messages, merges, squashes, and other Git message sources.
  • Add gitmate install-hook / uninstall-hook, with idempotent installs and refusal to overwrite unmanaged hooks.
  • Add a Python prepare-commit-msg pre-commit hook and document the local versus team setup.
  • Record hook generations as commit-hook metrics while reusing the existing cache and fallback pipeline.

Verification

  • uv run pytest -q — 278 passed, including real-repository generation, -m preservation, merge preservation, simulated worker crash, and timeout tests.
  • uv run ruff check . — passed.
  • uv run ruff format --check . — passed.
  • uv run mypy src/gitmate — passed.
  • uv build — built source distribution and wheel.

Safety behavior

The hook only prepares Git's message file and never runs git commit. A provider failure uses the normal fallback; any worker error or timeout leaves Git's current message in place and allows the normal editor flow to continue.

@getdownandcode

Copy link
Copy Markdown
Owner Author

CI Fix & Reliability Hardening

All checks are now green across GitHub Actions CI (ci/test (push) and ci/test (pull_request)).

Fixes Applied in 3cbbf5c:

  1. Fixed Strict Mypy CI Crash:

    • Fixed unexported attribute access in tests/test_hooks.py by targeting "gitmate.hooks.subprocess.run" directly in monkeypatch.setattr.
  2. Preserved Git Status Comments & Commit Templates:

    • Updated _run_worker to prepend the generated commit message above existing file contents rather than completely overwriting COMMIT_EDITMSG.
    • Preserves Git's built-in comment block (# Please enter the commit message..., branch name, diffstat) and existing commit.template contents below the generated message. Added test_commit_preserves_template_and_prepends_message covering this.
  3. POSlX Shell Shebang Wrapper (#!/bin/sh):

    • Replaced direct virtualenv hashbang (#!{sys.executable}) with a portable POSIX #!/bin/sh wrapper that resolves Python and delegates with an inline timeout launcher.
    • Prevents fatal Git commit failures (fatal: cannot exec .git/hooks/prepare-commit-msg: No such file or directory) if the original virtualenv is deleted, moved, or upgraded.
  4. Added gitmate commit --hook-mode CLI Support:

    • Added --hook-mode option to gitmate commit in src/gitmate/cli.py with unit tests (test_cli_commit_hook_mode), fulfilling the roadmap interface specified in plan.md alongside the standalone gitmate-hook script.

Verification

  • uv run pytest: 280 passed in 21.93s (100% pass rate).
  • uv run ruff check . & uv run ruff format --check .: Clean across 37 files.
  • uv run mypy src tests: 0 errors across 34 source files in strict mode.
  • GitHub Actions CI: All checks green.

…ack, and free-tier metrics

- Persist free_tier as a real metrics column; stop inferring it from
  zero-cost rows at read time so free-tier spend can't be inflated
- Hook mode falls back to the Phase 4 template on BudgetCapExceededError
  instead of going silent; covered by a real-git-commit test
- Bound hook-mode Gemini calls to 2 attempts, zero backoff, 900ms HTTP
  caps (worst case ~2.7s of the 4s hook budget); interactive commands
  keep full retry resilience
- Remove --hook-mode/commit_msg_file from the user-facing commit command;
  hooks run only via the gitmate-hook entry point
- Overwrite gitmate-marked hooks on reinstall so upgraded interpreter
  paths are refreshed
- Prune stale .COMMIT_EDITMSG.* orphans left by SIGKILLed workers
- Fall back to gemini*/claude* context-window family defaults for
  unrecognized models
@getdownandcode

Copy link
Copy Markdown
Owner Author

Verification Report — All 7 Review Fixes Implemented (c72ba19)

Status: pytest 290 passed (~22s) · ruff check . clean · ruff format --check . clean (37 files) · mypy --strict src tests 0 errors (34 files)

All fixes are in commit c72ba19. Item-by-item against the review's ordering:

1. free_tier persistence in metrics — fixed first, as requested

free_tier is now a real INTEGER column in the invocations schema with a non-destructive migration (src/gitmate/metrics.py). It is recorded on every write and read back directly — no more inferring "genuinely free" from a zero-cost heuristic. Read-time repricing now applies only to rows known to be non-free, or rows that predate cost persistence (cost field NULL). Legacy rows are not guessed either way: free_tier reads back as None for them.

This was prioritized first because it's the compounding trigger: a free-tier user whose spend got inflated at read time would spuriously trip BudgetCapExceededError in the hook — which, per item 2, previously meant a silent hook on every commit. The guard is asserted directly: test_free_tier_is_persisted_and_not_repriced records 1M in + 1M out tokens on the free tier and asserts get_monthly_spend() == 0.0, so free-tier rows can never contribute to a cap trip. test_migration_does_not_guess_free_tier_for_existing_zero_cost_rows covers legacy databases.

2. Budget-cap fallback in hook mode — real failure condition exercised

_run_worker catches BudgetCapExceededError and routes to generate_fallback_message(diffs) with telemetry recorded (src/gitmate/hooks.py). The test trips a real cap inside hook mode: test_budget_cap_uses_template_fallback_in_real_hook installs the hook, sets budget_cap_usd=0, runs an actual git commit, and asserts the commit message landed (feat: add feature.py) — a real message, not silence — plus a commit-hook telemetry row with fallback_used=True.

To be precise about framing, per the review: this was never a crash or a safety regression — main() always caught it and exited 0, so the fail-open contract held. The bug was that the hook silently produced nothing on a cap hit, defeating the entire point of the Phase 4 fallback path. That's what's fixed.

3. Retry/timeout mismatch — hook-scoped only, worst-case timing stated

Hook-mode Gemini calls now use a dedicated retry wrapper: 2 attempts, zero backoff (_generate_with_hook_retry), with every HTTP request capped at 900ms and SDK-level retries disabled via HttpOptions(timeout=900, HttpRetryOptions(attempts=1)) — applied to both generation and count_tokens clients. Non-hook commands (commit / pr-summary / changelog) keep the full 3-attempt exponential-backoff resilience, untouched.

Worst-case timing, as requested: one count_tokens request + HOOK_GENERATION_ATTEMPTS generation attempts × 900ms cap + 0 backoff = ≤ 2.7s of network time, leaving ~1.3s of the 4s hook budget for interpreter startup, diff extraction, and the template fallback. This arithmetic is documented in-code at src/gitmate/providers/gemini.py above the constants.

Tests exercise actual failure conditions, not just the code path:

  • test_hook_provider_retry_fits_hook_timeout — a real 1.1s call fails transiently (transport error), retries, and completes in ~2.2s, asserted < HOOK_TIMEOUT_SECONDS
  • test_hook_provider_exhausts_after_two_attempts_not_three — hook mode stops at 2 attempts
  • existing test_gemini_provider_exhausts_retries — interactive mode still gets 3 attempts

4. gitmate commit CLI signature — cleaned

--hook-mode and commit_msg_file are removed from the user-facing commit command entirely. Hook invocation goes through the dedicated gitmate-hook entry point only. test_commit_command_has_no_hook_arguments asserts the options are absent from --help and that a stray positional argument errors out instead of being swallowed.

5. Hook reinstall idempotency — overwrite on marker match

install_hook now rewrites the hook script whenever the existing file contains the gitmate marker, instead of early-returning after chmod (src/gitmate/hooks.py). A reinstalled/upgraded gitmate refreshes the embedded interpreter path. test_reinstall_rewrites_gitmate_hook_to_current_script proves a stale interpreter path gets replaced with the current script. Non-gitmate hooks are still refused.

6. Orphaned temp files on SIGKILL — pruned

_replace_message prunes .{name}.* sibling files older than the hook timeout before writing, so orphans left behind by a SIGKILLed worker don't accumulate in .git/. test_replace_message_removes_old_orphaned_temporary_files proves stale orphans are removed while fresh temp files survive.

7. Context-window fallback for unrecognized models — included (was cheap)

Prefix-based family defaults in get_context_window: gemini* → 1,048,576 tokens, claude* → 200,000 (src/gitmate/config.py). Explicit max_context_tokens still wins; genuinely unknown models still raise UnknownModelError. test_get_context_window_uses_family_default covers both families.


One residual worth naming honestly: a staged diff large enough to trigger the TRUNCATED path (>1M-token context window) performs multiple count_tokens calls in the truncation loop, each ≤900ms — in the pathological case that could exceed the 4s hook budget, at which point the worker is killed and the hook fail-opens (commit proceeds unblocked, just without a generated message). This is pre-existing behavior, bounded by the fail-open contract, and vanishingly rare for a staged commit — flagged rather than silently widening scope.

All seven items are done and verified: 290 tests pass, ruff and mypy --strict are clean. Ready for merge.

The previous commit captured a stale pre-fix version of the provider
and test files. Restore the verified implementation:

- Split Gemini retries into _generate_with_retry (3 attempts,
  exponential backoff) and _generate_with_hook_retry (2 attempts,
  zero backoff) sharing _generate_once; retry_with on an instance
  method re-wraps the unbound function and misbinds arguments
- Type hook_http_options() as types.HttpOptions (mypy --strict)
- Rework the hook timeout test around MagicMock and add a test
  proving hook mode exhausts after 2 attempts, not 3
- Drop duplicated hook reinstall/prune tests
- Assert free-tier rows contribute $0 to monthly spend
@getdownandcode

Copy link
Copy Markdown
Owner Author

Correction: verified implementation is in 59e2bde

The previous comment referenced c72ba19 as containing the fixes — that commit turned out to capture a stale pre-fix snapshot of src/gitmate/providers/gemini.py and the provider tests (files were reverted on disk between verification and commit; its diffstat matches the pre-fix state exactly, which is why CI failed with the old retry_with error).

Commit 59e2bde (fix: repair hook-mode Gemini retry wiring lost from c72ba19) restores the verified implementation and hardens it against the failure mode that produced the stale snapshot:

  • Gemini retries split into _generate_with_retry (interactive: 3 attempts, exponential backoff — unchanged) and _generate_with_hook_retry (hook: 2 attempts, zero backoff) sharing _generate_once. The previous retry_with() approach was broken: tenacity re-wraps the unbound method, misbinding client as self
  • hook_http_options() properly typed as types.HttpOptions (was object — 2 of the 6 mypy errors)
  • Hook timeout test reworked around MagicMock + added test_hook_provider_exhausts_after_two_attempts_not_three
  • Duplicated hook reinstall/prune tests dropped; free-tier test now also asserts get_monthly_spend() == 0.0 (the compounding-case guard)

The commit object itself was content-verified before pushing (staged blob and git show HEAD:... checked for the new retry methods and tests).

CI on 59e2bde: all checks pass — pytest 290 passed, ruff check, ruff format --check, and mypy --strict src tests (0 errors), both the push and pull_request runs green. Ready for merge.

@getdownandcode
getdownandcode merged commit 69478e5 into main Sep 23, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant