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
3 changes: 0 additions & 3 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,4 @@ Thumbs.db
# Agent tooling droppings (not part of this project)
.agents/

#md files
plan.md

gitmate.architecture.json
177 changes: 177 additions & 0 deletions plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
# gitmate — AI Git Assistant CLI: Build Plan

A CLI that generates commit messages, PR summaries, and changelogs from your
git diffs using an LLM — with cost control, caching, and a human always in
the loop. Goal: something you actually run every day, not just a portfolio repo.

---

## Design decisions locked in up front

| Decision | Choice | Why |
|---|---|---|
| Caching strategy | **Hash-based** (`sha256(diff)` → response) over semantic caching | Semantic caching (embedding similarity) 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. That's an acceptable, explainable tradeoff for v1. Revisit semantic caching only if cache-hit-rate data (Phase 6) shows it's worth the complexity. |
| Git access | **`subprocess` calls to the `git` binary** over GitPython | You need exact control over diff flags (`--staged`, `-U0`, path filters) and want zero abstraction leakage. GitPython is a nice-to-have wrapper you don't need — it's another dependency and another place for version mismatches to bite you. |
| LLM SDK | **Google GenAI SDK (Gemini)** default, behind your own `LLMProvider` interface (Anthropic secondary/deferred) | Official SDK (`google-genai`), targets Gemini 3 Flash / Flash-Lite per AGENTS.md. Handles generation and token counting behind a swappable interface without guessing with third-party tokenizers. |
| Config format | **TOML** (`~/.config/gitmate/config.toml`) | Human-editable, standard for Python CLIs (same as `pyproject.toml`), no extra parsing dependency (`tomllib` is stdlib in 3.11+). |
| Secrets | **`keyring`** library, not plaintext config | Uses the OS credential store (macOS Keychain / Windows Credential Manager / Secret Service on Linux). No API key ever touches a file you could accidentally `git add`. |
| Cache store | **`diskcache`** over raw SQLite | Gives you TTL, LRU eviction, and thread-safety out of the box with a dict-like API — SQLite is fine but you'd be re-implementing what this library already does correctly. |
| CLI framework | **Typer** + **Rich** | Typer for decorator-based commands and auto-generated `--help`; Rich for readable diffs/diffs-with-syntax-highlighting and a nice review prompt in the terminal. |
| Packaging | **`pyproject.toml` + `hatchling`**, installed via **`pipx`** | `pipx` is the standard way people install CLI tools without polluting their global Python env. Publish to PyPI once stable. |
| Automatic trigger | **`pre-commit` framework hook**, optional `prepare-commit-msg` git hook | This is what turns it from "a script I remember to run" into "part of my git workflow." |

---

## Phase 0 — Skeleton & Config (½–1 day)

**Goal:** `gitmate config set-key` works, `gitmate --help` shows all planned commands (stubbed).

- Set up `pyproject.toml`, package layout (`src/gitmate/`), Typer app with stub commands: `commit`, `pr-summary`, `changelog`, `doc`, `config`.
- `config` command: read/write `~/.config/gitmate/config.toml` (model choice, default commit style, budget cap); store API key via `keyring`.
- Basic Rich-styled CLI output (banner, error formatting).

**Deliverables:** installable package (`pipx install -e .`), working `config` command, CI skeleton (GitHub Actions running `pytest` on push).

**Tech:** Typer, Rich, `tomllib`/`tomli-w`, `keyring`, `pytest`, GitHub Actions.

---

## Phase 1 — Diff/Context Extractor (1 day)

**Goal:** `gitmate debug-diff` prints a cleaned, filtered diff for any git range.

- Wrap `subprocess.run(["git", "diff", ...])` for three modes: staged (`--staged`), branch-vs-branch (for PR summaries), and range (for changelogs, `tagA..tagB`).
- Noise filtering: skip lockfiles (`package-lock.json`, `poetry.lock`, `Cargo.lock`), minified/generated files, binary diffs — via a configurable ignore-glob list (defaults + user overrides in config).
- Return a structured object: list of `FileDiff(path, additions, deletions, patch_text, is_binary)`, not a raw string — every downstream layer works off this.

**Deliverables:** `DiffExtractor` class with unit tests against a scratch git repo (use `pytest` fixtures that `git init` a temp dir and make real commits — don't mock git itself, it's cheap to run for real).

