Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .pre-commit-hooks.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
- id: gitmate-prepare-commit-msg
name: Generate a commit message with gitmate
description: Safely generate a commit message before the editor opens.
entry: gitmate-hook
language: python
stages: [prepare-commit-msg]
pass_filenames: true
44 changes: 41 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ uv sync --extra dev
uv run pytest
```

## Usage (Phases 0–4)
## Usage

```bash
uv run gitmate --help
Expand All @@ -37,15 +37,53 @@ uv run gitmate config set cache_dir /path/to/cache # custom prompt cache d
uv run gitmate debug-diff # staged diff: per-file table + cleaned patch
uv run gitmate debug-diff --summary # per-file stats table only, no patch text
uv run gitmate debug-diff --base main # branch-vs-main diff (PR path)
uv run gitmate commit # review, edit, accept, or cancel a message
uv run gitmate pr-summary --base main
uv run gitmate changelog --from v1.0.0 --to v1.1.0
uv run gitmate stats
uv run gitmate install-hook # optional local prepare-commit-msg hook
uv run gitmate uninstall-hook # remove the local hook
```

Phase 4 introduces versioned prompt templates (`src/gitmate/templates/`: `commit_conventional.txt`,
`commit_plain.txt`, `pr_summary.txt`) and deterministic template-based fallback degradation
(`fallback.py`). When the LLM backend is offline, rate-limited, unauthenticated, or fails retries,
`generate_commit_message` warns the user (`⚠ API unavailable, using template fallback`) and
derives a clean commit message directly from diff metadata without making an LLM call.
`commit`, `pr-summary`, `changelog`, and `doc` remain stubbed until their phases
land — see `AGENTS.md` and the phased roadmap for details.
`doc` is an unscheduled stub; commit, PR summary, changelog, stats, and hook
commands are implemented.

### Automatic commit message generation

For a personal checkout, run `gitmate install-hook`. It installs a local
`prepare-commit-msg` hook; Git opens the usual editor with the generated message
so you can review or change it before saving. The hook never runs `git commit`
and has a four-second generation timeout. If generation fails or times out, it
leaves Git's existing message in place and exits successfully so the commit is
not blocked. It only generates when the source is `template` or absent; explicit
`git commit -m`/`-F` messages (`message`), merge messages (`merge`), squash
messages (`squash`), and other source values are left alone. Existing hooks are
refused rather than overwritten; `gitmate uninstall-hook` removes only the
gitmate-managed hook. Both commands work with Git's configured `core.hooksPath`.

For teams using the `pre-commit` framework, add this to `.pre-commit-config.yaml`
and run `pre-commit install --hook-type prepare-commit-msg`:

```yaml
repos:
- repo: https://github.com/getdownandcode/gitmate
rev: <gitmate-version>
hooks:
- id: gitmate-prepare-commit-msg
```

The repository's `.pre-commit-hooks.yaml` declares the `prepare-commit-msg`
stage and `language: python`, so pre-commit installs gitmate into its isolated
environment for each adopting checkout. `keyring` still uses the user's OS
credential store from that environment; the API key is not copied into the
repository or the pre-commit environment. The `gitmate install-hook` route is
local-only because `.git/hooks` is not version controlled; the pre-commit route
lets a team version the hook declaration for everyone who opts in.

## Diagrams

Expand Down
9 changes: 6 additions & 3 deletions plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,10 +142,13 @@ the loop. Goal: something you actually run every day, not just a portfolio repo.

**Goal:** stop having to remember to run the tool at all.

- Ship a `prepare-commit-msg` git hook script that calls `gitmate commit --hook-mode` (non-interactive generate, writes to the commit message file git already opens in your editor — so you review inside your normal `git commit` flow, no extra step).
- Also provide a `.pre-commit-hooks.yaml` so people using the `pre-commit` framework can add it with one config line.
- Ship an optional local `prepare-commit-msg` hook. It calls only the generation pipeline, writes the message for Git's normal editor, and never runs `git commit` or the interactive accept/edit/regenerate loop.
- Fail open: the launcher catches failures and exits 0; the generation worker has a four-second timeout and uses Phase 4 fallback when available. Message writes are atomic so a failed write leaves Git's original message intact.
- Generate only when the source is `template` or absent. Leave `message` (explicit `-m`/`-F`), `merge`, `squash`, and all other source values untouched.
- Refuse to overwrite an existing hook, support idempotent install and managed-hook uninstall, and honor Git's configured hooks path.
- Also provide `.pre-commit-hooks.yaml` with `language: python` and `stages: [prepare-commit-msg]` for team adoption. The local `.git/hooks` installer is for one developer; the versioned pre-commit declaration is for teams. Its isolated environment still uses the user's OS credential store through keyring.

**Deliverables:** `gitmate install-hook` command that symlinks/writes the hook into `.git/hooks/`, documented `pre-commit` config snippet.
**Deliverables:** `gitmate install-hook` and `gitmate uninstall-hook`, crash/timeout safe real-repository tests, and a documented pre-commit config snippet.

**Tech:** `pre-commit` framework config format, plain shell/Python hook script.

Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ dev = [

[project.scripts]
gitmate = "gitmate.cli:app"
gitmate-hook = "gitmate.hooks:main"

[tool.ruff]
line-length = 100
Expand Down
28 changes: 28 additions & 0 deletions src/gitmate/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,34 @@ def commit(
raise typer.Exit(code)


@app.command("install-hook")
def install_hook() -> None:
"""Install gitmate's local prepare-commit-msg hook in this repository."""
from gitmate.hooks import HookError
from gitmate.hooks import install_hook as install

try:
path = install()
except HookError as exc:
err_console.print(f"[red]error:[/red] {exc}")
raise typer.Exit(1) from None
console.print(f"[green]Installed prepare-commit-msg hook:[/green] {path}")


@app.command("uninstall-hook")
def uninstall_hook() -> None:
"""Remove gitmate's local prepare-commit-msg hook."""
from gitmate.hooks import HookError
from gitmate.hooks import uninstall_hook as uninstall

try:
path = uninstall()
except HookError as exc:
err_console.print(f"[red]error:[/red] {exc}")
raise typer.Exit(1) from None
console.print(f"[green]Removed gitmate prepare-commit-msg hook:[/green] {path}")


@app.command()
def stats(
days: int | None = typer.Option(None, "--days", "-d", help="Limit stats to the last N days."),
Expand Down
5 changes: 5 additions & 0 deletions src/gitmate/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,11 @@ def get_context_window(cfg: GitmateConfig) -> int:
return cfg.max_context_tokens
if cfg.model in DEFAULT_MODEL_CONTEXT_WINDOWS:
return DEFAULT_MODEL_CONTEXT_WINDOWS[cfg.model]
model_name = cfg.model.lower()
if model_name.startswith("gemini"):
return 1_048_576
if model_name.startswith("claude"):
return 200_000
raise UnknownModelError(
f"unknown model {cfg.model!r} with no context window configured. "
"Set 'max_context_tokens' in config.toml or use a known model."
Expand Down
4 changes: 4 additions & 0 deletions src/gitmate/fallback.py
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,8 @@ def generate_commit_message(
console: Console | None = None,
secret_store: config_mod.SecretStore | None = None,
bypass_cache: bool = False,
command: str | None = None,
hook_mode: bool = False,
) -> GenerationResult:
"""Orchestrate commit message generation with graceful fallback on provider failure."""
return run_generation_pipeline(
Expand All @@ -270,6 +272,8 @@ def generate_commit_message(
template_name=cfg.commit_style,
fallback_generator=lambda: generate_fallback_message(diffs, style=cfg.commit_style),
empty_diff_message="No changes staged for commit.",
command=command,
hook_mode=hook_mode,
provider=provider,
counter=counter,
console=console,
Expand Down
201 changes: 201 additions & 0 deletions src/gitmate/hooks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
"""Crash-safe prepare-commit-msg hook installation and execution."""

from __future__ import annotations

import os
import stat
import subprocess
import sys
import tempfile
import time
from pathlib import Path

HOOK_TIMEOUT_SECONDS = 4
HOOK_MARKER = "# Installed by gitmate; managed by `gitmate install-hook`."

HOOK_SCRIPT = f"""#!/bin/sh
{HOOK_MARKER}
# Crash-safe launcher for gitmate's prepare-commit-msg worker.
PYTHON="{sys.executable}"
if [ ! -x "$PYTHON" ]; then
if command -v python3 >/dev/null 2>&1; then
PYTHON="python3"
else
exit 0
fi
fi
exec "$PYTHON" -c '
import subprocess, sys
try:
subprocess.run(
[sys.executable, "-m", "gitmate.hooks", "--worker", *sys.argv[1:]],
timeout={HOOK_TIMEOUT_SECONDS},
check=False,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
except BaseException:
pass
sys.exit(0)
' "$@"
"""


class HookError(Exception):
"""Raised when a hook cannot be safely installed or removed."""


def _hook_path(cwd: Path | None = None) -> Path:
"""Resolve the repository's active prepare-commit-msg hook path."""
repo = cwd if cwd is not None else Path.cwd()
try:
result = subprocess.run(
["git", "rev-parse", "--git-path", "hooks/prepare-commit-msg"],
cwd=repo,
capture_output=True,
text=True,
timeout=2,
check=True,
)
except (OSError, subprocess.SubprocessError) as exc:
raise HookError(f"cannot locate the prepare-commit-msg hook: {exc}") from exc
path = Path(result.stdout.strip())
return path if path.is_absolute() else (repo / path).resolve()


def install_hook(cwd: Path | None = None) -> Path:
"""Install the crash-safe hook, refusing to replace user-owned hooks."""
path = _hook_path(cwd)
path.parent.mkdir(parents=True, exist_ok=True)
if path.exists():
content = path.read_text(encoding="utf-8", errors="replace")
if HOOK_MARKER not in content:
raise HookError(f"{path} already exists; move or remove it before installing gitmate.")
path.write_text(HOOK_SCRIPT, encoding="utf-8")
path.chmod(path.stat().st_mode | 0o111)
return path


def uninstall_hook(cwd: Path | None = None) -> Path:
"""Remove the gitmate hook while preserving hooks owned by other tools."""
path = _hook_path(cwd)
if not path.exists():
return path
content = path.read_text(encoding="utf-8", errors="replace")
if HOOK_MARKER not in content:
raise HookError(f"{path} is not a gitmate-managed hook; it was left untouched.")
path.unlink()
return path


def main() -> int:
"""Run the hook worker with a hard timeout and unconditional success status."""
args = sys.argv[1:]
if args and args[0] == "--worker":
try:
_run_worker(args[1:])
except BaseException: # noqa: BLE001, S110
pass
return 0

if args:
source = os.environ.get("PRE_COMMIT_COMMIT_MSG_SOURCE")
commit_hash = os.environ.get("PRE_COMMIT_COMMIT_OBJECT_NAME")
if len(args) == 1 and source:
args.append(source)
if len(args) == 2 and commit_hash:
args.append(commit_hash)

try:
subprocess.run(
[sys.executable, "-m", "gitmate.hooks", "--worker", *args],
timeout=HOOK_TIMEOUT_SECONDS,
check=False,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
except BaseException: # noqa: BLE001, S110
pass
return 0


def _run_worker(args: list[str]) -> None:
"""Generate a message for supported Git hook sources without committing."""
if not args or len(args) > 3:
return
message_file = Path(args[0])
source = args[1] if len(args) > 1 else ""
if source not in ("", "template") or not message_file.is_file():
return

from gitmate import config as config_mod
from gitmate.diff_extractor import DiffExtractor
from gitmate.fallback import generate_commit_message, generate_fallback_message
from gitmate.orchestrator import BudgetCapExceededError, safe_record_telemetry

cfg = config_mod.load_config()
diffs = DiffExtractor(extra_ignores=cfg.ignore_globs).staged()
if not diffs:
return
started = time.perf_counter()
try:
result = generate_commit_message(
diffs=diffs,
cfg=cfg,
console=None,
command="commit-hook",
hook_mode=True,
)
generated = result.text.rstrip()
except BudgetCapExceededError:
generated = generate_fallback_message(diffs, style=cfg.commit_style)
safe_record_telemetry(
command="commit-hook",
model=cfg.model,
tokens_in=None,
tokens_out=None,
cache_hit=False,
latency_ms=int((time.perf_counter() - started) * 1000),
fallback_used=True,
estimated_cost_usd=0.0,
free_tier=cfg.free_tier,
)
existing = message_file.read_text(encoding="utf-8", errors="replace")
if existing.strip():
new_message = f"{generated}\n\n{existing.lstrip()}"
else:
new_message = f"{generated}\n"
_replace_message(message_file, new_message)


def _replace_message(message_file: Path, message: str) -> None:
"""Atomically replace Git's message file so write failures preserve its contents."""
cutoff = time.time() - HOOK_TIMEOUT_SECONDS
for stale in message_file.parent.glob(f".{message_file.name}.*"):
try:
if stale.is_file() and stale.stat().st_mtime < cutoff:
stale.unlink()
except OSError:
continue

mode = stat.S_IMODE(message_file.stat().st_mode)
temp_path: Path | None = None
try:
with tempfile.NamedTemporaryFile(
mode="w",
encoding="utf-8",
dir=message_file.parent,
prefix=f".{message_file.name}.",
delete=False,
) as temp_file:
temp_path = Path(temp_file.name)
temp_file.write(message)
temp_path.chmod(mode)
os.replace(temp_path, message_file)
finally:
if temp_path is not None and temp_path.exists():
temp_path.unlink()


if __name__ == "__main__":
raise SystemExit(main())
Loading
Loading