diff --git a/README.md b/README.md index 0500c20..1ee7ebb 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # git-explain -**Commit message block?** Run this in your repo after you change files. It suggests `git add` and `git commit` lines you can copy—or apply in one step if you want. **Nothing leaves your machine** unless you turn on AI. +Suggest **conventional** `git add` / `git commit` messages from your changes. **Local heuristics by default** (no network); add **`--ai`** for Google Gemini. [![PyPI](https://img.shields.io/pypi/v/git-explain.svg?label=pypi)](https://pypi.org/project/git-explain/) [![GitHub tag](https://img.shields.io/github/v/tag/nazarli-shabnam/git-explain?label=repo)](https://github.com/nazarli-shabnam/git-explain/tags) @@ -8,75 +8,51 @@ --- -## Install (Python 3.10+) +## Install & run ```bash pip install git-explain +cd /path/to/your/git/repo # repo with local changes +git-explain ``` -**From source** (this repo): +**Using `--ai`?** Put **`GEMINI_API_KEY=…`** (or **`GOOGLE_API_KEY`**) in a **`.env` file at your project’s git root** — the top of the repo you’re working in, not the folder where `git-explain` is installed. -```bash -pip install -e . -``` - -**Clone:** install deps (`requirements.txt` is fine), `cd` into the repo, then: - -```bash -python -m git_explain -``` - -Run that from the repo root so Python picks up the `git_explain` folder—no `pip install -e .` needed. - -Optional: install a specific tag from GitHub instead of PyPI: - -```bash -pip install "git+https://github.com/nazarli-shabnam/git-explain.git@v2.2.1" -``` +**Enter** applies the suggested commands; **n** skips (copy only). Pin a release from GitHub: +`pip install "git+https://github.com/nazarli-shabnam/git-explain.git@v2.3.0"` (swap the tag as needed). --- -## Try it - -1. In any git repo, change or add a file (not ignored). -2. Run: - ```bash - git-explain - ``` -3. Choose what to include (`all` is fine), read the suggestion, answer **`n`** if you only want to copy commands yourself—nothing bad happens. +## Flags, keys, and AI -Heuristics guess a sensible type and message from paths and statuses. **No account, no key, no network** for that path. +| | | +|--|--| +| **Conventional commits** | `feat:`, `fix:`, optional `(scope)`, etc. — see [spec](https://www.conventionalcommits.org/). | +| **`.env`** | `GEMINI_API_KEY` or `GOOGLE_API_KEY` in **`.env` at that repo’s git root** (loaded after the repo is resolved). | +| **Shell (one session)** | Set the variable in the terminal; it overrides `.env` for that window. PowerShell: `$env:GEMINI_API_KEY="…"` then `git-explain --ai`. bash/zsh: `export GEMINI_API_KEY="…"`. | +| **`--auto`** | Apply without the apply prompt. | +| **`--staged-only`** | Commit the index only (no `git add` from the tool). | +| **`--cwd`** | Treat another directory as the git repo root. | -Suggested commits follow **[Conventional Commits](https://www.conventionalcommits.org/)**—`feat: …`, `fix: …`, optional `(scope)`, and so on—so changelogs and release tools can read them. +**`--ai`** — model sees paths + status. **`--ai --with-diff`** — also sends the diff (more detail, data goes to the API). **`--suggest`** — staged + AI only: prints one `git commit -m "…"` line (no other flags). More: **`git-explain --help`**. --- -## Optional: Gemini - -If you want sharper messages, set **`GEMINI_API_KEY`** (or `GOOGLE_API_KEY`) in the environment or a **`.env`** file in the folder where you run the tool. - -| Command | In plain terms | -|--------|----------------| -| `git-explain --ai` | AI sees **paths and change type** only (no file contents). | -| `git-explain --ai --with-diff` | AI also sees the **diff**—better detail; only use if you’re OK sending that to the API. | -| `git-explain --suggest` | **Staged only**; prints one plain `git commit -m "…"` line (easy to copy). Needs AI; don’t combine with other flags. | +## If Gemini errors -Everything else (`--auto`, `--staged-only`, `--cwd`, model override, shell completion): **`git-explain --help`**. +- **429 / quota** — [rate limits](https://ai.google.dev/gemini-api/docs/rate-limits). +- **404 / model** — e.g. **`GEMINI_MODEL=gemini-2.5-flash`**; [model list](https://ai.google.dev/api/models). --- -## If Gemini complains +## Develop -- **429 / quota** — wait a bit, or try the default model; see Google’s [rate limits](https://ai.google.dev/gemini-api/docs/rate-limits). -- **404 / model not found** — set something current, e.g. **`GEMINI_MODEL=gemini-2.5-flash`**, and check their [model list](https://ai.google.dev/api/models). - ---- - -## Developers +**Smoke-test a branch:** clone, `pip install -r requirements.txt`, run **`python -m git_explain`** from any git working tree (no `pip install -e .`). **Day-to-day hacking:** `pip install -e ".[dev]"` then `pytest -q`, `ruff check .`, `ruff format --check .`. ```bash -pip install -e ".[dev]" -pytest -q +cd path/to/git-explain +pip install -r requirements.txt +python -m git_explain ``` ## GitAds Sponsored diff --git a/git_explain/__init__.py b/git_explain/__init__.py index b19ee4b..55e4709 100644 --- a/git_explain/__init__.py +++ b/git_explain/__init__.py @@ -1 +1 @@ -__version__ = "2.2.1" +__version__ = "2.3.0" diff --git a/git_explain/cli.py b/git_explain/cli.py index 1cd47b9..dfe991d 100644 --- a/git_explain/cli.py +++ b/git_explain/cli.py @@ -1,5 +1,6 @@ """CLI for git-explain: suggest and optionally apply commit message from diffs.""" +import os import re import subprocess from dataclasses import dataclass, replace @@ -7,12 +8,15 @@ from typing import Iterable import typer -from dotenv import load_dotenv +from dotenv import dotenv_values from rich.console import Console from rich.panel import Panel from rich.text import Text -from git_explain.gemini import Suggestion, suggest_commands +from git_explain.gemini import ( + Suggestion, + suggest_commands, +) from git_explain.heuristics import suggest_from_changes from git_explain.git import ( get_combined_diff, @@ -25,11 +29,11 @@ normalize_commit_subject_for_dash_m, ) -load_dotenv() app = typer.Typer() console = Console() _DIFF_INFER_MAX_CHARS = 50_000 +_AI_ENV_KEYS = ("GEMINI_API_KEY", "GOOGLE_API_KEY", "GEMINI_MODEL") @dataclass(frozen=True) @@ -89,6 +93,18 @@ def _parse_combined(combined: str) -> tuple[bool | None, list[Change]]: return has_commits, changes +def _load_ai_env_from_dotenv(dotenv_path: Path) -> None: + """Load only AI-related vars from .env, overriding existing process values.""" + values = dotenv_values(dotenv_path) + for key in _AI_ENV_KEYS: + raw = values.get(key) + if raw is None: + continue + val = str(raw).strip() + if val: + os.environ[key] = val + + def _render_combined( has_commits: bool | None, items: Iterable[tuple[str, str]], title: str ) -> str: @@ -307,6 +323,11 @@ def run( except RuntimeError as e: console.print(f"[red]Error:[/red] {e}") raise typer.Exit(1) + + repo_env = repo_root / ".env" + if repo_env.is_file(): + _load_ai_env_from_dotenv(repo_env) + if not combined.strip(): console.print("[yellow]No staged, unstaged, or untracked changes.[/yellow]") return @@ -468,37 +489,12 @@ def suggest_for( return h, None selected_pairs = [(ch.status, ch.path) for ch in selected] - unique_paths = {p for _, p in selected_pairs} - - mode = "one" - if len(unique_paths) > 1: - if staged_only: - console.print( - "[dim]Note:[/dim] split commits are not available with --staged-only: " - "each commit would need its own staging, but this mode skips git add. " - "Using a single commit for everything currently staged." - ) - else: - mode_input = ( - typer.prompt("Commit mode: one or split", default="one").strip().lower() - ) - if mode_input in ("one", "split"): - mode = mode_input - plan: list[tuple[str, Suggestion]] = [] ai_fallback_notes: list[tuple[str, str]] = [] - if mode == "split": - groups = _group_changes(selected_pairs) - for gname, items in groups.items(): - sug, fb = suggest_for(items, title=gname.capitalize()) - plan.append((gname, sug)) - if fb: - ai_fallback_notes.append((gname, fb)) - else: - sug, fb = suggest_for(selected_pairs, title="Selected") - plan.append(("one", sug)) - if fb: - ai_fallback_notes.append(("", fb)) + sug, fb = suggest_for(selected_pairs, title="Selected") + plan.append(("one", sug)) + if fb: + ai_fallback_notes.append(("", fb)) if ai and ai_fallback_notes: lines = [ @@ -506,11 +502,7 @@ def suggest_for( "Commit message(s) come from [bold]local heuristics[/bold] instead.", "", ] - if mode == "split": - for gname, reason in ai_fallback_notes: - lines.append(f"[dim]{gname}:[/dim] {reason}") - else: - lines.append(ai_fallback_notes[0][1]) + lines.append(ai_fallback_notes[0][1]) lines.append("") lines.append( "[dim]Check API key (GEMINI_API_KEY / GOOGLE_API_KEY), quota, model name, and network.[/dim]" @@ -595,12 +587,12 @@ def _render_plan(pl: list[tuple[str, Suggestion]]) -> str: do_apply = True else: prompt = ( - "Apply these commit(s)? (y/n/auto)" + "Apply these commit(s)? (y/n)" if len(plan) > 1 - else "Apply these commands? (y/n/auto)" + else "Apply these commands? (y/n)" ) - choice = typer.prompt(prompt, default="n").strip().lower() - do_apply = choice == "auto" or choice in ("y", "yes") + choice = typer.prompt(prompt, default="y").strip().lower() + do_apply = choice in ("y", "yes") if do_apply: for name, sug in plan: diff --git a/git_explain/gemini.py b/git_explain/gemini.py index 8f32599..56f1bb1 100644 --- a/git_explain/gemini.py +++ b/git_explain/gemini.py @@ -2,7 +2,6 @@ import os import re -import time from dataclasses import dataclass from google import genai @@ -480,41 +479,15 @@ def suggest_commands( model = model or os.environ.get("GEMINI_MODEL") or DEFAULT_MODEL system_instruction = SYSTEM_PROMPT_WITH_DIFF if with_diff else SYSTEM_PROMPT client = _get_client() - last_err = None - for attempt in range(2): - try: - response = client.models.generate_content( - model=model, - contents=diff.strip(), - config=types.GenerateContentConfig( - system_instruction=system_instruction, - temperature=0.2, - max_output_tokens=1536 if with_diff else 512, - ), - ) - break - except Exception as e: - last_err = e - err_str = str(e).lower() - if attempt == 0 and ( - "429" in err_str - or "resource_exhausted" in err_str - or "quota" in err_str - ): - wait = 15 - if "retry in " in err_str: - m = re.search( - r"retry in (\d+(?:\.\d+)?)\s*s", err_str, re.IGNORECASE - ) - if m: - wait = min(60, max(5, int(float(m.group(1)) + 1))) - time.sleep(wait) - continue - raise - else: - if last_err is not None: - raise last_err - raise RuntimeError("Unexpected state in suggest_commands") + response = client.models.generate_content( + model=model, + contents=diff.strip(), + config=types.GenerateContentConfig( + system_instruction=system_instruction, + temperature=0.2, + max_output_tokens=1536 if with_diff else 512, + ), + ) text = (response.text or "").strip() raw = text # Strip markdown code block if present diff --git a/git_explain/heuristics.py b/git_explain/heuristics.py index 6921613..90f3b63 100644 --- a/git_explain/heuristics.py +++ b/git_explain/heuristics.py @@ -94,6 +94,11 @@ def _alnum_key(s: str) -> str: return re.sub(r"[^a-z0-9]+", "", (s or "").lower()) +def _path_first_segment(path: str) -> str: + parts = [x for x in path.replace("\\", "/").strip("/").split("/") if x] + return parts[0].lower() if parts else "root" + + def suggest_from_changes( *, changes: list[tuple[str, str]], @@ -172,7 +177,21 @@ def suggest_from_changes( topics.append("config") code_topics = _code_topics(paths) if code_topics: - topics.append(", ".join(code_topics[:5])) + if len(code_topics) > 4: + roots = {_path_first_segment(p) for p in paths} + if len(roots) == 1: + root = next(iter(roots)) + n = len( + [p for p in paths if os.path.splitext(p)[1].lower() in CODE_EXTS] + ) + topics.append(f"{n} modules under {root}") + elif len(roots) == 2: + a, b = sorted(roots) + topics.append(f"{len(paths)} files across {a} and {b}") + else: + topics.append(", ".join(code_topics[:4])) + else: + topics.append(", ".join(code_topics[:5])) # Dedupe while preserving order seen: set[str] = set() diff --git a/pyproject.toml b/pyproject.toml index 0e5967e..d02f0a2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,7 @@ classifiers = [ "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Topic :: Software Development :: Version Control :: Git", ] urls = { Homepage = "https://github.com/nazarli-shabnam/git-explain", Source = "https://github.com/nazarli-shabnam/git-explain" } diff --git a/tests/test_cli_utils.py b/tests/test_cli_utils.py index 0f8d331..f743b63 100644 --- a/tests/test_cli_utils.py +++ b/tests/test_cli_utils.py @@ -1,7 +1,10 @@ +import os + import pytest from git_explain.cli import ( _group_changes, + _load_ai_env_from_dotenv, _parse_combined, _parse_selection, _ps_quote, @@ -29,6 +32,12 @@ def test_parse_selection_ranges() -> None: assert paths == [] +def test_parse_selection_ignores_out_of_range_indices() -> None: + idx, paths = _parse_selection("0,2,7,3-10", 4) + assert idx == [2, 3, 4] + assert paths == [] + + def test_parse_selection_path_tokens() -> None: idx, paths = _parse_selection("main.py", 5) assert idx == [] @@ -132,6 +141,28 @@ def test_group_changes_code_bucket() -> None: assert groups["code"] == [("M", "src/app.ts")] +def test_group_changes_prioritizes_test_bucket_over_code() -> None: + changes = [ + ("M", "tests/foo.spec.ts"), + ("M", "src/auth_test.py"), + ] + groups = _group_changes(changes) + assert "tests" in groups + assert ("M", "tests/foo.spec.ts") in groups["tests"] + assert ("M", "src/auth_test.py") in groups["tests"] + assert "code" not in groups or ("M", "tests/foo.spec.ts") not in groups["code"] + + +def test_group_changes_handles_windows_style_paths() -> None: + changes = [ + ("M", r"tests\test_cli.py"), + ("M", r"git_explain\cli.py"), + ] + groups = _group_changes(changes) + assert ("M", r"tests\test_cli.py") in groups["tests"] + assert ("M", r"git_explain\cli.py") in groups["code"] + + def test_validate_suggest_flags_allows_suggest_alone() -> None: _validate_suggest_flags( suggest=True, @@ -154,3 +185,59 @@ def test_validate_suggest_flags_rejects_combined_flags() -> None: with_diff=False, ) assert "--suggest is a dedicated mode" in str(ex.value) + + +def test_load_ai_env_from_dotenv_only_sets_ai_keys(tmp_path, monkeypatch) -> None: + env_file = tmp_path / ".env" + env_file.write_text( + "GEMINI_API_KEY=from-file\n" + "GOOGLE_API_KEY=from-google\n" + "GEMINI_MODEL=gemini-model\n" + "PATH=should-not-touch\n", + encoding="utf-8", + ) + monkeypatch.delenv("GEMINI_API_KEY", raising=False) + monkeypatch.delenv("GOOGLE_API_KEY", raising=False) + monkeypatch.delenv("GEMINI_MODEL", raising=False) + monkeypatch.setenv("PATH", "existing-path") + + _load_ai_env_from_dotenv(env_file) + + assert os.environ.get("GEMINI_API_KEY") == "from-file" + assert os.environ.get("GOOGLE_API_KEY") == "from-google" + assert os.environ.get("GEMINI_MODEL") == "gemini-model" + assert os.environ.get("PATH") == "existing-path" + + +def test_load_ai_env_from_dotenv_overrides_existing(tmp_path, monkeypatch) -> None: + env_file = tmp_path / ".env" + env_file.write_text( + "GEMINI_API_KEY=from-file\nGOOGLE_API_KEY=from-file\nGEMINI_MODEL=from-file\n", + encoding="utf-8", + ) + monkeypatch.setenv("GEMINI_API_KEY", "existing-gemini") + monkeypatch.setenv("GOOGLE_API_KEY", "existing-google") + monkeypatch.setenv("GEMINI_MODEL", "existing-model") + + _load_ai_env_from_dotenv(env_file) + + assert os.environ.get("GEMINI_API_KEY") == "from-file" + assert os.environ.get("GOOGLE_API_KEY") == "from-file" + assert os.environ.get("GEMINI_MODEL") == "from-file" + + +def test_load_ai_env_from_dotenv_ignores_empty_values(tmp_path, monkeypatch) -> None: + env_file = tmp_path / ".env" + env_file.write_text( + "GEMINI_API_KEY=\nGOOGLE_API_KEY= \nGEMINI_MODEL=\n", + encoding="utf-8", + ) + monkeypatch.setenv("GEMINI_API_KEY", "existing-gemini") + monkeypatch.setenv("GOOGLE_API_KEY", "existing-google") + monkeypatch.setenv("GEMINI_MODEL", "existing-model") + + _load_ai_env_from_dotenv(env_file) + + assert os.environ.get("GEMINI_API_KEY") == "existing-gemini" + assert os.environ.get("GOOGLE_API_KEY") == "existing-google" + assert os.environ.get("GEMINI_MODEL") == "existing-model" diff --git a/tests/test_heuristics.py b/tests/test_heuristics.py index 44da7b5..545228a 100644 --- a/tests/test_heuristics.py +++ b/tests/test_heuristics.py @@ -72,6 +72,23 @@ def test_test_only_paths_get_specific_test_message() -> None: assert "project files" not in m +def test_many_modules_same_root_gets_compact_phrase() -> None: + """Avoid listing every folder when many packages sit under one tree (e.g. api/).""" + s = suggest_from_changes( + changes=[ + ("M", "api/internal/auth/a.go"), + ("M", "api/internal/handler/b.go"), + ("M", "api/internal/model/c.go"), + ("M", "api/internal/router/d.go"), + ("M", "api/internal/config/e.go"), + ("M", "api/internal/oauth/f.go"), + ], + has_commits=True, + ) + m = s.commit_message.lower() + assert "modules under api" in m + + def test_docker_nginx_env_paths_get_specific_build_message() -> None: """Infra paths should not collapse to 'add changes'.""" s = suggest_from_changes(