diff --git a/README.md b/README.md index a0b7aaf..14b646a 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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. @@ -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) | diff --git a/docs/class-diagram.drawio b/docs/class-diagram.drawio index c0498e4..04c09fd 100644 --- a/docs/class-diagram.drawio +++ b/docs/class-diagram.drawio @@ -1,16 +1,16 @@ - + - + - + @@ -68,7 +68,7 @@ - + @@ -172,6 +172,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -182,7 +225,7 @@ - + @@ -264,6 +307,17 @@ + + + + + + + + + + + diff --git a/docs/class-diagram.png b/docs/class-diagram.png index 64a5968..ea5fad5 100644 Binary files a/docs/class-diagram.png and b/docs/class-diagram.png differ diff --git a/src/gitmate/cli.py b/src/gitmate/cli.py index 4c08871..1d73892 100644 --- a/src/gitmate/cli.py +++ b/src/gitmate/cli.py @@ -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) diff --git a/src/gitmate/config.py b/src/gitmate/config.py index 57f0571..1f73abb 100644 --- a/src/gitmate/config.py +++ b/src/gitmate/config.py @@ -9,6 +9,7 @@ from typing import Any, Protocol import keyring +import keyring.errors import tomli_w APP_NAME = "gitmate" @@ -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] = { @@ -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: @@ -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 diff --git a/src/gitmate/fallback.py b/src/gitmate/fallback.py new file mode 100644 index 0000000..70b2ba8 --- /dev/null +++ b/src/gitmate/fallback.py @@ -0,0 +1,304 @@ +"""Template-based graceful degradation and commit message orchestration.""" + +from __future__ import annotations + +import os +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING + +from gitmate import config as config_mod +from gitmate.config import API_KEY_ACCOUNT, GitmateConfig +from gitmate.diff_extractor import FileDiff +from gitmate.prompt import render_commit_prompt +from gitmate.providers.base import LLMProvider, ProviderUnavailable +from gitmate.providers.cache import CachedProvider +from gitmate.providers.gemini import GeminiProvider +from gitmate.token_budget import ( + BudgetStrategy, + GeminiTokenCounter, + TokenBudgetError, + TokenBudgetManager, + TokenCounter, +) + +if TYPE_CHECKING: + from rich.console import Console + +DOC_EXTENSIONS = {".md", ".rst", ".txt", ".adoc"} +BUILD_FILENAMES = { + "pyproject.toml", + "uv.lock", + "setup.py", + "setup.cfg", + "package.json", + "package-lock.json", + "cargo.toml", + "cargo.lock", + "go.mod", + "go.sum", + "dockerfile", + "docker-compose.yml", +} + + +@dataclass(frozen=True) +class GenerationResult: + """Outcome of commit message generation, tracking fallback and usage.""" + + text: str + is_fallback: bool + model: str + fallback_reason: str | None = None + input_tokens: int | None = None + output_tokens: int | None = None + + +def _extract_scope(path_str: str) -> str | None: + """Infer a concise scope from a file path, omitting redundant top-level directory names.""" + path = Path(path_str) + parts = path.parts + if len(parts) <= 1: + return None + + # Skip generic container directories to isolate specific subsystem + skip_dirs = {"src", "lib", "app", "gitmate", "tests", "docs", ".github", "workflows"} + filtered = [p for p in parts[:-1] if p.lower() not in skip_dirs] + if filtered: + return filtered[-1].lower() + return None + + +def _find_common_scope(diffs: list[FileDiff]) -> str | None: + """Find a shared scope if all diffs belong to the same subsystem.""" + scopes = {_extract_scope(d.path) for d in diffs} + if len(scopes) == 1: + return next(iter(scopes)) + return None + + +def _infer_type(diff: FileDiff) -> str: + """Infer conventional commit type for a single FileDiff.""" + path_lower = diff.path.lower() + name = Path(diff.path).name.lower() + suffix = Path(diff.path).suffix.lower() + + if ( + path_lower.startswith(".github/") + or "workflows" in path_lower + or name in (".gitlab-ci.yml", "azure-pipelines.yml") + ): + return "ci" + if name in BUILD_FILENAMES or suffix in (".lock", ".toml"): + return "build" + if path_lower.startswith("docs/") or suffix in DOC_EXTENSIONS: + return "docs" + if ( + path_lower.startswith("tests/") + or "test_" in name + or "_test." in name + or name.endswith("_test.py") + ): + return "test" + if diff.status == "added": + return "feat" + if diff.status == "deleted": + return "chore" + if diff.status == "renamed": + return "refactor" + return "chore" + + +def _infer_multi_type(diffs: list[FileDiff]) -> str: + """Infer primary conventional type across multiple diffs.""" + types = [_infer_type(d) for d in diffs] + # If all share the same type (e.g. all docs, all tests, all ci) + if len(set(types)) == 1: + return types[0] + + statuses = {d.status for d in diffs} + if statuses == {"added"}: + return "feat" + if statuses == {"deleted"}: + return "chore" + if statuses == {"renamed"}: + return "refactor" + if any(t == "feat" for t in types): + return "feat" + return "chore" + + +def generate_fallback_message(diffs: list[FileDiff], style: str = "conventional") -> str: + """Derive a clean, deterministic commit message from diff metadata without an LLM.""" + if not diffs: + return "chore: empty commit" if style == "conventional" else "Empty commit" + + if len(diffs) == 1: + return _single_file_fallback(diffs[0], style) + + return _multi_file_fallback(diffs, style) + + +def _single_file_fallback(diff: FileDiff, style: str) -> str: + """Generate fallback message for a single file diff.""" + path = diff.path + name = Path(path).name + scope = _extract_scope(path) + commit_type = _infer_type(diff) + + if style == "conventional": + scope_prefix = f"({scope})" if scope and scope != commit_type else "" + if diff.status == "added": + return f"{commit_type}{scope_prefix}: add {name}" + if diff.status == "deleted": + return f"{commit_type}{scope_prefix}: remove {name}" + if diff.status == "renamed": + old_name = Path(diff.old_path).name if diff.old_path else "file" + return f"refactor{scope_prefix}: rename {old_name} to {name}" + return f"{commit_type}{scope_prefix}: update {name}" + + # Plain style + if diff.status == "added": + return f"Add {path}" + if diff.status == "deleted": + return f"Remove {path}" + if diff.status == "renamed": + return f"Rename {diff.old_path or 'file'} to {path}" + return f"Update {path}" + + +def _multi_file_fallback(diffs: list[FileDiff], style: str) -> str: + """Generate fallback summary and file list for multiple diffs.""" + added = sum(1 for d in diffs if d.status == "added") + modified = sum(1 for d in diffs if d.status == "modified") + deleted = sum(1 for d in diffs if d.status == "deleted") + renamed = sum(1 for d in diffs if d.status == "renamed") + + parts: list[str] = [] + if added: + parts.append(f"added: {added}") + if modified: + parts.append(f"modified: {modified}") + if deleted: + parts.append(f"deleted: {deleted}") + if renamed: + parts.append(f"renamed: {renamed}") + breakdown = f" ({', '.join(parts)})" if parts else "" + + scope = _find_common_scope(diffs) + scope_str = f" in {scope}" if scope else "" + commit_type = _infer_multi_type(diffs) + + if style == "conventional": + scope_prefix = f"({scope})" if scope and scope != commit_type else "" + header = f"{commit_type}{scope_prefix}: update {len(diffs)} files{breakdown}" + else: + header = f"Update {len(diffs)} files{scope_str}{breakdown}" + + # Build detailed body bullets + bullets: list[str] = [] + for d in diffs: + if d.is_binary: + bullets.append(f"- Binary file: {d.path}") + elif d.status == "added": + bullets.append(f"- Add {d.path} (+{d.additions})") + elif d.status == "deleted": + bullets.append(f"- Remove {d.path} (-{d.deletions})") + elif d.status == "renamed": + bullets.append(f"- Rename {d.old_path or 'file'} -> {d.path}") + else: + bullets.append(f"- Modify {d.path} (+{d.additions}, -{d.deletions})") + + body = "\n".join(bullets) + return f"{header}\n\n{body}" + + +def generate_commit_message( + diffs: list[FileDiff], + cfg: GitmateConfig, + provider: LLMProvider | None = None, + counter: TokenCounter | None = None, + console: Console | None = None, + secret_store: config_mod.SecretStore | None = None, +) -> GenerationResult: + """Orchestrate commit message generation with graceful fallback on provider failure.""" + if not diffs: + return GenerationResult( + text="No changes staged for commit.", + is_fallback=False, + model=cfg.model, + ) + + store = secret_store if secret_store is not None else config_mod.KeyringSecretStore() + api_key = store.get_secret(API_KEY_ACCOUNT) or os.environ.get("GEMINI_API_KEY") + + # 1. Budget evaluation with fallback on counter/budget failure + active_counter = counter if counter is not None else GeminiTokenCounter(api_key=api_key) + try: + budget_mgr = TokenBudgetManager.from_config(cfg, active_counter) + decision = budget_mgr.assess(diffs) + if decision.strategy is BudgetStrategy.NEEDS_CHUNKING: + if console is not None: + console.print( + "[yellow]⚠ Diff exceeds token budget, using template fallback[/yellow]" + ) + return GenerationResult( + text=generate_fallback_message(diffs, style=cfg.commit_style), + is_fallback=True, + model=cfg.model, + fallback_reason=decision.summary_note, + ) + patch_chunks = [d.patch_text for d in decision.included_diffs if d.patch_text] + diff_text = "\n".join(patch_chunks) + summary_note = decision.summary_note + except TokenBudgetError as exc: + if console is not None: + console.print("[yellow]⚠ API unavailable, using template fallback[/yellow]") + fallback_msg = generate_fallback_message(diffs, style=cfg.commit_style) + return GenerationResult( + text=fallback_msg, + is_fallback=True, + model=cfg.model, + fallback_reason=f"Token budgeting failed: {exc}", + ) + + # 2. Render prompt template + prompt_text, version_key = render_commit_prompt( + style=cfg.commit_style, + diff_text=diff_text, + summary_note=summary_note, + ) + + # 3. Resolve provider + active_provider: LLMProvider + if provider is not None: + active_provider = provider + else: + base_provider = GeminiProvider(api_key=api_key) + active_provider = CachedProvider( + provider=base_provider, + cache_dir=cfg.cache_dir, + template_version=version_key, + ) + + # 4. Generate with graceful degradation + try: + resp = active_provider.generate(prompt=prompt_text, model=cfg.model) + return GenerationResult( + text=resp.text, + is_fallback=False, + model=resp.model, + input_tokens=resp.input_tokens, + output_tokens=resp.output_tokens, + ) + except ProviderUnavailable as exc: + if console is not None: + console.print("[yellow]⚠ API unavailable, using template fallback[/yellow]") + + fallback_msg = generate_fallback_message(diffs, style=cfg.commit_style) + return GenerationResult( + text=fallback_msg, + is_fallback=True, + model=cfg.model, + fallback_reason=str(exc), + ) diff --git a/src/gitmate/prompt.py b/src/gitmate/prompt.py new file mode 100644 index 0000000..c21d98e --- /dev/null +++ b/src/gitmate/prompt.py @@ -0,0 +1,103 @@ +"""Prompt template loading, version extraction, and rendering.""" + +from __future__ import annotations + +import importlib.resources +import string +from dataclasses import dataclass + +TEMPLATE_ALIASES: dict[str, str] = { + "conventional": "commit_conventional", + "plain": "commit_plain", + "pr_summary": "pr_summary", + "commit_conventional": "commit_conventional", + "commit_plain": "commit_plain", +} + + +class TemplateError(Exception): + """Base exception for prompt template failures.""" + + +class TemplateNotFoundError(TemplateError): + """Raised when a requested prompt template cannot be found.""" + + +class InvalidTemplateError(TemplateError): + """Raised when a template file contains an invalid version or format.""" + + +@dataclass(frozen=True) +class PromptTemplate: + """A versioned prompt template whose version_key drives cache invalidation.""" + + name: str + version: str + body: str + + @property + def version_key(self) -> str: + """Stable key combining template name and version for diskcache hashing.""" + return f"{self.name}:v{self.version}" + + def render(self, **kwargs: str) -> str: + """Substitute variables using safe string template interpolation.""" + template = string.Template(self.body) + return template.safe_substitute(**kwargs).strip() + + +def parse_template_content(content: str, name: str = "template") -> PromptTemplate: + """Parse a template string containing an optional 'version: ' header.""" + lines = content.strip().splitlines() + if not lines: + raise InvalidTemplateError(f"Template {name!r} is empty.") + + version = "1" + body_lines: list[str] = [] + + if lines[0].strip().startswith("version:"): + ver_part = lines[0].strip().split("version:", 1)[1].strip() + if not ver_part: + raise InvalidTemplateError(f"Template {name!r} has empty version header.") + version = ver_part + + # Skip separator '---' if present + start_idx = 1 + if len(lines) > 1 and lines[1].strip() == "---": + start_idx = 2 + body_lines = lines[start_idx:] + else: + body_lines = lines + + body = "\n".join(body_lines).strip() + if not body: + raise InvalidTemplateError(f"Template {name!r} has no prompt content.") + + return PromptTemplate(name=name, version=version, body=body) + + +def load_template(style: str) -> PromptTemplate: + """Load a versioned prompt template by style name from package resources.""" + normalized = style.strip().lower() + template_name = TEMPLATE_ALIASES.get(normalized) + if template_name is None: + valid_styles = ", ".join(sorted(set(TEMPLATE_ALIASES.keys()))) + raise TemplateNotFoundError( + f"Unknown template style {style!r}. Expected one of: {valid_styles}." + ) + + filename = f"{template_name}.txt" + try: + ref = importlib.resources.files("gitmate.templates").joinpath(filename) + content = ref.read_text(encoding="utf-8") + except (FileNotFoundError, ModuleNotFoundError) as exc: + raise TemplateNotFoundError(f"Template file {filename!r} not found: {exc}") from exc + + return parse_template_content(content, name=template_name) + + +def render_commit_prompt(style: str, diff_text: str, summary_note: str = "") -> tuple[str, str]: + """Render a commit prompt for the given style and return (prompt, version_key).""" + template = load_template(style) + rendered = template.render(diff=diff_text, summary_note=summary_note) + return rendered, template.version_key diff --git a/src/gitmate/templates/__init__.py b/src/gitmate/templates/__init__.py new file mode 100644 index 0000000..228f376 --- /dev/null +++ b/src/gitmate/templates/__init__.py @@ -0,0 +1 @@ +"""Versioned prompt templates for gitmate.""" diff --git a/src/gitmate/templates/commit_conventional.txt b/src/gitmate/templates/commit_conventional.txt new file mode 100644 index 0000000..858ac51 --- /dev/null +++ b/src/gitmate/templates/commit_conventional.txt @@ -0,0 +1,35 @@ +version: 1 +--- +You are an expert software engineer reviewing a git diff to create a Conventional Commits message. + +Write a clean, concise, and accurate commit message based on the changes below. + +Format: +(): + +[optional body: explain WHAT changed and WHY, not HOW. Keep line lengths <= 72 characters.] + +[optional footer: e.g. BREAKING CHANGE: or Closes #] + +Allowed types: +- feat: A new feature for the user +- fix: A bug fix for the user +- docs: Documentation changes only +- style: Changes that do not affect the meaning of the code (formatting, white-space) +- refactor: A code change that neither fixes a bug nor adds a feature +- perf: A code change that improves performance +- test: Adding missing tests or correcting existing tests +- build: Changes that affect the build system or external dependencies +- ci: Changes to CI configuration files and scripts +- chore: Other changes that don't modify src or test files + +Rules: +1. Imperative mood in subject ("add", "fix", "change", NOT "added", "fixes", "changing"). +2. Lowercase type and scope. +3. No trailing period on the subject line. +4. Output ONLY the raw commit message text — do not wrap in markdown code blocks, do not add introductory remarks or sign-offs. + +$summary_note + +Diff: +$diff diff --git a/src/gitmate/templates/commit_plain.txt b/src/gitmate/templates/commit_plain.txt new file mode 100644 index 0000000..4519f05 --- /dev/null +++ b/src/gitmate/templates/commit_plain.txt @@ -0,0 +1,21 @@ +version: 1 +--- +You are an expert software engineer reviewing a git diff to create a standard, high-quality git commit message. + +Write a concise and readable commit message based on the changes below. + +Format: + + +[optional body: bullet points explaining what changed and why. Keep line lengths <= 72 characters.] + +Rules: +1. Use imperative mood in the summary line ("Add feature", "Fix bug", "Refactor module", NOT "Added", "Fixing"). +2. The summary line must be clear, concise, and start with a capital letter. +3. If a body is necessary, separate it from the summary line with a blank line and use clean bullet points. +4. Output ONLY the raw commit message text — no markdown code blocks, quotes, greetings, or conversational filler. + +$summary_note + +Diff: +$diff diff --git a/src/gitmate/templates/pr_summary.txt b/src/gitmate/templates/pr_summary.txt new file mode 100644 index 0000000..1905244 --- /dev/null +++ b/src/gitmate/templates/pr_summary.txt @@ -0,0 +1,25 @@ +version: 1 +--- +You are an expert software engineer reviewing a git diff to create a Pull Request description. + +Generate a well-structured Pull Request description in GitHub Markdown based on the changes below. + +Format: +## Summary +A concise paragraph explaining the motivation, context, and high-level architecture of these changes. + +## Changes +A bulleted list summarizing the key code modifications, grouped logically if multiple areas are touched. + +## Verification & Testing +A bulleted list describing how these changes were tested and verified (automated tests, manual validation). + +Rules: +1. Be technical, accurate, and direct. +2. Highlight any breaking changes or configuration changes clearly. +3. Output ONLY the markdown description itself — no introductory pleasantries or closing remarks. + +$summary_note + +Diff: +$diff diff --git a/tests/conftest.py b/tests/conftest.py index 98e2317..53a6236 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -21,9 +21,17 @@ def tmp_config_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: @pytest.fixture(autouse=True) def mem_store(monkeypatch: pytest.MonkeyPatch) -> InMemorySecretStore: - """Swap the CLI's secret store for an in-memory fake across all tests.""" + """Swap the CLI's secret store for an in-memory fake across all tests; never touch OS Keychain.""" store = InMemorySecretStore() monkeypatch.setattr(cli, "secret_store", store) + monkeypatch.setattr( + "keyring.get_password", + lambda service, account: store.get_secret(account), + ) + monkeypatch.setattr( + "keyring.set_password", + lambda service, account, password: store.set_secret(account, password), + ) return store diff --git a/tests/test_config.py b/tests/test_config.py index 8d4e27a..d3476c0 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -4,12 +4,15 @@ from pathlib import Path +import keyring.errors import pytest from gitmate.config import ( + SERVICE_NAME, ConfigError, GitmateConfig, InMemorySecretStore, + KeyringSecretStore, UnknownModelError, config_path, get_context_window, @@ -55,6 +58,13 @@ def test_wrong_types_raise(tmp_config_dir: Path) -> None: load_config() +def test_invalid_commit_style_raises(tmp_config_dir: Path) -> None: + config_path().parent.mkdir(parents=True, exist_ok=True) + config_path().write_text('commit_style = "pr_summary"\n', encoding="utf-8") + with pytest.raises(ConfigError, match="'commit_style' must be one of"): + load_config() + + def test_memory_store_round_trip() -> None: store = InMemorySecretStore() assert store.get_secret("api-key") is None @@ -62,6 +72,34 @@ def test_memory_store_round_trip() -> None: assert store.get_secret("api-key") == "s3cr3t" +def test_keyring_store_get_secret_found(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("keyring.get_password", lambda service, account: "my-key") + store = KeyringSecretStore() + assert store.get_secret("api-key") == "my-key" + + +def test_keyring_store_get_secret_returns_none_on_keyring_error( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def _raise(*args: object, **kwargs: object) -> str: + raise keyring.errors.NoKeyringError("no backend") + + monkeypatch.setattr("keyring.get_password", _raise) + store = KeyringSecretStore() + assert store.get_secret("api-key") is None + + +def test_keyring_store_set_secret(monkeypatch: pytest.MonkeyPatch) -> None: + calls: list[tuple[str, str, str]] = [] + monkeypatch.setattr( + "keyring.set_password", + lambda s, a, p: calls.append((s, a, p)), + ) + store = KeyringSecretStore() + store.set_secret("api-key", "secret-val") + assert calls == [(SERVICE_NAME, "api-key", "secret-val")] + + def test_ignore_globs_round_trip(tmp_config_dir: Path) -> None: save_config(GitmateConfig(ignore_globs=["*.gen.py"])) assert load_config() == GitmateConfig(ignore_globs=["*.gen.py"]) diff --git a/tests/test_fallback.py b/tests/test_fallback.py new file mode 100644 index 0000000..125e2d1 --- /dev/null +++ b/tests/test_fallback.py @@ -0,0 +1,245 @@ +"""Tests for the offline, deterministic template-based fallback generator.""" + +from __future__ import annotations + +from gitmate.diff_extractor import FileDiff +from gitmate.fallback import generate_fallback_message + + +def test_empty_diffs() -> None: + assert generate_fallback_message([], style="conventional") == "chore: empty commit" + assert generate_fallback_message([], style="plain") == "Empty commit" + + +def test_single_file_added_conventional() -> None: + diff = FileDiff( + path="src/auth/login.py", + old_path=None, + status="added", + additions=45, + deletions=0, + patch_text="+ class Login...", + is_binary=False, + ) + msg = generate_fallback_message([diff], style="conventional") + assert msg == "feat(auth): add login.py" + + +def test_single_file_added_plain() -> None: + diff = FileDiff( + path="src/auth/login.py", + old_path=None, + status="added", + additions=45, + deletions=0, + patch_text="+ class Login...", + is_binary=False, + ) + msg = generate_fallback_message([diff], style="plain") + assert msg == "Add src/auth/login.py" + + +def test_single_file_modified_conventional() -> None: + diff = FileDiff( + path="src/gitmate/cli.py", + old_path=None, + status="modified", + additions=10, + deletions=2, + patch_text="@@ ...", + is_binary=False, + ) + msg = generate_fallback_message([diff], style="conventional") + assert msg == "chore: update cli.py" + + +def test_single_file_deleted_conventional() -> None: + diff = FileDiff( + path="src/auth/old_auth.py", + old_path=None, + status="deleted", + additions=0, + deletions=50, + patch_text="- def old...", + is_binary=False, + ) + msg = generate_fallback_message([diff], style="conventional") + assert msg == "chore(auth): remove old_auth.py" + + +def test_single_file_renamed_conventional() -> None: + diff = FileDiff( + path="src/auth/auth_v2.py", + old_path="src/auth/auth_v1.py", + status="renamed", + additions=5, + deletions=2, + patch_text="@@ ...", + is_binary=False, + ) + msg = generate_fallback_message([diff], style="conventional") + assert msg == "refactor(auth): rename auth_v1.py to auth_v2.py" + + +def test_single_file_docs_conventional() -> None: + diff = FileDiff( + path="README.md", + old_path=None, + status="modified", + additions=5, + deletions=1, + patch_text="@@ ...", + is_binary=False, + ) + msg = generate_fallback_message([diff], style="conventional") + assert msg == "docs: update README.md" + + +def test_single_file_test_conventional() -> None: + diff = FileDiff( + path="tests/test_cli.py", + old_path=None, + status="modified", + additions=20, + deletions=0, + patch_text="@@ ...", + is_binary=False, + ) + msg = generate_fallback_message([diff], style="conventional") + assert msg == "test: update test_cli.py" + + +def test_single_file_build_conventional() -> None: + diff = FileDiff( + path="pyproject.toml", + old_path=None, + status="modified", + additions=1, + deletions=0, + patch_text="@@ ...", + is_binary=False, + ) + msg = generate_fallback_message([diff], style="conventional") + assert msg == "build: update pyproject.toml" + + +def test_single_file_ci_conventional() -> None: + diff = FileDiff( + path=".github/workflows/ci.yml", + old_path=None, + status="modified", + additions=3, + deletions=1, + patch_text="@@ ...", + is_binary=False, + ) + msg = generate_fallback_message([diff], style="conventional") + assert msg == "ci: update ci.yml" + + +def test_multi_file_same_scope_conventional() -> None: + diffs = [ + FileDiff( + path="src/providers/gemini.py", + old_path=None, + status="modified", + additions=50, + deletions=5, + patch_text="...", + is_binary=False, + ), + FileDiff( + path="src/providers/anthropic.py", + old_path=None, + status="added", + additions=80, + deletions=0, + patch_text="...", + is_binary=False, + ), + ] + msg = generate_fallback_message(diffs, style="conventional") + lines = msg.splitlines() + assert lines[0] == "feat(providers): update 2 files (added: 1, modified: 1)" + assert "- Add src/providers/anthropic.py (+80)" in msg + assert "- Modify src/providers/gemini.py (+50, -5)" in msg + + +def test_multi_file_plain_style() -> None: + diffs = [ + FileDiff( + path="src/auth/login.py", + old_path=None, + status="added", + additions=10, + deletions=0, + patch_text="...", + is_binary=False, + ), + FileDiff( + path="src/auth/logout.py", + old_path=None, + status="modified", + additions=5, + deletions=2, + patch_text="...", + is_binary=False, + ), + ] + msg = generate_fallback_message(diffs, style="plain") + lines = msg.splitlines() + assert lines[0] == "Update 2 files in auth (added: 1, modified: 1)" + assert "- Add src/auth/login.py (+10)" in msg + assert "- Modify src/auth/logout.py (+5, -2)" in msg + + +def test_multi_file_cross_subsystem_no_shared_scope() -> None: + diffs = [ + FileDiff( + path="src/auth/login.py", + old_path=None, + status="added", + additions=10, + deletions=0, + patch_text="...", + is_binary=False, + ), + FileDiff( + path="docs/guide.md", + old_path=None, + status="added", + additions=5, + deletions=0, + patch_text="...", + is_binary=False, + ), + ] + msg = generate_fallback_message(diffs, style="conventional") + lines = msg.splitlines() + assert lines[0] == "feat: update 2 files (added: 2)" + + +def test_multi_file_with_binary_and_renames() -> None: + diffs = [ + FileDiff( + path="assets/logo.png", + old_path=None, + status="added", + additions=0, + deletions=0, + patch_text="", + is_binary=True, + ), + FileDiff( + path="src/old.py", + old_path="src/legacy.py", + status="renamed", + additions=0, + deletions=0, + patch_text="", + is_binary=False, + ), + ] + msg = generate_fallback_message(diffs, style="conventional") + assert "- Binary file: assets/logo.png" in msg + assert "- Rename src/legacy.py -> src/old.py" in msg diff --git a/tests/test_orchestrator.py b/tests/test_orchestrator.py new file mode 100644 index 0000000..dcfd3ef --- /dev/null +++ b/tests/test_orchestrator.py @@ -0,0 +1,203 @@ +"""Tests for commit message orchestration and graceful degradation on provider failure.""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +from rich.console import Console + +from gitmate.config import GitmateConfig, InMemorySecretStore +from gitmate.diff_extractor import FileDiff +from gitmate.fallback import generate_commit_message +from gitmate.providers.base import LLMResponse, ProviderUnavailable +from gitmate.token_budget import NoApiKeyError, TokenCounter + + +class FakeProvider: + """In-memory LLMProvider for testing.""" + + def __init__(self, response: str = "feat(cli): add commit command") -> None: + self.response = response + self.prompts: list[str] = [] + + def generate(self, prompt: str, model: str) -> LLMResponse: + self.prompts.append(prompt) + return LLMResponse( + text=self.response, + model=model, + input_tokens=150, + output_tokens=25, + ) + + +class FailingProvider: + """LLMProvider that simulates backend outage or rate limits.""" + + def __init__(self, error_msg: str = "Gemini unavailable after retries: 503") -> None: + self.error_msg = error_msg + + def generate(self, prompt: str, model: str) -> LLMResponse: + raise ProviderUnavailable(self.error_msg) + + +class FakeCounter(TokenCounter): + """Predictable in-memory token counter.""" + + def __init__(self, token_rate: int = 1) -> None: + self.token_rate = token_rate + + def count_tokens(self, text: str, model: str) -> int: + return max(1, len(text) // self.token_rate) + + +class FailingCounter(TokenCounter): + """TokenCounter simulating missing credentials or connection drop.""" + + def count_tokens(self, text: str, model: str) -> int: + raise NoApiKeyError("Missing API key for counting.") + + +def _sample_diff() -> list[FileDiff]: + return [ + FileDiff( + path="src/auth/login.py", + old_path=None, + status="added", + additions=30, + deletions=5, + patch_text="@@ -1,5 +1,30 @@\n+class LoginService:\n+ pass", + is_binary=False, + ) + ] + + +def test_generate_commit_message_success() -> None: + cfg = GitmateConfig(model="gemini-3.5-flash-lite", commit_style="conventional") + provider = FakeProvider(response="feat(auth): add login service") + counter = FakeCounter() + + result = generate_commit_message( + diffs=_sample_diff(), + cfg=cfg, + provider=provider, + counter=counter, + ) + + assert result.is_fallback is False + assert result.text == "feat(auth): add login service" + assert result.model == "gemini-3.5-flash-lite" + assert result.input_tokens == 150 + assert result.output_tokens == 25 + assert result.fallback_reason is None + assert len(provider.prompts) == 1 + assert "Diff:" in provider.prompts[0] + assert "+class LoginService:" in provider.prompts[0] + + +def test_generate_commit_message_provider_failure_triggers_fallback() -> None: + cfg = GitmateConfig(model="gemini-3.5-flash-lite", commit_style="conventional") + provider = FailingProvider(error_msg="503 Service Unavailable") + counter = FakeCounter() + console = MagicMock(spec=Console) + + result = generate_commit_message( + diffs=_sample_diff(), + cfg=cfg, + provider=provider, + counter=counter, + console=console, + ) + + assert result.is_fallback is True + assert result.text == "feat(auth): add login.py" + assert result.fallback_reason == "503 Service Unavailable" + console.print.assert_called_once_with( + "[yellow]⚠ API unavailable, using template fallback[/yellow]" + ) + + +def test_generate_commit_message_plain_style_fallback() -> None: + cfg = GitmateConfig(model="gemini-3.5-flash-lite", commit_style="plain") + provider = FailingProvider() + counter = FakeCounter() + + result = generate_commit_message( + diffs=_sample_diff(), + cfg=cfg, + provider=provider, + counter=counter, + ) + + assert result.is_fallback is True + assert result.text == "Add src/auth/login.py" + + +def test_generate_commit_message_empty_diffs() -> None: + cfg = GitmateConfig() + result = generate_commit_message(diffs=[], cfg=cfg) + + assert result.is_fallback is False + assert result.text == "No changes staged for commit." + + +def test_generate_commit_message_counter_failure_triggers_fallback() -> None: + cfg = GitmateConfig(commit_style="conventional") + counter = FailingCounter() + console = MagicMock(spec=Console) + + result = generate_commit_message( + diffs=_sample_diff(), + cfg=cfg, + counter=counter, + console=console, + ) + + assert result.is_fallback is True + assert result.text == "feat(auth): add login.py" + assert "Token budgeting failed" in (result.fallback_reason or "") + console.print.assert_called_once_with( + "[yellow]⚠ API unavailable, using template fallback[/yellow]" + ) + + +def test_generate_commit_message_chunking_uses_fallback() -> None: + cfg = GitmateConfig(max_context_tokens=2_600) + provider = FakeProvider() + counter = FakeCounter() + console = MagicMock(spec=Console) + + result = generate_commit_message( + diffs=_sample_diff(), + cfg=cfg, + provider=provider, + counter=counter, + console=console, + ) + + assert result.is_fallback is True + assert result.text == "feat(auth): add login.py" + assert result.fallback_reason is not None + assert "requires chunking" in result.fallback_reason + assert provider.prompts == [] + console.print.assert_called_once_with( + "[yellow]⚠ Diff exceeds token budget, using template fallback[/yellow]" + ) + + +def test_generate_commit_message_custom_secret_store() -> None: + cfg = GitmateConfig(commit_style="conventional") + provider = FakeProvider(response="feat(auth): add login feature") + counter = FakeCounter() + store = InMemorySecretStore() + store.set_secret("api-key", "test-key-123") + + result = generate_commit_message( + diffs=_sample_diff(), + cfg=cfg, + provider=provider, + counter=counter, + secret_store=store, + ) + + assert result.is_fallback is False + assert result.text == "feat(auth): add login feature" diff --git a/tests/test_prompt.py b/tests/test_prompt.py new file mode 100644 index 0000000..4663f97 --- /dev/null +++ b/tests/test_prompt.py @@ -0,0 +1,103 @@ +"""Tests for prompt template loading, version parsing, and rendering.""" + +from __future__ import annotations + +import pytest + +from gitmate.prompt import ( + InvalidTemplateError, + PromptTemplate, + TemplateNotFoundError, + load_template, + parse_template_content, + render_commit_prompt, +) +from gitmate.providers.cache import cache_key + + +def test_load_known_templates() -> None: + for style in ("conventional", "plain", "pr_summary"): + template = load_template(style) + assert isinstance(template, PromptTemplate) + assert template.version == "1" + assert "$diff" in template.body + assert template.version_key == f"{template.name}:v1" + + +def test_load_template_aliases() -> None: + conv = load_template("conventional") + full = load_template("commit_conventional") + assert conv.name == full.name == "commit_conventional" + assert conv.body == full.body + + +def test_load_unknown_template_raises() -> None: + with pytest.raises(TemplateNotFoundError, match="Unknown template style 'pirate'"): + load_template("pirate") + + +def test_parse_template_content_with_separator() -> None: + raw = "version: 42\n---\nPrompt body with $diff" + template = parse_template_content(raw, name="custom") + assert template.name == "custom" + assert template.version == "42" + assert template.body == "Prompt body with $diff" + assert template.version_key == "custom:v42" + + +def test_parse_template_content_without_separator() -> None: + raw = "version: 2\nSingle header followed by $diff" + template = parse_template_content(raw, name="custom") + assert template.version == "2" + assert template.body == "Single header followed by $diff" + + +def test_parse_template_content_default_version() -> None: + raw = "Just a plain prompt body without version header." + template = parse_template_content(raw, name="defaulted") + assert template.version == "1" + assert template.body == raw + + +def test_parse_template_content_empty_raises() -> None: + with pytest.raises(InvalidTemplateError, match="is empty"): + parse_template_content("", name="empty") + + +def test_parse_template_content_empty_version_raises() -> None: + with pytest.raises(InvalidTemplateError, match="has empty version header"): + parse_template_content("version:\n---\nbody", name="bad_ver") + + +def test_parse_template_content_empty_body_raises() -> None: + with pytest.raises(InvalidTemplateError, match="has no prompt content"): + parse_template_content("version: 1\n---\n ", name="no_body") + + +def test_render_with_literal_curly_braces() -> None: + raw = "version: 1\n---\nFormat: (): {literal_brace}\nDiff: $diff" + template = parse_template_content(raw, name="braces") + rendered = template.render(diff="my diff") + assert "{literal_brace}" in rendered + assert "Diff: my diff" in rendered + + +def test_render_commit_prompt() -> None: + prompt_text, version_key = render_commit_prompt( + style="conventional", + diff_text="+ print('hello')", + summary_note="Note: 1 file omitted", + ) + assert "+ print('hello')" in prompt_text + assert "Note: 1 file omitted" in prompt_text + assert version_key == "commit_conventional:v1" + + +def test_template_version_bump_invalidates_cache_key() -> None: + t1 = parse_template_content("version: 1\n---\nPrompt $diff", name="t") + t2 = parse_template_content("version: 2\n---\nPrompt $diff", name="t") + + key1 = cache_key("Prompt foo", "gemini-3.5-flash-lite", t1.version_key) + key2 = cache_key("Prompt foo", "gemini-3.5-flash-lite", t2.version_key) + + assert key1 != key2