Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/gitmate/changelog.py
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,7 @@ def generate_changelog(
extractor = (
diff_extractor
if diff_extractor is not None
else DiffExtractor(repo=repo_path, runner=runner)
else DiffExtractor(repo=repo_path, runner=runner, extra_ignores=active_cfg.ignore_globs)
)
diffs = []
if include_diff:
Expand Down Expand Up @@ -253,6 +253,7 @@ def generate_changelog(
console=con,
secret_store=secret_store,
bypass_cache=bypass_cache,
check_budget=False, # already checked above
)

if output_file is not None:
Expand Down
13 changes: 8 additions & 5 deletions src/gitmate/chunking.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@ def summarize_omitted_diffs(
"""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.
prompts the provider for concise per-file summaries, and aggregates billed token
usage (responses served from a CachedProvider are excluded).
"""
summaries: list[str] = []
total_in = 0
Expand All @@ -63,10 +64,12 @@ def summarize_omitted_diffs(
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
# Cache hits consumed no API tokens this run; don't bill them.
if not getattr(provider, "last_hit", False):
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)
Expand Down
20 changes: 13 additions & 7 deletions src/gitmate/committer.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@
from pathlib import Path

from rich.console import Console
from rich.markup import escape
from rich.panel import Panel
from rich.text import Text

from gitmate import config as config_mod
from gitmate.config import GitmateConfig
Expand Down Expand Up @@ -56,7 +58,7 @@ def _render_panel(console: Console, message: str, model: str, is_fallback: bool)
else f"[green bold]{model}[/green bold]"
)
panel = Panel(
message,
Text(message),
title=f"Proposed Commit Message [dim]({status_badge})[/dim]",
border_style="yellow" if is_fallback else "green",
padding=(1, 2),
Expand Down Expand Up @@ -88,12 +90,12 @@ def _execute_commit(message: str, console: Console | None = None, cwd: Path | No
)
if proc.returncode == 0:
if console is not None and proc.stdout.strip():
console.print(proc.stdout.strip())
console.print(Text(proc.stdout.strip()))
else:
if console is not None:
err_msg = proc.stderr.strip() or proc.stdout.strip()
if err_msg:
console.print(f"[red]{err_msg}[/red]")
console.print(Text(err_msg, style="red"))
return proc.returncode
except FileNotFoundError:
if console is not None:
Expand Down Expand Up @@ -236,6 +238,7 @@ def commit_flow(
console=con,
secret_store=secret_store,
bypass_cache=False,
check_budget=False,
)
latency_ms = int((time.perf_counter() - t0) * 1000)

Expand All @@ -248,6 +251,7 @@ def commit_flow(
latency_ms=latency_ms,
fallback_used=gen_result.is_fallback,
estimated_cost_usd=gen_result.estimated_cost_usd,
free_tier=active_cfg.free_tier,
)

current_message = gen_result.text
Expand All @@ -265,7 +269,7 @@ def commit_flow(
while True:
try:
raw_input = con.input(
"[bold cyan]?[/bold cyan] [bold][a]ccept / [e]dit / [r]egenerate / [c]ancel:[/bold] "
"[bold cyan]?[/bold cyan] [bold]\\[a]ccept / \\[e]dit / \\[r]egenerate / \\[c]ancel:[/bold] "
)
except (EOFError, KeyboardInterrupt):
con.print("\nCommit cancelled.")
Expand All @@ -274,7 +278,7 @@ def commit_flow(
choice = raw_input.strip().lower()
if not choice:
con.print(
"[yellow]Please select an option: [a]ccept, [e]dit, [r]egenerate, or [c]ancel.[/yellow]"
"[yellow]Please select an option: \\[a]ccept, \\[e]dit, \\[r]egenerate, or \\[c]ancel.[/yellow]"
)
continue

Expand Down Expand Up @@ -306,6 +310,7 @@ def commit_flow(
console=con,
secret_store=secret_store,
bypass_cache=True,
check_budget=False,
)
latency_ms = int((time.perf_counter() - t0) * 1000)

Expand All @@ -318,6 +323,7 @@ def commit_flow(
latency_ms=latency_ms,
fallback_used=gen_result.is_fallback,
estimated_cost_usd=gen_result.estimated_cost_usd,
free_tier=active_cfg.free_tier,
)

current_message = gen_result.text
Expand All @@ -331,6 +337,6 @@ def commit_flow(

else:
con.print(
f"[yellow]Invalid selection '{raw_input}'. "
"Please choose [a]ccept, [e]dit, [r]egenerate, or [c]ancel.[/yellow]"
f"[yellow]Invalid selection '{escape(raw_input)}'. "
"Please choose \\[a]ccept, \\[e]dit, \\[r]egenerate, or \\[c]ancel.[/yellow]"
)
2 changes: 2 additions & 0 deletions src/gitmate/fallback.py
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,7 @@ def generate_commit_message(
bypass_cache: bool = False,
command: str | None = None,
hook_mode: bool = False,
check_budget: bool = True,
) -> GenerationResult:
"""Orchestrate commit message generation with graceful fallback on provider failure."""
return run_generation_pipeline(
Expand All @@ -279,4 +280,5 @@ def generate_commit_message(
console=console,
secret_store=secret_store,
bypass_cache=bypass_cache,
check_budget=check_budget,
)
43 changes: 30 additions & 13 deletions src/gitmate/orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,10 @@
from gitmate.metrics import calculate_cost, get_monthly_spend, record_invocation
from gitmate.prompt import load_template, render_prompt
from gitmate.providers.base import LLMProvider, ProviderUnavailable
from gitmate.providers.cache import CachedProvider, cache_key
from gitmate.providers.cache import CachedProvider
from gitmate.providers.gemini import GeminiProvider
from gitmate.token_budget import (
OMITTED_NOTE_PREFIX,
BudgetStrategy,
GeminiTokenCounter,
HeuristicTokenCounter,
Expand Down Expand Up @@ -105,6 +106,23 @@ def check_budget_cap(cfg: GitmateConfig, console: Console | None = None) -> None
)


def _prompt_diff_text(diffs: list[FileDiff]) -> str:
"""Join patches for the prompt, labelling files whose patch text lacks a header.

Binary files carry no patch and truncated files carry only a size note, so
without a label the model would never learn which file they were.
"""
parts: list[str] = []
for d in diffs:
if d.is_binary:
parts.append(f"Binary file {d.path} ({d.status})")
elif d.patch_text.startswith(OMITTED_NOTE_PREFIX):
parts.append(f"File {d.path} ({d.status}): {d.patch_text}")
elif d.patch_text:
parts.append(d.patch_text)
return "\n".join(parts)


def run_generation_pipeline(
diffs: list[FileDiff],
cfg: GitmateConfig,
Expand All @@ -119,16 +137,22 @@ def run_generation_pipeline(
secret_store: SecretStore | None = None,
bypass_cache: bool = False,
hook_mode: bool = False,
check_budget: bool = True,
) -> GenerationResult:
"""Execute unified generation: token budgeting, chunking, caching, LLM call, and fallback."""
"""Execute unified generation: token budgeting, chunking, caching, LLM call, and fallback.

Pass check_budget=False only when the caller has already run check_budget_cap,
so the 80% warning is not printed twice.
"""
if empty_diff_message is not None and not diffs:
return GenerationResult(
text=empty_diff_message,
is_fallback=False,
model=cfg.model,
)

check_budget_cap(cfg, console=console)
if check_budget:
check_budget_cap(cfg, console=console)

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")
Expand Down Expand Up @@ -170,7 +194,7 @@ def run_generation_pipeline(
decision = budget_mgr.assess(diffs)
except TokenBudgetError as exc:
if console is not None:
console.print("[yellow]⚠ API unavailable, using template fallback[/yellow]")
console.print("[yellow]⚠ Token budgeting failed, using template fallback[/yellow]")
gen_result = GenerationResult(
text=fallback_generator(),
is_fallback=True,
Expand Down Expand Up @@ -225,12 +249,9 @@ def run_generation_pipeline(
summary_note = (
f"{decision.summary_note}\n\nSummaries of large omitted files:\n{merged_summaries}"
)
patch_chunks = [d.patch_text for d in decision.included_diffs if d.patch_text]
diff_text = "\n".join(patch_chunks)
else:
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
diff_text = _prompt_diff_text(decision.included_diffs)

render_kwargs: dict[str, str] = {
"diff": diff_text,
Expand All @@ -241,12 +262,8 @@ def run_generation_pipeline(

prompt_text, _ = render_prompt(template_name, **render_kwargs)

cache_hit = False
if not bypass_cache and isinstance(active_provider, CachedProvider):
key = cache_key(prompt_text, cfg.model, active_provider._template_version)
cache_hit = active_provider._cache.get(key, default=None) is not None

resp = active_provider.generate(prompt=prompt_text, model=cfg.model)
cache_hit = isinstance(active_provider, CachedProvider) and active_provider.last_hit
if resp.input_tokens is not None:
total_input_tokens += resp.input_tokens
if resp.output_tokens is not None:
Expand Down
7 changes: 6 additions & 1 deletion src/gitmate/pr_summary.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,11 @@ def generate_pr_summary(

check_budget_cap(active_cfg, console=con)

extractor = diff_extractor if diff_extractor is not None else DiffExtractor(repo=repo_dir)
extractor = (
diff_extractor
if diff_extractor is not None
else DiffExtractor(repo=repo_dir, extra_ignores=active_cfg.ignore_globs)
)
diffs = extractor.branch_comparison(base=base)

if not diffs:
Expand All @@ -115,6 +119,7 @@ def generate_pr_summary(
console=con,
secret_store=secret_store,
bypass_cache=bypass_cache,
check_budget=False, # already checked above
)

if copy_to_cb:
Expand Down
3 changes: 3 additions & 0 deletions src/gitmate/providers/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ def __init__(
self._cache = Cache(resolved)
self._ttl = ttl
self._template_version = template_version
#: True when the most recent generate() was answered from the cache.
self.last_hit = False

@property
def underlying(self) -> LLMProvider:
Expand All @@ -56,6 +58,7 @@ def generate(self, prompt: str, model: str) -> LLMResponse:
"""Return a cached response when present, else generate and store."""
key = cache_key(prompt, model, self._template_version)
cached: LLMResponse | None = self._cache.get(key, default=None)
self.last_hit = cached is not None
if cached is not None:
return cached
response = self._provider.generate(prompt, model)
Expand Down
5 changes: 4 additions & 1 deletion src/gitmate/token_budget.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@
if TYPE_CHECKING:
from google import genai

#: Start of the placeholder that replaces a patch dropped for size.
OMITTED_NOTE_PREFIX = "[patch omitted for size:"


class TokenBudgetError(Exception):
"""Base exception for token budgeting and counting errors."""
Expand Down Expand Up @@ -264,7 +267,7 @@ def assess(
current_tokens = total_tokens

for candidate in candidates:
note = f"[patch omitted for size: +{candidate.additions}/-{candidate.deletions} lines]"
note = f"{OMITTED_NOTE_PREFIX} +{candidate.additions}/-{candidate.deletions} lines]"
if len(candidate.patch_text) <= len(note):
continue
omitted.append(candidate)
Expand Down
47 changes: 47 additions & 0 deletions tests/test_committer.py
Original file line number Diff line number Diff line change
Expand Up @@ -573,3 +573,50 @@ def test_commit_flow_budget_cap_warning_allows_commit(
assert provider.call_count == 1
messages = _get_git_log_messages(git_repo)
assert messages[0] == "feat: allowed commit"


# --- Regression: free-tier spend & Rich markup in the review UI ---


def test_free_tier_commit_records_zero_spend(
git_repo: Path, metrics_dir: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Free-tier commits must not be repriced at paid rates when read back."""
from gitmate.metrics import get_monthly_spend

stage_file(git_repo, "file.txt", b"content\n")
monkeypatch.setattr(sys.stdin, "isatty", lambda: False)
cfg = GitmateConfig(allow_noninteractive_commit=True, free_tier=True)

assert commit_flow(yes=True, cfg=cfg, provider=FakeProvider()) == 0

invocations = get_invocations()
assert invocations[0].free_tier is True
assert get_monthly_spend() == 0.0


def test_preview_and_git_output_render_brackets_literally(
git_repo: Path, metrics_dir: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""`[...]` in the message, menu, and git output must not be eaten as Rich markup."""
stage_file(git_repo, "file.txt", b"content\n")
monkeypatch.setattr(sys.stdin, "isatty", lambda: True)
con = Console(record=True, width=200)
prompts: list[str] = []
inputs = iter(["[/oops]", "a"])

def _input(prompt: str = "") -> str:
prompts.append(prompt)
return next(inputs)

monkeypatch.setattr(con, "input", _input)
message = "fix(cli): handle list[str] input [skip ci]"

assert commit_flow(console=con, provider=FakeProvider(message)) == 0

out = con.export_text()
assert message in out # preview panel
assert "Invalid selection '[/oops]'" in out # no MarkupError
assert "[a]ccept, [e]dit, [r]egenerate, or [c]ancel" in out
assert "[master " in out or "[main " in out # git's "[branch hash]" line
assert _get_git_log_messages(git_repo)[0] == message
Loading
Loading