Skip to content
Open
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
66 changes: 66 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
name: release

on:
push:
tags: ["v*"]
workflow_dispatch:
inputs:
registry:
description: "Target registry for this manual run"
type: choice
options: [testpypi, pypi]
default: testpypi

jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v5
- run: uv sync --extra dev --frozen
- run: uv run pytest -q
- run: uv build
# Validate metadata before anything is uploaded; twine runs ephemerally.
- run: uvx twine check dist/*
- uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
if-no-files-found: error

publish:
needs: build
runs-on: ubuntu-latest
# OIDC for PyPI trusted publishing, scoped to this job only: the build
# job (which runs third-party build-backend code) cannot mint credentials.
permissions:
id-token: write
steps:
- uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- uses: astral-sh/setup-uv@v5
- name: Publish (manual runs default to TestPyPI; tag pushes go to PyPI)
run: |
if [[ "${{ github.event_name }}" == "workflow_dispatch" && "${{ inputs.registry }}" == "testpypi" ]]; then
uv publish --publish-url https://test.pypi.org/legacy/
else
uv publish
fi

github-release:
if: startsWith(github.ref, 'refs/tags/v')
needs: publish
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- run: gh release create "$GITHUB_REF_NAME" dist/* --title "$GITHUB_REF_NAME" --generate-notes
env:
GH_TOKEN: ${{ github.token }}
61 changes: 61 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# Changelog

All notable changes to gitmate are documented here. The format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); the project follows
[Semantic Versioning](https://semver.org/).

## [0.1.0] - 2026-09-25

First public release. AI-assisted commit messages, PR summaries, and
changelogs with cost control, caching, graceful degradation, and a human
always in the loop.

### Added

- **Commit messages** — `gitmate commit` generates a message from the staged
diff, then asks before anything is committed: accept, edit, regenerate, or
cancel. Non-interactive `--yes` is opt-in only.
- **PR summaries** — `gitmate pr-summary --base main` diffs a branch against
its base and generates a ready-to-paste description (copied to clipboard
where supported).
- **Changelogs** — `gitmate changelog --from <ref> --to <ref>` groups commits
by Conventional Commits type into a release-notes section.
- **Diff extraction** — `gitmate debug-diff` prints a cleaned, filtered diff
(staged, branch, or tag range) with rename detection, binary handling, and
gitignore-style noise filtering; ignore patterns are user-configurable.
- **Token budgeting** — diffs over the model's context window are truncated
largest-first or chunked and summarized; budgets resolve per model
(Gemini ~1M, Claude ~200k) with user overrides.
- **Provider layer** — Gemini via `google-genai` (default
`gemini-3.5-flash-lite`), behind a swappable `LLMProvider` protocol with
retries on transient errors and a typed `ProviderUnavailable`.
- **Response caching** — `diskcache` keyed on
`sha256(template_version + model + prompt)`; repeat diffs cost nothing.
- **Graceful degradation** — when the API is offline, rate-limited, or
keyless, deterministic template-based messages are generated locally with a
visible warning.
- **Cost telemetry** — every invocation logs latency, tokens, cache hits, and
estimated spend to a local SQLite database; `gitmate stats` reports it, with
free-tier accounting.
- **Git hooks** — `gitmate install-hook` adds a crash-safe
`prepare-commit-msg` hook (4-second budget, atomic writes, fail-open);
a `.pre-commit-hooks.yaml` entry is included for pre-commit framework users.
- **Secrets in the OS keychain** — API keys are stored via `keyring`, never
in config files.

### Fixed

- Hardened git-diff parsing against filenames with spaces, quotes, backslashes,
and non-ASCII characters, plus ambient git config that breaks naive parsers
(`diff.mnemonicPrefix`, `core.quotepath`, `diff.renames`).
- Token budgets reject non-positive or negative inputs instead of silently
over-budgeting.
- Hook-mode retries capped to stay inside the hook timeout; SDK-level retries
disabled for hook calls.
- Re-running `install-hook` upgrades a gitmate-managed hook in place; foreign
hooks are never overwritten.

### Security

- API keys live only in the OS credential store and are never written to disk
or logs.
230 changes: 159 additions & 71 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,71 +3,96 @@
AI-assisted git commit messages, PR summaries, and changelogs — with caching,
cost control, and a human always in the loop before anything is committed.

## Install (development)
[![CI](https://github.com/getdownandcode/gitmate/actions/workflows/ci.yml/badge.svg)](https://github.com/getdownandcode/gitmate/actions/workflows/ci.yml)
![Python](https://img.shields.io/badge/python-3.11%2B-blue)
[![License](https://img.shields.io/badge/license-Apache--2.0-blue)](LICENSE)

Requires [uv](https://docs.astral.sh/uv/). No manual install step is needed;
`uv run` syncs the environment automatically:
![gitmate demo](docs/demo.gif)

## Why gitmate

- **A human reviews everything.** `gitmate commit` shows the message and asks
before anything is committed — accept, edit, regenerate, or cancel.
Non-interactive mode exists but is strictly opt-in.
- **It still works when the API doesn't.** Offline, rate-limited, or keyless?
gitmate degrades to deterministic, template-based messages with a visible
warning — never a stack trace in your face, never a silent quality drop.
- **Costs are tracked, not guessed.** Responses are cached by content hash,
diffs over the model's context window are truncated or chunked, and every
invocation logs tokens, latency, and estimated spend to a local SQLite
database you can inspect with `gitmate stats`.

## Install

```bash
git clone https://github.com/getdownandcode/gitmate.git
cd gitmate
uv run gitmate --help
# uv (recommended)
uv tool install gitmate-cli

# pipx
pipx install gitmate-cli

# plain pip
pip install gitmate-cli
```

For the full dev toolchain (pytest, ruff, mypy):
The command is `gitmate` (the distribution name is `gitmate-cli`).
Requires Python 3.11+.

Then store your Gemini API key — it goes into your OS keychain, never a file:

```bash
uv sync --extra dev
uv run pytest
gitmate config set-key
```

## Usage
## Quickstart

```bash
uv run gitmate --help
uv run gitmate config set-key # stored in the OS credential store, never in a file
uv run gitmate config show
uv run gitmate config set model gemini-3.5-flash-lite
uv run gitmate config set commit_style conventional # or 'plain'
uv run gitmate config set ignore_globs '*.gen.py,*.tmp' # comma-separated, empty clears
uv run gitmate config set max_context_tokens 8000 # override model context limit
uv run gitmate config set reserved_output_tokens 1500 # reserve headroom for model reply
uv run gitmate config set template_overhead 600 # reserve headroom for prompt template
uv run gitmate config set cache_dir /path/to/cache # custom prompt cache directory
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
git add -p # stage something
gitmate commit # generate → review → accept
```

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.
`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`:
That's the whole loop. The generated message appears in your terminal with
`[a]ccept / [e]dit / [r]egenerate / [c]ancel` — you choose what lands.

## Commands

```bash
gitmate commit # message for the staged diff (interactive)
gitmate pr-summary --base main # PR description vs base branch, to clipboard
gitmate changelog --from v1.0.0 --to v1.1.0 # grouped release notes
gitmate stats # usage, cache hits, spend, latency
gitmate debug-diff # cleaned/filtered staged diff (debugging aid)
```

Configuration lives in `~/.config/gitmate/config.toml` (auto-created):

```bash
gitmate config show # current settings (never the key)
gitmate config set model gemini-3.5-flash-lite
gitmate config set commit_style conventional # or 'plain'
gitmate config set ignore_globs '*.gen.py,*.tmp'
gitmate config set max_context_tokens 8000 # override the model's window
gitmate config set cache_dir /path/to/cache # prompt-cache location
gitmate config set free_tier true # zero-cost accounting
```

### Automatic commit messages via git hooks

For a personal checkout:

```bash
gitmate install-hook
```

This installs a `prepare-commit-msg` hook: Git opens your editor with the
generated message already in it, so you review inside your normal
`git commit` flow. The hook is crash-safe — four-second generation budget,
atomic message writes, and it never blocks a commit: on failure or timeout
it leaves Git's own message untouched and exits 0. Re-running `install-hook`
upgrades a gitmate-managed hook in place; foreign hooks are never overwritten.
`gitmate uninstall-hook` removes it.

For teams on the [pre-commit](https://pre-commit.com) framework:

```yaml
repos:
Expand All @@ -77,22 +102,85 @@ repos:
- 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

Editable draw.io sources plus publishable PNG exports (open the `.drawio`
files in the draw.io desktop app to edit):

| Diagram | Source | Export |
|---|---|---|
| System architecture (Phases 0–8, gemini-3.5-flash-lite) | [`docs/architecture.drawio`](docs/architecture.drawio) | [`docs/architecture.png`](docs/architecture.png) |
| Class diagram (Phases 0–4: config, diffs, budget, providers, templates & fallback) | [`docs/class-diagram.drawio`](docs/class-diagram.drawio) | [`docs/class-diagram.png`](docs/class-diagram.png) |
| Use cases (implemented + planned) | [`docs/use-case.drawio`](docs/use-case.drawio) | [`docs/use-case.png`](docs/use-case.png) |
| `config` command sequences | [`docs/sequence.drawio`](docs/sequence.drawio) | [`docs/sequence.png`](docs/sequence.png) |
## Architecture

![System architecture](docs/architecture.png)

Full-resolution diagram and editable sources (draw.io): see
[`docs/`](docs/) — architecture, class, use-case, and sequence diagrams.

## Real metrics (from `gitmate stats`)

These are gitmate's own numbers from this project's development period
(~1 week of real usage while building Phases 0–8; ~61% of runs hit the
template fallback because the keychain often had no API key during automated
runs — shown exactly as recorded, not cleaned up):

| Command | Runs | Cache hit rate | Tokens | Est. spend | Avg latency |
|---|---:|---:|---:|---:|---:|
| `changelog` | 94 | 0.0% | 7,750 | $0.0057 | 1 ms |
| `commit` | 64 | 0.0% | 0 | $0.0000 | 2 ms |
| `commit-hook` | 5 | 0.0% | 0 | $0.0000 | 0 ms |
| `pr_summary` | 114 | 0.0% | 3,520 | $0.0030 | 0 ms |

**Total: 277 invocations, 11,270 tokens, $0.0087 estimated spend.**
Two weeks of daily-use data will replace this table at the next release.

## The caching tradeoff

gitmate uses **hash-based caching** (`sha256(template_version + model +
prompt)` → response) rather than semantic caching. Semantic caching needs a
vector store, an embedding-model call on every lookup, and a similarity
threshold you'll forever be tuning. Hash caching is O(1), free, and correct;
its only cost is a ~0% hit rate on a diff that changed by one line — an
acceptable, explainable tradeoff for v1. Because the key includes a
`template_version`, changing a prompt template deliberately invalidates old
entries instead of mixing generations. Semantic caching gets revisited only
if the metrics above ever show the hit rate matters.

## Design decisions

**Graceful degradation comes first.** The most interesting engineering in a
CLI that calls an LLM is not the happy path — it's every other path. gitmate
treats API failure as a normal input: typed `ProviderUnavailable` after
bounded retries, a deterministic template fallback that derives a usable
message from diff metadata (no LLM call at all), and a visible warning so
nobody mistakes a degraded message for a generated one.

**A human is in the loop by default, not as a feature.** Nothing auto-commits
without explicit confirmation. The `--yes` escape hatch is opt-in precisely
because the easiest way to break trust with a tool that writes to your
repository is to make one decision the user didn't approve.

**`subprocess` over GitPython.** Exact control over diff flags (`--staged`,
three-dot ranges, `-M`, `-z`) with zero abstraction leakage — and the parser
is hardened against the real-world mess git emits (spaces, quotes, backslashes,
non-ASCII filenames, and ambient config like `diff.mnemonicPrefix` that
silently breaks naive parsers).

**One provider protocol, proven once.** `LLMProvider` is a three-line
protocol; Gemini (`google-genai`) is the default, Anthropic slots in behind
the same boundary. The abstraction earned its keep by staying small.

**Plans lose to data.** Phase 2 originally truncated "low-signal" files
(test snapshots first) per the roadmap. In practice that actively harmed
test-focused commits — where the tests *are* the story. It was reverted to
size-only ordering, deliberately, with the reasoning recorded in the code.

**Tests run against real git.** The suite never mocks the git binary —
scratch repositories in `tmp_path` with real commits. Only the `LLMProvider`
boundary is faked, because that's where our code ends and a vendor's begins.
297 tests, `mypy --strict` clean, `ruff` clean.

## Development

```bash
git clone https://github.com/getdownandcode/gitmate.git
cd gitmate
uv sync --extra dev
uv run pytest
```

## License

Apache-2.0 — see [LICENSE](LICENSE).
Loading
Loading