diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..b3bd3b6 --- /dev/null +++ b/.github/workflows/release.yml @@ -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 }} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..eac5e40 --- /dev/null +++ b/CHANGELOG.md @@ -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 --to ` 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. diff --git a/README.md b/README.md index 02d6555..7ff8794 100644 --- a/README.md +++ b/README.md @@ -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: @@ -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). diff --git a/docs/architecture.drawio b/docs/architecture.drawio index 6a38828..05327ff 100644 --- a/docs/architecture.drawio +++ b/docs/architecture.drawio @@ -8,7 +8,7 @@ - + @@ -41,6 +41,9 @@ + + + @@ -140,6 +143,12 @@ + + + + + + diff --git a/docs/architecture.png b/docs/architecture.png index d81365e..52a718a 100644 Binary files a/docs/architecture.png and b/docs/architecture.png differ diff --git a/docs/demo.gif b/docs/demo.gif new file mode 100644 index 0000000..e6d34a0 Binary files /dev/null and b/docs/demo.gif differ diff --git a/pyproject.toml b/pyproject.toml index 356f3d8..2e99a2f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,12 +3,36 @@ requires = ["hatchling"] build-backend = "hatchling.build" [project] -name = "gitmate" +name = "gitmate-cli" version = "0.1.0" description = "AI-assisted git commit messages, PR summaries, and changelogs" readme = "README.md" requires-python = ">=3.11" license = { text = "Apache-2.0" } +authors = [{ name = "Amar K P", email = "amarkerpala59@gmail.com" }] +keywords = [ + "git", + "commit", + "commit-message", + "changelog", + "pull-request", + "llm", + "ai", + "gemini", +] +classifiers = [ + "Development Status :: 4 - Beta", + "Environment :: Console", + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Topic :: Software Development :: Version Control :: Git", +] dependencies = [ "typer>=0.12", "rich>=13.0", @@ -22,6 +46,12 @@ dependencies = [ "pyperclip>=1.8.2", ] +[project.urls] +Homepage = "https://github.com/getdownandcode/gitmate" +Repository = "https://github.com/getdownandcode/gitmate" +Issues = "https://github.com/getdownandcode/gitmate/issues" +Changelog = "https://github.com/getdownandcode/gitmate/blob/main/CHANGELOG.md" + [project.optional-dependencies] dev = [ "pytest>=8.0", @@ -34,6 +64,14 @@ dev = [ gitmate = "gitmate.cli:app" gitmate-hook = "gitmate.hooks:main" +# Internal build documentation; not needed by anyone who pip-installs. +[tool.hatch.build.targets.sdist] +exclude = ["plan.md", "AGENTS.md", "docs", ".github", "uv.lock", ".agents", ".gitignore"] + +# Import package differs from the distribution name (gitmate vs gitmate-cli). +[tool.hatch.build.targets.wheel] +packages = ["src/gitmate"] + [tool.ruff] line-length = 100 target-version = "py311" diff --git a/src/gitmate/cli.py b/src/gitmate/cli.py index e44aec9..2d6193f 100644 --- a/src/gitmate/cli.py +++ b/src/gitmate/cli.py @@ -24,6 +24,35 @@ err_console = Console(stderr=True) secret_store: config_mod.SecretStore = config_mod.KeyringSecretStore() +#: The installed distribution name; the CLI command stays `gitmate`. +DIST_NAME = "gitmate-cli" + + +def _version_callback(value: bool) -> None: + """Print the installed distribution version for --version.""" + if value: + from importlib.metadata import PackageNotFoundError, version + + try: + console.print(version(DIST_NAME)) + except PackageNotFoundError: + console.print("unknown (gitmate-cli is not installed as a distribution)") + raise typer.Exit() + + +@app.callback() +def _main( + version: bool = typer.Option( + False, + "--version", + "-V", + help="Show the installed gitmate version and exit.", + callback=_version_callback, + is_eager=True, + ), +) -> None: + """AI-assisted git commit messages, PR summaries, and changelogs.""" + def _stub(name: str, milestone: str) -> None: """Report a planned command that has no implementation yet.""" @@ -37,7 +66,11 @@ def commit( """Generate a commit message for the staged diff and review before committing.""" from gitmate.committer import commit_flow - code = commit_flow(yes=yes, console=console, secret_store=secret_store) + try: + code = commit_flow(yes=yes, console=console, secret_store=secret_store) + except config_mod.ConfigError as exc: + err_console.print(f"[red]error:[/red] {exc}") + raise typer.Exit(1) from None if code != 0: raise typer.Exit(code) @@ -175,7 +208,7 @@ def pr_summary( copy_to_cb=copy, create_pr=create_pr, ) - except (GitCommandError, BudgetCapExceededError, GitHubCliError) as exc: + except (GitCommandError, BudgetCapExceededError, GitHubCliError, config_mod.ConfigError) as exc: err_console.print(f"[red]error:[/red] {exc}") raise typer.Exit(1) from None @@ -228,7 +261,7 @@ def changelog( secret_store=secret_store, bypass_cache=bypass_cache, ) - except (GitCommandError, BudgetCapExceededError) as exc: + except (GitCommandError, BudgetCapExceededError, config_mod.ConfigError) as exc: err_console.print(f"[red]error:[/red] {exc}") raise typer.Exit(1) from None @@ -397,8 +430,11 @@ def config_set(field: str, value: str) -> None: def _parse_budget(value: str) -> float: - """Parse a budget string, rejecting non-numeric input.""" + """Parse a budget string, rejecting non-numeric or negative input.""" try: - return float(value) + budget = float(value) except ValueError: raise typer.BadParameter(f"{value!r} is not a number.") from None + if budget < 0: + raise typer.BadParameter(f"'budget_cap_usd' must be non-negative (got {value!r}).") + return budget diff --git a/src/gitmate/config.py b/src/gitmate/config.py index 5120615..80cd8ef 100644 --- a/src/gitmate/config.py +++ b/src/gitmate/config.py @@ -168,6 +168,8 @@ def load_config(path: Path | None = None) -> GitmateConfig: raise ConfigError(f"cannot parse {resolved}: 'budget_cap_usd' must be a number") else: cap = float(budget) + if cap < 0: + raise ConfigError(f"cannot parse {resolved}: 'budget_cap_usd' must be non-negative") globs = raw.get("ignore_globs", []) if not isinstance(globs, list) or not all(isinstance(g, str) for g in globs): raise ConfigError(f"cannot parse {resolved}: 'ignore_globs' must be a string list") diff --git a/src/gitmate/providers/gemini.py b/src/gitmate/providers/gemini.py index 8a61eb2..4633739 100644 --- a/src/gitmate/providers/gemini.py +++ b/src/gitmate/providers/gemini.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging from typing import TYPE_CHECKING from tenacity import ( @@ -14,6 +15,12 @@ from gitmate.providers.base import LLMResponse, ProviderUnavailable, RetryableProviderError +# The SDK emits a once-per-process AFC advisory via logger.warning on every +# Models.generate_content call; it is irrelevant to gitmate's single-shot +# usage and pollutes the commit flow's output, so quiet that channel to +# ERROR while leaving genuine SDK errors audible. +logging.getLogger("google_genai.models").setLevel(logging.ERROR) + if TYPE_CHECKING: from google import genai from google.genai import types diff --git a/tests/test_cli.py b/tests/test_cli.py index cc798c7..310c6f1 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -28,6 +28,15 @@ def test_help_lists_all_commands() -> None: assert name in result.output +def test_version_flag_prints_distribution_version() -> None: + from importlib.metadata import version + + for flag in ("--version", "-V"): + result = runner.invoke(cli.app, [flag]) + assert result.exit_code == 0 + assert version("gitmate-cli") in result.output + + def test_stubs_report_not_implemented() -> None: result = runner.invoke(cli.app, ["doc"]) assert result.exit_code == 0 @@ -169,6 +178,33 @@ def test_config_show_malformed_config_clean_error(tmp_config_dir: Path) -> None: assert "Traceback" not in result.output +def test_commit_malformed_config_clean_error(tmp_config_dir: Path, git_repo: Path) -> None: + config_path().parent.mkdir(parents=True, exist_ok=True) + config_path().write_text("model = [invalid toml\n", encoding="utf-8") + result = runner.invoke(cli.app, ["commit"]) + assert result.exit_code == 1 + assert "error:" in result.output + assert "Traceback" not in result.output + + +def test_pr_summary_malformed_config_clean_error(tmp_config_dir: Path, git_repo: Path) -> None: + config_path().parent.mkdir(parents=True, exist_ok=True) + config_path().write_text("model = [invalid toml\n", encoding="utf-8") + result = runner.invoke(cli.app, ["pr-summary"]) + assert result.exit_code == 1 + assert "error:" in result.output + assert "Traceback" not in result.output + + +def test_changelog_malformed_config_clean_error(tmp_config_dir: Path, git_repo: Path) -> None: + config_path().parent.mkdir(parents=True, exist_ok=True) + config_path().write_text("model = [invalid toml\n", encoding="utf-8") + result = runner.invoke(cli.app, ["changelog", "--from", "HEAD"]) + assert result.exit_code == 1 + assert "error:" in result.output + assert "Traceback" not in result.output + + def test_debug_diff_git_command_error_clean_error(tmp_config_dir: Path, git_repo: Path) -> None: result = runner.invoke(cli.app, ["debug-diff", "--base", "nonexistent-branch"]) assert result.exit_code == 1 @@ -217,6 +253,12 @@ def test_config_set_budget_fields_validation(tmp_config_dir: Path) -> None: assert "must be greater than" in res.output +def test_config_set_rejects_negative_budget_cap(tmp_config_dir: Path) -> None: + res = runner.invoke(cli.app, ["config", "set", "budget_cap_usd", "--", "-5"]) + assert res.exit_code != 0 + assert "non-negative" in res.output + + def test_commit_cli_no_staged_diff_exits_zero(git_repo: Path, tmp_config_dir: Path) -> None: result = runner.invoke(cli.app, ["commit"]) assert result.exit_code == 0 diff --git a/tests/test_config.py b/tests/test_config.py index 2bd2b79..b9838c2 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -168,6 +168,13 @@ def test_budget_config_round_trip(tmp_config_dir: Path) -> None: assert loaded.template_overhead == 800 +def test_negative_budget_cap_rejected(tmp_config_dir: Path) -> None: + config_path().parent.mkdir(parents=True, exist_ok=True) + config_path().write_text("budget_cap_usd = -1.0\n", encoding="utf-8") + with pytest.raises(ConfigError, match="must be non-negative"): + load_config() + + @pytest.mark.parametrize("val", ['"big"', "true", "-100", "0"]) def test_invalid_max_context_tokens_raises(tmp_config_dir: Path, val: str) -> None: config_path().parent.mkdir(parents=True, exist_ok=True) diff --git a/tests/test_providers.py b/tests/test_providers.py index 0d4fd7b..866795b 100644 --- a/tests/test_providers.py +++ b/tests/test_providers.py @@ -265,3 +265,11 @@ def test_cached_provider_underlying_property(tmp_path: Path) -> None: assert cached.underlying is fake finally: cached.close() + + +def test_sdk_afc_advisory_quieted() -> None: + # The SDK's once-per-process AFC warning would otherwise print above + # every generated commit message; the provider pins that logger to ERROR. + import logging + + assert logging.getLogger("google_genai.models").level == logging.ERROR diff --git a/uv.lock b/uv.lock index 4d139ff..fbe2c39 100644 --- a/uv.lock +++ b/uv.lock @@ -597,7 +597,7 @@ wheels = [ ] [[package]] -name = "gitmate" +name = "gitmate-cli" version = "0.1.0" source = { editable = "." } dependencies = [