git-explain 2.2.1__tar.gz → 2.5.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- git_explain-2.5.0/PKG-INFO +122 -0
- git_explain-2.5.0/README.md +89 -0
- git_explain-2.5.0/git_explain/__init__.py +1 -0
- {git_explain-2.2.1 → git_explain-2.5.0}/git_explain/cli.py +179 -42
- {git_explain-2.2.1 → git_explain-2.5.0}/git_explain/gemini.py +131 -78
- {git_explain-2.2.1 → git_explain-2.5.0}/git_explain/heuristics.py +25 -39
- {git_explain-2.2.1 → git_explain-2.5.0}/git_explain/path_topics.py +40 -0
- git_explain-2.5.0/git_explain.egg-info/PKG-INFO +122 -0
- {git_explain-2.2.1 → git_explain-2.5.0}/git_explain.egg-info/SOURCES.txt +2 -0
- {git_explain-2.2.1 → git_explain-2.5.0}/git_explain.egg-info/requires.txt +1 -1
- {git_explain-2.2.1 → git_explain-2.5.0}/pyproject.toml +3 -1
- {git_explain-2.2.1 → git_explain-2.5.0}/tests/test_cli_utils.py +123 -3
- {git_explain-2.2.1 → git_explain-2.5.0}/tests/test_gemini.py +55 -0
- git_explain-2.5.0/tests/test_gemini_suggest_commands.py +127 -0
- git_explain-2.5.0/tests/test_git.py +209 -0
- {git_explain-2.2.1 → git_explain-2.5.0}/tests/test_heuristics.py +17 -0
- git_explain-2.2.1/PKG-INFO +0 -111
- git_explain-2.2.1/README.md +0 -80
- git_explain-2.2.1/git_explain/__init__.py +0 -1
- git_explain-2.2.1/git_explain.egg-info/PKG-INFO +0 -111
- {git_explain-2.2.1 → git_explain-2.5.0}/LICENSE +0 -0
- {git_explain-2.2.1 → git_explain-2.5.0}/git_explain/__main__.py +0 -0
- {git_explain-2.2.1 → git_explain-2.5.0}/git_explain/commit_infer.py +0 -0
- {git_explain-2.2.1 → git_explain-2.5.0}/git_explain/git.py +0 -0
- {git_explain-2.2.1 → git_explain-2.5.0}/git_explain/run.py +0 -0
- {git_explain-2.2.1 → git_explain-2.5.0}/git_explain.egg-info/dependency_links.txt +0 -0
- {git_explain-2.2.1 → git_explain-2.5.0}/git_explain.egg-info/entry_points.txt +0 -0
- {git_explain-2.2.1 → git_explain-2.5.0}/git_explain.egg-info/top_level.txt +0 -0
- {git_explain-2.2.1 → git_explain-2.5.0}/setup.cfg +0 -0
- {git_explain-2.2.1 → git_explain-2.5.0}/tests/test_commit_infer.py +0 -0
- {git_explain-2.2.1 → git_explain-2.5.0}/tests/test_run_apply.py +0 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: git-explain
|
|
3
|
+
Version: 2.5.0
|
|
4
|
+
Summary: CLI that suggests git add/commit from diffs using Gemini
|
|
5
|
+
Author: nazarli-shabnam
|
|
6
|
+
Author-email: shabnamnezerli@gmail.com
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
Project-URL: Homepage, https://github.com/nazarli-shabnam/git-explain
|
|
9
|
+
Project-URL: Source, https://github.com/nazarli-shabnam/git-explain
|
|
10
|
+
Keywords: git,cli,commit,ai,gemini
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Software Development :: Version Control :: Git
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
License-File: LICENSE
|
|
24
|
+
Requires-Dist: google-genai>=1.50.0
|
|
25
|
+
Requires-Dist: typer>=0.12.0
|
|
26
|
+
Requires-Dist: rich>=13.0.0
|
|
27
|
+
Requires-Dist: python-dotenv>=1.0.0
|
|
28
|
+
Requires-Dist: prompt_toolkit>=3.0.0
|
|
29
|
+
Provides-Extra: dev
|
|
30
|
+
Requires-Dist: pytest>=8.0.0; extra == "dev"
|
|
31
|
+
Requires-Dist: ruff==0.12.3; extra == "dev"
|
|
32
|
+
Dynamic: license-file
|
|
33
|
+
|
|
34
|
+
# git-explain
|
|
35
|
+
|
|
36
|
+
Suggests **conventional** `git add` / `git commit` messages from your changes. Uses AI when you configure a key; otherwise uses simple local rules.
|
|
37
|
+
|
|
38
|
+
[](https://pypi.org/project/git-explain/)
|
|
39
|
+
[](https://github.com/nazarli-shabnam/git-explain/tags)
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## Install and upgrade
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
pip install git-explain
|
|
47
|
+
pip install --upgrade git-explain
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Use the second command anytime you want the latest release from PyPI.
|
|
51
|
+
|
|
52
|
+
In a terminal, go to your project folder (the one that contains `.git`) and run:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
git-explain
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
The first time you run it without `AI_MODEL` in `.env`, the tool can create `.env` with a default Gemini model and a link to create an API key.
|
|
59
|
+
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
## Configure (`.env`)
|
|
63
|
+
|
|
64
|
+
Put a file named **`.env` in the repo root** (next to `.git`). Typical variables:
|
|
65
|
+
|
|
66
|
+
| Variable | Role |
|
|
67
|
+
|----------|------|
|
|
68
|
+
| `AI_MODEL` | Gemini model id, e.g. `gemini-2.5-flash`. Set on first run if missing. |
|
|
69
|
+
| `AI_API_KEY` | From [Google AI Studio](https://aistudio.google.com/apikey). |
|
|
70
|
+
| `AI_MODEL_FALLBACKS` | Optional: comma-separated backup models, tried **in order** after `AI_MODEL` on retryable busy/rate-limit errors. If you omit this variable, the tool uses the **default fallbacks** below. |
|
|
71
|
+
|
|
72
|
+
**Default `AI_MODEL_FALLBACKS` (when the variable is unset):** `gemini-2.5-flash-lite`, then `gemini-3-flash-preview` — each is tried in sequence after a failed attempt on the previous model in the chain (starting from `AI_MODEL`).
|
|
73
|
+
|
|
74
|
+
If `AI_API_KEY` is empty, **`GEMINI_API_KEY`** is still read (same key, older name).
|
|
75
|
+
|
|
76
|
+
---
|
|
77
|
+
|
|
78
|
+
## Flags
|
|
79
|
+
|
|
80
|
+
| | |
|
|
81
|
+
|--|--|
|
|
82
|
+
| `--auto` | Apply suggested commands without a confirmation prompt. |
|
|
83
|
+
| `--staged-only` | Work with staged changes only (no `git add` from the tool). |
|
|
84
|
+
| `--cwd` | Use another directory as the git repo root. |
|
|
85
|
+
| `--model` | Override the AI model for this run (defaults to `AI_MODEL` from the repo `.env`). |
|
|
86
|
+
| `--with-diff` | Send the full diff to the AI (more context). |
|
|
87
|
+
| `--suggest` | Print one suggested `git commit -m "…"` line (staged, AI only). |
|
|
88
|
+
|
|
89
|
+
If you pick **more than one changed file**, you can choose **one** commit or **split** into several (split is not available with `--staged-only`). **Enter** applies the suggestion; **n** skips so you can copy instead.
|
|
90
|
+
|
|
91
|
+
Commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`feat:`, `fix:`, optional scope, etc.).
|
|
92
|
+
|
|
93
|
+
---
|
|
94
|
+
|
|
95
|
+
## When AI fails
|
|
96
|
+
|
|
97
|
+
Wrong key, bad model name, network issues, or quota errors → the tool falls back to local heuristics and shows a warning. On retryable busy/rate-limit errors it steps through the fallback chain: your `AI_MODEL` first, then the models in `AI_MODEL_FALLBACKS` (or the **default** `gemini-2.5-flash-lite` → `gemini-3-flash-preview` list if that variable is unset).
|
|
98
|
+
|
|
99
|
+
---
|
|
100
|
+
|
|
101
|
+
## Install a specific version from GitHub
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
pip install "git+https://github.com/nazarli-shabnam/git-explain.git@v2.3.0"
|
|
105
|
+
pip install "git+https://github.com/nazarli-shabnam/git-explain.git@v2.4.0"
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Replace `v2.3.0` with the [tag](https://github.com/nazarli-shabnam/git-explain/tags) you want.
|
|
109
|
+
|
|
110
|
+
---
|
|
111
|
+
|
|
112
|
+
## Develop
|
|
113
|
+
|
|
114
|
+
From a clone of this repo:
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
pip install -r requirements.txt
|
|
118
|
+
python -m git_explain
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Contributors: `pip install -e ".[dev]"` then `pytest -q`, `ruff check .`, `ruff format --check .`.
|
|
122
|
+
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# git-explain
|
|
2
|
+
|
|
3
|
+
Suggests **conventional** `git add` / `git commit` messages from your changes. Uses AI when you configure a key; otherwise uses simple local rules.
|
|
4
|
+
|
|
5
|
+
[](https://pypi.org/project/git-explain/)
|
|
6
|
+
[](https://github.com/nazarli-shabnam/git-explain/tags)
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## Install and upgrade
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
pip install git-explain
|
|
14
|
+
pip install --upgrade git-explain
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Use the second command anytime you want the latest release from PyPI.
|
|
18
|
+
|
|
19
|
+
In a terminal, go to your project folder (the one that contains `.git`) and run:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
git-explain
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
The first time you run it without `AI_MODEL` in `.env`, the tool can create `.env` with a default Gemini model and a link to create an API key.
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## Configure (`.env`)
|
|
30
|
+
|
|
31
|
+
Put a file named **`.env` in the repo root** (next to `.git`). Typical variables:
|
|
32
|
+
|
|
33
|
+
| Variable | Role |
|
|
34
|
+
|----------|------|
|
|
35
|
+
| `AI_MODEL` | Gemini model id, e.g. `gemini-2.5-flash`. Set on first run if missing. |
|
|
36
|
+
| `AI_API_KEY` | From [Google AI Studio](https://aistudio.google.com/apikey). |
|
|
37
|
+
| `AI_MODEL_FALLBACKS` | Optional: comma-separated backup models, tried **in order** after `AI_MODEL` on retryable busy/rate-limit errors. If you omit this variable, the tool uses the **default fallbacks** below. |
|
|
38
|
+
|
|
39
|
+
**Default `AI_MODEL_FALLBACKS` (when the variable is unset):** `gemini-2.5-flash-lite`, then `gemini-3-flash-preview` — each is tried in sequence after a failed attempt on the previous model in the chain (starting from `AI_MODEL`).
|
|
40
|
+
|
|
41
|
+
If `AI_API_KEY` is empty, **`GEMINI_API_KEY`** is still read (same key, older name).
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
## Flags
|
|
46
|
+
|
|
47
|
+
| | |
|
|
48
|
+
|--|--|
|
|
49
|
+
| `--auto` | Apply suggested commands without a confirmation prompt. |
|
|
50
|
+
| `--staged-only` | Work with staged changes only (no `git add` from the tool). |
|
|
51
|
+
| `--cwd` | Use another directory as the git repo root. |
|
|
52
|
+
| `--model` | Override the AI model for this run (defaults to `AI_MODEL` from the repo `.env`). |
|
|
53
|
+
| `--with-diff` | Send the full diff to the AI (more context). |
|
|
54
|
+
| `--suggest` | Print one suggested `git commit -m "…"` line (staged, AI only). |
|
|
55
|
+
|
|
56
|
+
If you pick **more than one changed file**, you can choose **one** commit or **split** into several (split is not available with `--staged-only`). **Enter** applies the suggestion; **n** skips so you can copy instead.
|
|
57
|
+
|
|
58
|
+
Commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`feat:`, `fix:`, optional scope, etc.).
|
|
59
|
+
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
## When AI fails
|
|
63
|
+
|
|
64
|
+
Wrong key, bad model name, network issues, or quota errors → the tool falls back to local heuristics and shows a warning. On retryable busy/rate-limit errors it steps through the fallback chain: your `AI_MODEL` first, then the models in `AI_MODEL_FALLBACKS` (or the **default** `gemini-2.5-flash-lite` → `gemini-3-flash-preview` list if that variable is unset).
|
|
65
|
+
|
|
66
|
+
---
|
|
67
|
+
|
|
68
|
+
## Install a specific version from GitHub
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
pip install "git+https://github.com/nazarli-shabnam/git-explain.git@v2.3.0"
|
|
72
|
+
pip install "git+https://github.com/nazarli-shabnam/git-explain.git@v2.4.0"
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Replace `v2.3.0` with the [tag](https://github.com/nazarli-shabnam/git-explain/tags) you want.
|
|
76
|
+
|
|
77
|
+
---
|
|
78
|
+
|
|
79
|
+
## Develop
|
|
80
|
+
|
|
81
|
+
From a clone of this repo:
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
pip install -r requirements.txt
|
|
85
|
+
python -m git_explain
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Contributors: `pip install -e ".[dev]"` then `pytest -q`, `ruff check .`, `ruff format --check .`.
|
|
89
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "2.5.0"
|
|
@@ -1,18 +1,23 @@
|
|
|
1
1
|
"""CLI for git-explain: suggest and optionally apply commit message from diffs."""
|
|
2
2
|
|
|
3
|
+
import os
|
|
3
4
|
import re
|
|
4
5
|
import subprocess
|
|
5
6
|
from dataclasses import dataclass, replace
|
|
6
7
|
from pathlib import Path
|
|
8
|
+
from collections.abc import Callable
|
|
7
9
|
from typing import Iterable
|
|
8
10
|
|
|
11
|
+
import httpx
|
|
12
|
+
import requests
|
|
9
13
|
import typer
|
|
10
|
-
from dotenv import
|
|
14
|
+
from dotenv import dotenv_values
|
|
15
|
+
from google.genai import errors as genai_errors
|
|
11
16
|
from rich.console import Console
|
|
12
17
|
from rich.panel import Panel
|
|
13
18
|
from rich.text import Text
|
|
14
19
|
|
|
15
|
-
from git_explain.gemini import Suggestion, suggest_commands
|
|
20
|
+
from git_explain.gemini import DEFAULT_MODEL, Suggestion, suggest_commands
|
|
16
21
|
from git_explain.heuristics import suggest_from_changes
|
|
17
22
|
from git_explain.git import (
|
|
18
23
|
get_combined_diff,
|
|
@@ -25,11 +30,70 @@ from git_explain.run import (
|
|
|
25
30
|
normalize_commit_subject_for_dash_m,
|
|
26
31
|
)
|
|
27
32
|
|
|
28
|
-
load_dotenv()
|
|
29
33
|
app = typer.Typer()
|
|
30
34
|
console = Console()
|
|
31
35
|
|
|
32
36
|
_DIFF_INFER_MAX_CHARS = 50_000
|
|
37
|
+
_AI_ENV_KEYS = (
|
|
38
|
+
"AI_MODEL",
|
|
39
|
+
"AI_API_KEY",
|
|
40
|
+
"GEMINI_API_KEY",
|
|
41
|
+
"AI_MODEL_FALLBACKS",
|
|
42
|
+
)
|
|
43
|
+
# Terminal hyperlinks (OSC 8) for first-run setup — Ctrl+click in supported terminals.
|
|
44
|
+
_GOOGLE_AI_API_KEY_URL = "https://aistudio.google.com/apikey"
|
|
45
|
+
|
|
46
|
+
# Expected failure modes from an AI call: missing key / unparseable response
|
|
47
|
+
# (RuntimeError), Gemini API errors, and network-level failures. Anything else
|
|
48
|
+
# is a real bug and should not be silently treated as "AI unavailable".
|
|
49
|
+
_AI_CALL_ERRORS = (
|
|
50
|
+
RuntimeError,
|
|
51
|
+
genai_errors.APIError,
|
|
52
|
+
httpx.HTTPError,
|
|
53
|
+
requests.exceptions.RequestException,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _gemini_fallback_notifier(
|
|
58
|
+
group_label: str | None = None,
|
|
59
|
+
) -> Callable[[str], None]:
|
|
60
|
+
"""Dim one-line notices when switching models after rate limits / overload.
|
|
61
|
+
|
|
62
|
+
``group_label`` prefixes the line in split-commit mode (one AI call per group),
|
|
63
|
+
so repeated "primary busy" messages are distinguishable.
|
|
64
|
+
"""
|
|
65
|
+
first: list[bool] = [True]
|
|
66
|
+
prefix = f"{group_label}: " if group_label else ""
|
|
67
|
+
|
|
68
|
+
def _notify(next_model: str) -> None:
|
|
69
|
+
if first[0]:
|
|
70
|
+
console.print(
|
|
71
|
+
Text(
|
|
72
|
+
f"{prefix}Primary model busy; trying fallback: {next_model}",
|
|
73
|
+
style="dim",
|
|
74
|
+
)
|
|
75
|
+
)
|
|
76
|
+
first[0] = False
|
|
77
|
+
else:
|
|
78
|
+
console.print(
|
|
79
|
+
Text(f"{prefix}Model busy; trying fallback: {next_model}", style="dim")
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
return _notify
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _model_picker_line(
|
|
86
|
+
num: int, label: str, link_url: str, model_id: str | None = None
|
|
87
|
+
) -> Text:
|
|
88
|
+
"""OSC 8 link on `label`; optional cyan tail in parentheses (e.g. model id)."""
|
|
89
|
+
line = Text()
|
|
90
|
+
line.append(f" {num}. ")
|
|
91
|
+
line.append(label, style=f"link {link_url}")
|
|
92
|
+
if model_id:
|
|
93
|
+
line.append(" (")
|
|
94
|
+
line.append(model_id, style="cyan")
|
|
95
|
+
line.append(")")
|
|
96
|
+
return line
|
|
33
97
|
|
|
34
98
|
|
|
35
99
|
@dataclass(frozen=True)
|
|
@@ -89,6 +153,87 @@ def _parse_combined(combined: str) -> tuple[bool | None, list[Change]]:
|
|
|
89
153
|
return has_commits, changes
|
|
90
154
|
|
|
91
155
|
|
|
156
|
+
def _load_ai_env_from_dotenv(dotenv_path: Path) -> None:
|
|
157
|
+
"""Load only AI-related vars from .env, overriding existing process values."""
|
|
158
|
+
values = dotenv_values(dotenv_path)
|
|
159
|
+
for key in _AI_ENV_KEYS:
|
|
160
|
+
raw = values.get(key)
|
|
161
|
+
if raw is None:
|
|
162
|
+
continue
|
|
163
|
+
val = str(raw).strip()
|
|
164
|
+
if val:
|
|
165
|
+
os.environ[key] = val
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _upsert_env_var(dotenv_path: Path, key: str, value: str) -> None:
|
|
169
|
+
lines: list[str] = []
|
|
170
|
+
if dotenv_path.exists():
|
|
171
|
+
lines = dotenv_path.read_text(encoding="utf-8").splitlines()
|
|
172
|
+
replaced = False
|
|
173
|
+
out: list[str] = []
|
|
174
|
+
prefix = key + "="
|
|
175
|
+
for ln in lines:
|
|
176
|
+
if ln.startswith(prefix):
|
|
177
|
+
out.append(f"{key}={value}")
|
|
178
|
+
replaced = True
|
|
179
|
+
else:
|
|
180
|
+
out.append(ln)
|
|
181
|
+
if not replaced:
|
|
182
|
+
if out and out[-1].strip():
|
|
183
|
+
out.append("")
|
|
184
|
+
out.append(f"{key}={value}")
|
|
185
|
+
dotenv_path.write_text("\n".join(out) + "\n", encoding="utf-8")
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _ensure_repo_env_file(repo_env: Path) -> bool:
|
|
189
|
+
if repo_env.is_file():
|
|
190
|
+
return True
|
|
191
|
+
create = (
|
|
192
|
+
typer.prompt("No .env found. Create one now? (y/n)", default="y")
|
|
193
|
+
.strip()
|
|
194
|
+
.lower()
|
|
195
|
+
)
|
|
196
|
+
if create not in ("y", "yes"):
|
|
197
|
+
return False
|
|
198
|
+
repo_env.write_text("", encoding="utf-8")
|
|
199
|
+
return True
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _choose_and_persist_ai_model(repo_env: Path) -> str:
|
|
203
|
+
"""First run: set default Gemini model and reload .env for API keys."""
|
|
204
|
+
console.print(
|
|
205
|
+
Text(
|
|
206
|
+
"Add your API key to .env (create one in Google AI Studio if needed):",
|
|
207
|
+
style="dim",
|
|
208
|
+
)
|
|
209
|
+
)
|
|
210
|
+
console.print(
|
|
211
|
+
_model_picker_line(
|
|
212
|
+
1,
|
|
213
|
+
"Google AI Studio",
|
|
214
|
+
_GOOGLE_AI_API_KEY_URL,
|
|
215
|
+
model_id=DEFAULT_MODEL,
|
|
216
|
+
)
|
|
217
|
+
)
|
|
218
|
+
model = DEFAULT_MODEL
|
|
219
|
+
_upsert_env_var(repo_env, "AI_MODEL", model)
|
|
220
|
+
os.environ["AI_MODEL"] = model
|
|
221
|
+
if repo_env.is_file():
|
|
222
|
+
_load_ai_env_from_dotenv(repo_env)
|
|
223
|
+
return model
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _resolve_project_ai_model(repo_env: Path, model_override: str | None) -> str | None:
|
|
227
|
+
if model_override:
|
|
228
|
+
return model_override
|
|
229
|
+
model = (os.environ.get("AI_MODEL") or "").strip()
|
|
230
|
+
if model:
|
|
231
|
+
return model
|
|
232
|
+
if not _ensure_repo_env_file(repo_env):
|
|
233
|
+
return None
|
|
234
|
+
return _choose_and_persist_ai_model(repo_env)
|
|
235
|
+
|
|
236
|
+
|
|
92
237
|
def _render_combined(
|
|
93
238
|
has_commits: bool | None, items: Iterable[tuple[str, str]], title: str
|
|
94
239
|
) -> str:
|
|
@@ -199,7 +344,6 @@ def _validate_suggest_flags(
|
|
|
199
344
|
*,
|
|
200
345
|
suggest: bool,
|
|
201
346
|
auto: bool,
|
|
202
|
-
ai: bool,
|
|
203
347
|
staged_only: bool,
|
|
204
348
|
model: str | None,
|
|
205
349
|
with_diff: bool,
|
|
@@ -209,14 +353,10 @@ def _validate_suggest_flags(
|
|
|
209
353
|
bad: list[str] = []
|
|
210
354
|
if auto:
|
|
211
355
|
bad.append("--auto")
|
|
212
|
-
if ai:
|
|
213
|
-
bad.append("--ai")
|
|
214
356
|
if staged_only:
|
|
215
357
|
bad.append("--staged-only")
|
|
216
358
|
if with_diff:
|
|
217
359
|
bad.append("--with-diff")
|
|
218
|
-
if model is not None:
|
|
219
|
-
bad.append("--model")
|
|
220
360
|
if bad:
|
|
221
361
|
raise typer.BadParameter(
|
|
222
362
|
"--suggest is a dedicated mode and cannot be combined with: "
|
|
@@ -230,9 +370,6 @@ def main(
|
|
|
230
370
|
auto: bool = typer.Option(
|
|
231
371
|
False, "--auto", help="Apply suggestion without prompting"
|
|
232
372
|
),
|
|
233
|
-
ai: bool = typer.Option(
|
|
234
|
-
False, "--ai", help="Use Gemini to suggest commit message (default: off)"
|
|
235
|
-
),
|
|
236
373
|
staged_only: bool = typer.Option(
|
|
237
374
|
False,
|
|
238
375
|
"--staged-only",
|
|
@@ -244,15 +381,12 @@ def main(
|
|
|
244
381
|
model: str | None = typer.Option(
|
|
245
382
|
None,
|
|
246
383
|
"--model",
|
|
247
|
-
help=(
|
|
248
|
-
"Override Gemini model name for --ai "
|
|
249
|
-
"(defaults to GEMINI_MODEL env var or internal default)."
|
|
250
|
-
),
|
|
384
|
+
help="Override AI model for this run (defaults to AI_MODEL from repo .env).",
|
|
251
385
|
),
|
|
252
386
|
with_diff: bool = typer.Option(
|
|
253
387
|
False,
|
|
254
388
|
"--with-diff",
|
|
255
|
-
help="
|
|
389
|
+
help="Send full diff to the configured AI model for more specific messages (opt-in).",
|
|
256
390
|
),
|
|
257
391
|
suggest: bool = typer.Option(
|
|
258
392
|
False,
|
|
@@ -265,7 +399,6 @@ def main(
|
|
|
265
399
|
_validate_suggest_flags(
|
|
266
400
|
suggest=suggest,
|
|
267
401
|
auto=auto,
|
|
268
|
-
ai=ai,
|
|
269
402
|
staged_only=staged_only,
|
|
270
403
|
model=model,
|
|
271
404
|
with_diff=with_diff,
|
|
@@ -273,7 +406,6 @@ def main(
|
|
|
273
406
|
run(
|
|
274
407
|
cwd=Path(cwd) if cwd else None,
|
|
275
408
|
auto=auto,
|
|
276
|
-
ai=ai,
|
|
277
409
|
staged_only=staged_only,
|
|
278
410
|
model=model,
|
|
279
411
|
with_diff=with_diff,
|
|
@@ -284,32 +416,29 @@ def main(
|
|
|
284
416
|
def run(
|
|
285
417
|
cwd: Path | None = None,
|
|
286
418
|
auto: bool = False,
|
|
287
|
-
ai: bool = False,
|
|
288
419
|
staged_only: bool = False,
|
|
289
420
|
model: str | None = None,
|
|
290
421
|
with_diff: bool = False,
|
|
291
422
|
suggest: bool = False,
|
|
292
423
|
) -> None:
|
|
293
424
|
console.print(Text("git-explain", style="bold"))
|
|
294
|
-
if with_diff and not ai:
|
|
295
|
-
console.print(
|
|
296
|
-
"[yellow]Warning:[/yellow] --with-diff has no effect without --ai. "
|
|
297
|
-
"It only affects AI-generated commit messages."
|
|
298
|
-
)
|
|
299
|
-
enable = typer.prompt("Enable AI? (y/n)", default="n").strip().lower()
|
|
300
|
-
if enable in ("y", "yes"):
|
|
301
|
-
ai = True
|
|
302
|
-
else:
|
|
303
|
-
with_diff = False
|
|
304
425
|
|
|
305
426
|
try:
|
|
306
427
|
combined, repo_root = get_combined_diff(cwd=cwd)
|
|
307
428
|
except RuntimeError as e:
|
|
308
429
|
console.print(f"[red]Error:[/red] {e}")
|
|
309
430
|
raise typer.Exit(1)
|
|
431
|
+
|
|
432
|
+
repo_env = repo_root / ".env"
|
|
433
|
+
if repo_env.is_file():
|
|
434
|
+
_load_ai_env_from_dotenv(repo_env)
|
|
435
|
+
|
|
310
436
|
if not combined.strip():
|
|
311
437
|
console.print("[yellow]No staged, unstaged, or untracked changes.[/yellow]")
|
|
312
438
|
return
|
|
439
|
+
ai_model = _resolve_project_ai_model(repo_env, model)
|
|
440
|
+
if repo_env.is_file():
|
|
441
|
+
_load_ai_env_from_dotenv(repo_env)
|
|
313
442
|
has_commits, changes = _parse_combined(combined)
|
|
314
443
|
console.print(Panel(combined, title="Changed files", border_style="dim"))
|
|
315
444
|
|
|
@@ -332,12 +461,19 @@ def run(
|
|
|
332
461
|
if len(staged_diff) > _DIFF_INFER_MAX_CHARS
|
|
333
462
|
else staged_diff
|
|
334
463
|
)
|
|
464
|
+
if not ai_model:
|
|
465
|
+
console.print(
|
|
466
|
+
"[red]Error:[/red] --suggest requires an AI model. "
|
|
467
|
+
"Set AI_MODEL in repo .env or pass --model."
|
|
468
|
+
)
|
|
469
|
+
raise typer.Exit(1)
|
|
335
470
|
try:
|
|
336
471
|
sug, _raw = suggest_commands(
|
|
337
472
|
payload,
|
|
338
|
-
model=
|
|
473
|
+
model=ai_model,
|
|
339
474
|
with_diff=bool(staged_diff),
|
|
340
475
|
unified_diff_for_infer=infer_diff,
|
|
476
|
+
fallback_notifier=_gemini_fallback_notifier(),
|
|
341
477
|
)
|
|
342
478
|
if sug is None:
|
|
343
479
|
raise RuntimeError("Could not parse AI suggestion.")
|
|
@@ -436,7 +572,7 @@ def run(
|
|
|
436
572
|
else raw_d
|
|
437
573
|
)
|
|
438
574
|
|
|
439
|
-
if
|
|
575
|
+
if ai_model:
|
|
440
576
|
payload = _render_combined(has_commits, change_items, title=title)
|
|
441
577
|
if with_diff:
|
|
442
578
|
paths_for_diff = [p for _, p in change_items]
|
|
@@ -444,16 +580,18 @@ def run(
|
|
|
444
580
|
if diff_text:
|
|
445
581
|
payload = payload + "\n\n## Diff\n" + diff_text
|
|
446
582
|
try:
|
|
583
|
+
fb_label = title if mode == "split" else None
|
|
447
584
|
sug, _raw = suggest_commands(
|
|
448
585
|
payload,
|
|
449
|
-
model=
|
|
586
|
+
model=ai_model,
|
|
450
587
|
with_diff=with_diff,
|
|
451
588
|
unified_diff_for_infer=infer_diff,
|
|
589
|
+
fallback_notifier=_gemini_fallback_notifier(fb_label),
|
|
452
590
|
)
|
|
453
591
|
if sug is None:
|
|
454
592
|
raise RuntimeError("Could not parse AI suggestion.")
|
|
455
593
|
return sug, None
|
|
456
|
-
except
|
|
594
|
+
except _AI_CALL_ERRORS as e:
|
|
457
595
|
h = suggest_from_changes(
|
|
458
596
|
changes=change_items,
|
|
459
597
|
has_commits=has_commits,
|
|
@@ -500,9 +638,10 @@ def run(
|
|
|
500
638
|
if fb:
|
|
501
639
|
ai_fallback_notes.append(("", fb))
|
|
502
640
|
|
|
503
|
-
if
|
|
641
|
+
if ai_model and ai_fallback_notes:
|
|
642
|
+
key_help = "Check AI_API_KEY, AI_MODEL, AI_MODEL_FALLBACKS, quota/model availability, and network."
|
|
504
643
|
lines = [
|
|
505
|
-
"[bold]
|
|
644
|
+
"[bold]Configured AI was not used for the suggestion below.[/bold]",
|
|
506
645
|
"Commit message(s) come from [bold]local heuristics[/bold] instead.",
|
|
507
646
|
"",
|
|
508
647
|
]
|
|
@@ -512,9 +651,7 @@ def run(
|
|
|
512
651
|
else:
|
|
513
652
|
lines.append(ai_fallback_notes[0][1])
|
|
514
653
|
lines.append("")
|
|
515
|
-
lines.append(
|
|
516
|
-
"[dim]Check API key (GEMINI_API_KEY / GOOGLE_API_KEY), quota, model name, and network.[/dim]"
|
|
517
|
-
)
|
|
654
|
+
lines.append(f"[dim]{key_help}[/dim]")
|
|
518
655
|
console.print(
|
|
519
656
|
Panel(
|
|
520
657
|
"\n".join(lines),
|
|
@@ -595,12 +732,12 @@ def run(
|
|
|
595
732
|
do_apply = True
|
|
596
733
|
else:
|
|
597
734
|
prompt = (
|
|
598
|
-
"Apply these commit(s)? (y/n
|
|
735
|
+
"Apply these commit(s)? (y/n)"
|
|
599
736
|
if len(plan) > 1
|
|
600
|
-
else "Apply these commands? (y/n
|
|
737
|
+
else "Apply these commands? (y/n)"
|
|
601
738
|
)
|
|
602
|
-
choice = typer.prompt(prompt, default="
|
|
603
|
-
do_apply = choice
|
|
739
|
+
choice = typer.prompt(prompt, default="y").strip().lower()
|
|
740
|
+
do_apply = choice in ("y", "yes")
|
|
604
741
|
|
|
605
742
|
if do_apply:
|
|
606
743
|
for name, sug in plan:
|