**Tech:** `subprocess`, `pytest` with `tmp_path` fixtures.

---

## Phase 2 — Token Budget Manager (½–1 day)

**Goal:** Given a `FileDiff` list, decide: send whole, truncate, or chunk-and-summarize.

- Count tokens via the google-genai SDK's token-counting support (`GeminiTokenCounter`, provider-specific behind a `TokenCounter` Protocol).
- Strategy: if under budget, send as-is. If over, drop full patches for the largest low-signal files first (test snapshots, lockfile-adjacent generated code) and replace with a one-line "N files changed, omitted for size" note. If still over after that, fall back to chunk-and-summarize-then-merge (summarize each file's diff separately, then feed summaries into the final prompt).
- Make the strategy explicit and loggable — the user should be able to run `--verbose` and see "included 8/12 files, truncated 4."

**Deliverables:** `TokenBudgetManager` with a documented strategy function and tests covering: fits easily, needs truncation, needs chunking.

**Tech:** `google-genai` SDK token counting, plain Python (no extra deps needed here).

---

## Phase 3 — Provider Abstraction + Cache Layer (1 day)

**Goal:** `LLMProvider.generate(prompt) -> LLMResponse`, with caching in front of it, swappable backend.

- Define a small `Protocol`/ABC: `LLMProvider.generate(prompt: str, model: str) -> LLMResponse`.
- Implement `GeminiProvider` first, targeting **Gemini 3 Flash / Flash-Lite** via the `google-genai` SDK (`client.models.generate_content`). This is the locked-in default per AGENTS.md; Anthropic is the secondary provider using the same interface and is deferred to the stretch-ideas list until the abstraction is proven once. Model IDs churn fast (2.0 Flash is retired; Flash-Lite previews deprecate within months), so the ID stays configurable in `config.py` rather than hardcoded.
- Cache: wrap the provider call — `diskcache.Cache` keyed on `sha256(prompt + model + template_version)`, with a configurable TTL (default: no expiry, since a given diff's ideal message doesn't go stale — but bump the key if you change your prompt template, hence `template_version` in the key). Template version is a hardcoded `"1"` placeholder until Phase 4 ships real templates, so Phase 3 cache entries don't need a key migration later.
- Cache location follows the project's XDG convention: `~/.cache/gitmate/`, overridable via a `cache_dir` config field.
- Retry with exponential backoff (`tenacity` library) on transient errors (5xx, 429 rate-limit, transport failures); on final failure, raise a typed `ProviderUnavailable` exception (Phase 4 catches this).
- **Named Risk (from Phase 2 review):** `TokenBudgetManager` has a hardcoded `context_window` default (1,048,576) that does not inspect `model`. Add a `TokenBudgetManager.from_config(cfg, counter)` factory that resolves `get_context_window(cfg)`, so non-Gemini models (e.g. Claude's 200k limit) never get silently over-budgeted to 1M.

**Deliverables:** `LLMProvider` interface, `GeminiProvider` implementation, `CachedProvider` wrapper, `from_config` factory, tests using a fake in-memory provider (no real API calls in CI — mock at the `LLMProvider` boundary, not deeper; include a call-count test proving the cache is consulted before `generate()`).

**Tech:** `google-genai` SDK, `diskcache`, `tenacity` for retries. (Anthropic SDK, already a dependency, stays unused until the secondary provider is implemented.)

---

## Phase 4 — Graceful Degradation + Prompt Templates (½ day)

**Goal:** If the API is down/rate-limited/offline, you still get a usable commit message.

- Template-based fallback: derive a message from `git status` + file extensions (e.g. "Update 3 files in src/auth (added: 2, modified: 1)"). No LLM call at all.
- Prompt templates as versioned files (`templates/commit_conventional.txt`, `templates/commit_plain.txt`, `templates/pr_summary.txt`), selected by config. Bump a version string in each when you edit it — this is the `template_version` the cache key depends on.
- Wire this so `LLMProvider` failure (`ProviderUnavailable`) → caught → fallback path, with a visible warning ("⚠ API unavailable, using template fallback") so the user isn't confused about message quality.
- Chunk-and-summarize execution (deferred from Phase 2): when `TokenBudgetManager.assess()` returns `BudgetStrategy.NEEDS_CHUNKING`, consume `omitted_diffs`, summarize chunks via the provider, and merge summaries into the final prompt (with graceful fallback if the provider fails).

**Deliverables:** fallback generator, prompt template engine with at least 2 prompt template styles, chunk-and-summarize orchestrator, tests that simulate provider failure, template rendering, and chunk-and-summarize prompt merging.

**Tech:** plain Python, Jinja2 (optional, if templates get more than string `.format()` can cleanly handle).

---

## Phase 5 — Output & Human-in-the-Loop (½ day)

**Goal:** `gitmate commit` shows the generated message, lets you edit/accept/reject, then actually commits.

- Default: print the message in Rich, prompt `[a]ccept / [e]dit / [r]egenerate / [c]ancel`.
- Edit path: open `$EDITOR` pre-filled with the message (mirrors `git commit -e`).
- Accept path: write to `.git/COMMIT_EDITMSG` and run `git commit -F`.
- Never auto-commit without one of these confirmations, even with `--yes` flags — make `--yes` require an explicit config opt-in, not a default flag.

**Deliverables:** working `gitmate commit` end-to-end against a real repo; integration test using a temp git repo.

**Tech:** Rich `Prompt`, `subprocess` for `$EDITOR` and final `git commit`.

---

## Phase 6 — Metrics & the README table (½ day)

**Goal:** Every run logs latency, tokens used, cache hit/miss, and estimated cost to a local SQLite table — this is what generates your README's metrics table, and it's also what tells you later whether semantic caching would actually help.

- `~/.local/share/gitmate/metrics.db`: one row per invocation (timestamp, command, tokens_in, tokens_out, cache_hit, latency_ms, estimated_cost_usd, fallback_used).
- `gitmate stats` command: prints aggregate table (avg latency, cache hit rate, total estimated spend this month) — this is the exact table you paste into the README.

**Deliverables:** metrics recording wired into Phases 3–5, `gitmate stats` command, a real 2-week's worth of your own usage data before you write the README.

**Tech:** stdlib `sqlite3` (this one really is simple enough not to need `diskcache`'s abstraction — it's structured rows, not a KV cache).

---

## Phase 7 — Beyond commits: PR summaries & changelogs (1–1.5 days)

**Goal:** the tool earns its keep beyond `git commit` — this is what makes it a daily-use tool instead of a one-trick demo.

- `gitmate pr-summary --base main`: diff current branch against `main`, generate a PR description (what changed, why, testing notes) using the `pr_summary` template. Copies to clipboard (`pyperclip`) and/or opens the `gh pr create` flow if the `gh` CLI is detected.
- `gitmate changelog --from v1.0.0 --to v1.1.0`: walks commit messages + diffs between two tags/refs, groups by conventional-commit type (feat/fix/chore), generates a `CHANGELOG.md` section.
- Both reuse the same Diff Extractor / Token Budget Manager / Provider stack — this is the payoff of building those as real layers instead of one script.

**Deliverables:** two new working commands, tests, and (this is the actually-useful part) you running `pr-summary` on your next 3 real PRs instead of writing them by hand.

**Tech:** `pyperclip`, optional `gh` CLI shell-out detection.

---

## Phase 8 — Automatic trigger: git hook integration (½ day)

**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.

**Deliverables:** `gitmate install-hook` command that symlinks/writes the hook into `.git/hooks/`, documented `pre-commit` config snippet.

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

---

## Phase 9 — Packaging, docs, and the story (½–1 day)

**Goal:** ship it.

- Finalize `pyproject.toml`, publish to PyPI (`pip install gitmate` or your chosen name — check it's not taken).
- README: architecture diagram (your existing one, updated with Config/Metrics/Hook boxes), the real metrics table from Phase 6, a demo GIF (`vhs` or `asciinema` + `agg` are free tools for scripted terminal GIFs), and the caching-tradeoff paragraph already decided above.
- Write the "interview story": lead with the graceful-degradation and human-in-the-loop decisions — those are the two choices that signal product judgment, not just API-calling.

**Deliverables:** PyPI package, finished README, demo GIF.

**Tech:** `hatchling`/`build`/`twine` for publishing, `vhs` (Charm) for demo recording.

---

## Suggested order & time budget

Phases 0→5 are the MVP (~4–5 days) and get you a working `gitmate commit`.
Phase 6 (metrics) should run *underneath* everything from Phase 3 onward even though it's written up last — start logging early so you have real data by the time you write the README.
Phases 7–8 are what make this a tool you keep using after the demo, not just for the interview — don't skip them for "future work" bullet points.

## Stretch ideas (only after the above works and you're using it daily)
- Budget cap: hard-stop or warn when `metrics.db` shows spend exceeding a configured monthly cap.
- Multi-provider: add an `OpenAIProvider` to prove the abstraction actually swaps cleanly.
- Revisit semantic caching *only if* Phase 6 data shows hash-cache hit rate is too low to matter.
77 changes: 77 additions & 0 deletions src/gitmate/chunking.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
"""Chunking abstractions: split oversized diffs and summarize via LLMProvider."""

from __future__ import annotations

from typing import TYPE_CHECKING

if TYPE_CHECKING:
from gitmate.diff_extractor import FileDiff
from gitmate.providers.base import LLMProvider


def chunk_patch_text(patch: str, max_chars: int = 8_000) -> list[str]:
"""Split large patch text into chunks if needed to avoid blowing per-call limits."""
if len(patch) <= max_chars:
return [patch]
lines = patch.splitlines(keepends=True)
chunks: list[str] = []
current: list[str] = []
current_len = 0
for line in lines:
if current_len + len(line) > max_chars and current:
chunks.append("".join(current))
current = [line]
current_len = len(line)
else:
current.append(line)
current_len += len(line)
if current:
chunks.append("".join(current))
return chunks


# Compatibility alias for internal callers
_chunk_patch_text = chunk_patch_text


def summarize_omitted_diffs(
omitted_diffs: list[FileDiff],
provider: LLMProvider,
model: str,
) -> tuple[str, int, int]:
"""Summarize omitted diffs per file/chunk and return merged notes and token usage.

Consumes BudgetDecision.omitted_diffs (which preserves untruncated patches),
prompts the provider for concise per-file summaries, and aggregates token usage.
"""
summaries: list[str] = []
total_in = 0
total_out = 0

for diff in omitted_diffs:
if diff.is_binary or not diff.patch_text.strip():
summaries.append(f"- {diff.path}: [binary or empty file]")
continue

chunks = chunk_patch_text(diff.patch_text)
file_part_summaries: list[str] = []
for i, chunk in enumerate(chunks, start=1):
part_suffix = f" (part {i}/{len(chunks)})" if len(chunks) > 1 else ""
prompt = (
f"Summarize the following git diff for {diff.path}{part_suffix} in 1-2 concise bullet points "
f"(focus on WHAT changed and WHY):\n\n"
f"{chunk}"
)
resp = provider.generate(prompt=prompt, model=model)
if resp.input_tokens is not None:
total_in += resp.input_tokens
if resp.output_tokens is not None:
total_out += resp.output_tokens
text = resp.text.strip()
if text:
file_part_summaries.append(text)

joined_file_summary = "; ".join(file_part_summaries) if file_part_summaries else "Updated"
summaries.append(f"- {diff.path}: {joined_file_summary}")

return "\n".join(summaries), total_in, total_out
6 changes: 6 additions & 0 deletions src/gitmate/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,18 @@
#: Context window limits for known models; users override via `max_context_tokens`.
DEFAULT_MODEL_CONTEXT_WINDOWS: dict[str, int] = {
"gemini-3.5-flash-lite": 1_048_576,
# Standard Flash tier registered for completeness; gemini-3.5-flash-lite remains
# the configured default above.
"gemini-3.5-flash": 1_048_576,
"gemini-3-flash": 1_048_576,
"gemini-flash": 1_048_576,
"claude-3-5-sonnet": 200_000,
"claude-3-5-haiku": 200_000,
}
DEFAULT_RESERVED_OUTPUT_TOKENS = 2048
#: Calibrated in Phase 4 against real templates (commit_conventional: ~329 tokens,
#: commit_plain: ~210 tokens, pr_summary: ~206 tokens); 500 tokens provides a safe
#: upper bound with ~170+ tokens of headroom for dynamic summary notes.
DEFAULT_TEMPLATE_OVERHEAD = 500


Expand Down
Loading
Loading