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
13 changes: 8 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,14 @@ uv sync --extra dev
uv run pytest
```

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

```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
Expand All @@ -38,9 +39,11 @@ 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)
```

Phase 3 introduces the `LLMProvider` abstraction, backed by `GeminiProvider`
(targeting `gemini-3.5-flash-lite` with tenacity exponential backoff retries)
and wrapped by `CachedProvider` (hash-keyed disk caching via `diskcache`).
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.

Expand All @@ -52,6 +55,6 @@ 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–3: config, diff extraction, token budgeting, providers & caching) | [`docs/class-diagram.drawio`](docs/class-diagram.drawio) | [`docs/class-diagram.png`](docs/class-diagram.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) |
64 changes: 59 additions & 5 deletions docs/class-diagram.drawio
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<mxfile host="drawio" version="26.0.0">
<diagram name="class-diagram">
<mxGraphModel dx="1200" dy="900" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1500" pageHeight="1150" math="0" shadow="0">
<mxGraphModel dx="1200" dy="900" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1500" pageHeight="1400" math="0" shadow="0">
<root>
<mxCell id="0"/>
<mxCell id="1" parent="0"/>

<!-- Header -->
<mxCell id="title" value="gitmate — Architecture &amp; Class Diagram (Phases 0–3)" style="text;html=1;align=left;verticalAlign=middle;fontStyle=1;fontSize=22;" vertex="1" parent="1">
<mxCell id="title" value="gitmate — Architecture &amp; Class Diagram (Phases 0–4)" style="text;html=1;align=left;verticalAlign=middle;fontStyle=1;fontSize=22;" vertex="1" parent="1">
<mxGeometry x="40" y="20" width="800" height="34" as="geometry"/>
</mxCell>
<mxCell id="subtitle" value="config.py + diff_extractor.py + token_budget.py + providers/ + cli.py · Strict typing, I/O behind protocols, offline-first verification" style="text;html=1;align=left;verticalAlign=middle;fontSize=12;fontColor=#555555;" vertex="1" parent="1">
<mxCell id="subtitle" value="config.py + diff_extractor.py + token_budget.py + providers/ + prompt.py + fallback.py + cli.py · Strict typing, I/O behind protocols, offline-first verification" style="text;html=1;align=left;verticalAlign=middle;fontSize=12;fontColor=#555555;" vertex="1" parent="1">
<mxGeometry x="40" y="54" width="1100" height="22" as="geometry"/>
</mxCell>

Expand Down Expand Up @@ -68,7 +68,7 @@
<mxCell id="fd_t" value="&lt;b&gt;FileDiff&lt;/b&gt;&lt;br/&gt;«dataclass»" style="rounded=1;whiteSpace=wrap;html=1;align=center;fontSize=13;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1">
<mxGeometry x="360" y="320" width="220" height="35" as="geometry"/>
</mxCell>
<mxCell id="fd_b" value="+ path: str&lt;br/&gt;+ additions: int&lt;br/&gt;+ deletions: int&lt;br/&gt;+ patch_text: str&lt;br/&gt;+ is_binary: bool&lt;br/&gt;+ old_path: str | None = None&lt;br/&gt;+ status: str = &quot;modified&quot;" style="rounded=0;whiteSpace=wrap;html=1;align=left;verticalAlign=top;fontSize=11;spacingLeft=8;spacingTop=6;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1">
<mxCell id="fd_b" value="+ path: str&lt;br/&gt;+ old_path: str | None&lt;br/&gt;+ status: FileStatus&lt;br/&gt;+ additions: int&lt;br/&gt;+ deletions: int&lt;br/&gt;+ patch_text: str&lt;br/&gt;+ is_binary: bool" style="rounded=0;whiteSpace=wrap;html=1;align=left;verticalAlign=top;fontSize=11;spacingLeft=8;spacingTop=6;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1">
<mxGeometry x="360" y="355" width="220" height="120" as="geometry"/>
</mxCell>

Expand Down Expand Up @@ -172,6 +172,49 @@
<mxGeometry x="930" y="850" width="190" height="25" as="geometry"/>
</mxCell>

<!-- Phase 4: Prompt Templates & Fallback Degradation -->
<mxCell id="pt_t" value="&lt;b&gt;PromptTemplate&lt;/b&gt;&lt;br/&gt;«dataclass»" style="rounded=1;whiteSpace=wrap;html=1;align=center;fontSize=13;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1">
<mxGeometry x="50" y="1000" width="260" height="35" as="geometry"/>
</mxCell>
<mxCell id="pt_b" value="+ name: str&lt;br/&gt;+ version: str&lt;br/&gt;+ body: str&lt;br/&gt;+ version_key: str # {name}:v{version}&lt;br/&gt;+ render(**kwargs) -&amp;gt; str" style="rounded=0;whiteSpace=wrap;html=1;align=left;verticalAlign=top;fontSize=11;spacingLeft=8;spacingTop=6;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1">
<mxGeometry x="50" y="1035" width="260" height="85" as="geometry"/>
</mxCell>

<mxCell id="pe_t" value="&lt;b&gt;Prompt Engine (prompt.py)&lt;/b&gt;" style="rounded=1;whiteSpace=wrap;html=1;align=center;fontSize=12;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1">
<mxGeometry x="50" y="1145" width="260" height="30" as="geometry"/>
</mxCell>
<mxCell id="pe_b" value="+ load_template(style) -&amp;gt; PromptTemplate&lt;br/&gt;+ render_commit_prompt() -&amp;gt; (prompt, key)&lt;br/&gt;+ parse_template_content(raw) -&amp;gt; PromptTemplate" style="rounded=0;whiteSpace=wrap;html=1;align=left;verticalAlign=top;fontSize=11;spacingLeft=8;spacingTop=4;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1">
<mxGeometry x="50" y="1175" width="260" height="60" as="geometry"/>
</mxCell>

<mxCell id="fb_t" value="&lt;b&gt;Fallback Generator (fallback.py)&lt;/b&gt;" style="rounded=1;whiteSpace=wrap;html=1;align=center;fontSize=12;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1">
<mxGeometry x="360" y="1000" width="260" height="30" as="geometry"/>
</mxCell>
<mxCell id="fb_b" value="+ generate_fallback_message(diffs, style)&lt;br/&gt;- _single_file_fallback(diff, style)&lt;br/&gt;- _multi_file_fallback(diffs, style)&lt;br/&gt;- _extract_scope(path) -&amp;gt; str | None&lt;br/&gt;- _infer_type(diff) -&amp;gt; str" style="rounded=0;whiteSpace=wrap;html=1;align=left;verticalAlign=top;fontSize=11;spacingLeft=8;spacingTop=4;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1">
<mxGeometry x="360" y="1030" width="260" height="90" as="geometry"/>
</mxCell>

<mxCell id="orch_t" value="&lt;b&gt;Orchestrator (fallback.py)&lt;/b&gt;" style="rounded=1;whiteSpace=wrap;html=1;align=center;fontSize=12;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1">
<mxGeometry x="360" y="1145" width="260" height="30" as="geometry"/>
</mxCell>
<mxCell id="orch_b" value="+ generate_commit_message(&lt;br/&gt; diffs, cfg, provider, counter, console&lt;br/&gt; ) -&amp;gt; GenerationResult&lt;br/&gt; # Catches ProviderUnavailable -&amp;gt; fallback" style="rounded=0;whiteSpace=wrap;html=1;align=left;verticalAlign=top;fontSize=11;spacingLeft=8;spacingTop=4;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1">
<mxGeometry x="360" y="1175" width="260" height="60" as="geometry"/>
</mxCell>

<mxCell id="gr_t" value="&lt;b&gt;GenerationResult&lt;/b&gt;&lt;br/&gt;«dataclass»" style="rounded=1;whiteSpace=wrap;html=1;align=center;fontSize=13;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1">
<mxGeometry x="660" y="1000" width="230" height="35" as="geometry"/>
</mxCell>
<mxCell id="gr_b" value="+ text: str&lt;br/&gt;+ is_fallback: bool&lt;br/&gt;+ model: str&lt;br/&gt;+ fallback_reason: str | None = None&lt;br/&gt;+ input_tokens: int | None = None&lt;br/&gt;+ output_tokens: int | None = None" style="rounded=0;whiteSpace=wrap;html=1;align=left;verticalAlign=top;fontSize=11;spacingLeft=8;spacingTop=6;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1">
<mxGeometry x="660" y="1035" width="230" height="95" as="geometry"/>
</mxCell>

<mxCell id="te_t" value="&lt;b&gt;TemplateError&lt;/b&gt;&lt;br/&gt;«extends Exception»" style="rounded=1;whiteSpace=wrap;html=1;align=center;fontSize=12;fillColor=#f8cecc;strokeColor=#b85450;" vertex="1" parent="1">
<mxGeometry x="930" y="1000" width="200" height="30" as="geometry"/>
</mxCell>
<mxCell id="te_b" value="TemplateNotFoundError&lt;br/&gt;InvalidTemplateError" style="rounded=0;whiteSpace=wrap;html=1;align=center;verticalAlign=middle;fontSize=10;fillColor=#f8cecc;strokeColor=#b85450;" vertex="1" parent="1">
<mxGeometry x="930" y="1030" width="200" height="35" as="geometry"/>
</mxCell>

<!-- CLI Box -->
<mxCell id="cli_t" value="&lt;b&gt;gitmate CLI&lt;/b&gt;&lt;br/&gt;Typer app · commands only, no business logic" style="rounded=1;whiteSpace=wrap;html=1;align=center;fontSize=13;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1">
<mxGeometry x="1100" y="160" width="340" height="40" as="geometry"/>
Expand All @@ -182,7 +225,7 @@

<!-- Legend -->
<mxCell id="legend" value="Legend" style="rounded=0;whiteSpace=wrap;html=1;fillColor=none;strokeColor=#666666;verticalAlign=top;fontStyle=1;fontSize=13;" vertex="1" parent="1">
<mxGeometry x="50" y="1010" width="1060" height="60" as="geometry"/>
<mxGeometry x="50" y="1265" width="1060" height="60" as="geometry"/>
</mxCell>
<mxCell id="leg1" value="" style="rounded=0;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="legend">
<mxGeometry x="20" y="30" width="30" height="16" as="geometry"/>
Expand Down Expand Up @@ -264,6 +307,17 @@
<mxGeometry relative="1" as="geometry"/>
</mxCell>

<!-- Phase 4 Connectors -->
<mxCell id="e_pe_pt" value="loads / parses" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;fontSize=11;endArrow=open;endFill=0;dashed=1;exitX=0.5;exitY=0;exitDx=0;exitDy=0;entryX=0.5;entryY=1;entryDx=0;entryDy=0;" edge="1" parent="1" source="pe_t" target="pt_b">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="e_orch_gr" value="produces" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;fontSize=11;endArrow=open;endFill=0;dashed=1;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0.5;entryY=1;entryDx=0;entryDy=0;" edge="1" parent="1" source="orch_b" target="gr_b">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="e_orch_fb" value="uses" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;fontSize=11;endArrow=open;endFill=0;dashed=1;exitX=0.5;exitY=0;exitDx=0;exitDy=0;entryX=0.5;entryY=1;entryDx=0;entryDy=0;" edge="1" parent="1" source="orch_t" target="fb_b">
<mxGeometry relative="1" as="geometry"/>
</mxCell>

</root>
</mxGraphModel>
</diagram>
Expand Down
Binary file modified docs/class-diagram.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions src/gitmate/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,9 @@ def config_set(field: str, value: str) -> None:
stripped = value.strip()
if not stripped:
raise typer.BadParameter(f"'{field}' must not be empty.")
if field == "commit_style" and stripped not in config_mod.COMMIT_STYLES:
valid_styles = ", ".join(config_mod.COMMIT_STYLES)
raise typer.BadParameter(f"'commit_style' must be one of: {valid_styles}.")
setattr(cfg, field, stripped)
else:
setattr(cfg, field, value)
Expand Down
12 changes: 10 additions & 2 deletions src/gitmate/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from typing import Any, Protocol

import keyring
import keyring.errors
import tomli_w

APP_NAME = "gitmate"
Expand All @@ -22,6 +23,7 @@
# still resolves.
DEFAULT_MODEL = "gemini-3.5-flash-lite"
DEFAULT_COMMIT_STYLE = "conventional"
COMMIT_STYLES = ("conventional", "plain")

#: Context window limits for known models; users override via `max_context_tokens`.
DEFAULT_MODEL_CONTEXT_WINDOWS: dict[str, int] = {
Expand Down Expand Up @@ -77,8 +79,11 @@ def set_secret(self, account: str, secret: str) -> None:
keyring.set_password(SERVICE_NAME, account, secret)

def get_secret(self, account: str) -> str | None:
"""Return a secret from the OS store, or None when absent."""
return keyring.get_password(SERVICE_NAME, account)
"""Return a secret from the OS store, or None when absent or unavailable."""
try:
return keyring.get_password(SERVICE_NAME, account)
except keyring.errors.KeyringError:
return None


class InMemorySecretStore:
Expand Down Expand Up @@ -138,6 +143,9 @@ def load_config(path: Path | None = None) -> GitmateConfig:
style = raw.get("commit_style", DEFAULT_COMMIT_STYLE)
if not isinstance(style, str):
raise ConfigError(f"cannot parse {resolved}: 'commit_style' must be a string")
if style not in COMMIT_STYLES:
valid_styles = ", ".join(COMMIT_STYLES)
raise ConfigError(f"cannot parse {resolved}: 'commit_style' must be one of: {valid_styles}")
budget = raw.get("budget_cap_usd")
if budget is None:
cap: float | None = None
Expand Down
Loading
Loading