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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 25 additions & 49 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,82 +1,58 @@
# 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)
<!-- GitAds-Verify: 29ITVVWNRUVU524NJ5ZRR6DSZKIHP3EX -->

---

## 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).
Comment thread
nazarli-shabnam marked this conversation as resolved.

---

## 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
Expand Down
2 changes: 1 addition & 1 deletion git_explain/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "2.2.1"
__version__ = "2.3.0"
74 changes: 33 additions & 41 deletions git_explain/cli.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,22 @@
"""CLI for git-explain: suggest and optionally apply commit message from diffs."""

import os
import re
import subprocess
from dataclasses import dataclass, replace
from pathlib import Path
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,
Expand All @@ -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)
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)

Comment on lines +327 to +330

Copilot AI Apr 13, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new repo_root/.env load is likely ineffective because this module already calls load_dotenv() at import time (which can load a different .env based on the current working directory). Since override=False here, any variables loaded earlier (e.g. from a subdir .env) will prevent the repo-root key from being applied, contradicting the README’s “git root” behavior. Consider removing the import-time load_dotenv() and only loading from repo_root (keeping override=False so shell env still wins).

Copilot uses AI. Check for mistakes.
Comment on lines +327 to +330

Copilot AI Apr 13, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Loading the repo’s .env with load_dotenv() will import all variables from that file into the process environment, which can unexpectedly affect subsequent subprocess calls (e.g., git) beyond just GEMINI_* settings. Since the feature goal is “read API key from .env”, consider reading the file (e.g., via dotenv_values) and only populating GEMINI_API_KEY/GOOGLE_API_KEY (and possibly GEMINI_MODEL) when they aren’t already set.

Copilot uses AI. Check for mistakes.
Comment on lines +327 to +330

Copilot AI Apr 13, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This new .env resolution behavior is subtle (repo_root vs CWD precedence, and “shell overrides .env”). There’s existing pytest coverage for cli helpers, but no test validating that GEMINI_API_KEY is picked up from repo_root/.env and that an already-set environment variable is not overridden. Adding a focused unit/integration test would help prevent regressions.

Copilot uses AI. Check for mistakes.
if not combined.strip():
console.print("[yellow]No staged, unstaged, or untracked changes.[/yellow]")
return
Expand Down Expand Up @@ -468,49 +489,20 @@ 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 = [
"[bold]You used --ai, but Gemini was not used for the suggestion below.[/bold]",
"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]"
Expand Down Expand Up @@ -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()
Comment thread
nazarli-shabnam marked this conversation as resolved.
do_apply = choice in ("y", "yes")

if do_apply:
for name, sug in plan:
Expand Down
45 changes: 9 additions & 36 deletions git_explain/gemini.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

import os
import re
import time
from dataclasses import dataclass

from google import genai
Expand Down Expand Up @@ -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
Expand Down
21 changes: 20 additions & 1 deletion git_explain/heuristics.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]],
Expand Down Expand Up @@ -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()
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down
Loading
Loading