git-explain 2.2.0__tar.gz → 2.4.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.4.0/PKG-INFO +125 -0
- git_explain-2.4.0/README.md +92 -0
- git_explain-2.4.0/git_explain/__init__.py +1 -0
- {git_explain-2.2.0 → git_explain-2.4.0}/git_explain/cli.py +165 -41
- {git_explain-2.2.0 → git_explain-2.4.0}/git_explain/gemini.py +130 -39
- {git_explain-2.2.0 → git_explain-2.4.0}/git_explain/heuristics.py +25 -2
- git_explain-2.4.0/git_explain.egg-info/PKG-INFO +125 -0
- {git_explain-2.2.0 → git_explain-2.4.0}/pyproject.toml +2 -0
- {git_explain-2.2.0 → git_explain-2.4.0}/tests/test_cli_utils.py +123 -3
- {git_explain-2.2.0 → git_explain-2.4.0}/tests/test_gemini.py +55 -0
- {git_explain-2.2.0 → git_explain-2.4.0}/tests/test_heuristics.py +17 -0
- git_explain-2.2.0/PKG-INFO +0 -111
- git_explain-2.2.0/README.md +0 -80
- git_explain-2.2.0/git_explain/__init__.py +0 -1
- git_explain-2.2.0/git_explain.egg-info/PKG-INFO +0 -111
- {git_explain-2.2.0 → git_explain-2.4.0}/LICENSE +0 -0
- {git_explain-2.2.0 → git_explain-2.4.0}/git_explain/__main__.py +0 -0
- {git_explain-2.2.0 → git_explain-2.4.0}/git_explain/commit_infer.py +0 -0
- {git_explain-2.2.0 → git_explain-2.4.0}/git_explain/git.py +0 -0
- {git_explain-2.2.0 → git_explain-2.4.0}/git_explain/path_topics.py +0 -0
- {git_explain-2.2.0 → git_explain-2.4.0}/git_explain/run.py +0 -0
- {git_explain-2.2.0 → git_explain-2.4.0}/git_explain.egg-info/SOURCES.txt +0 -0
- {git_explain-2.2.0 → git_explain-2.4.0}/git_explain.egg-info/dependency_links.txt +0 -0
- {git_explain-2.2.0 → git_explain-2.4.0}/git_explain.egg-info/entry_points.txt +0 -0
- {git_explain-2.2.0 → git_explain-2.4.0}/git_explain.egg-info/requires.txt +0 -0
- {git_explain-2.2.0 → git_explain-2.4.0}/git_explain.egg-info/top_level.txt +0 -0
- {git_explain-2.2.0 → git_explain-2.4.0}/setup.cfg +0 -0
- {git_explain-2.2.0 → git_explain-2.4.0}/tests/test_commit_infer.py +0 -0
- {git_explain-2.2.0 → git_explain-2.4.0}/tests/test_run_apply.py +0 -0
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: git-explain
|
|
3
|
+
Version: 2.4.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.8.0; 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
|
+
<!-- GitAds-Verify: 29ITVVWNRUVU524NJ5ZRR6DSZKIHP3EX -->
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## Install and upgrade
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
pip install git-explain
|
|
48
|
+
pip install --upgrade git-explain
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Use the second command anytime you want the latest release from PyPI.
|
|
52
|
+
|
|
53
|
+
In a terminal, go to your project folder (the one that contains `.git`) and run:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
git-explain
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
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.
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
## Configure (`.env`)
|
|
64
|
+
|
|
65
|
+
Put a file named **`.env` in the repo root** (next to `.git`). Typical variables:
|
|
66
|
+
|
|
67
|
+
| Variable | Role |
|
|
68
|
+
|----------|------|
|
|
69
|
+
| `AI_MODEL` | Gemini model id, e.g. `gemini-2.5-flash`. Set on first run if missing. |
|
|
70
|
+
| `AI_API_KEY` | From [Google AI Studio](https://aistudio.google.com/apikey). |
|
|
71
|
+
| `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. |
|
|
72
|
+
|
|
73
|
+
**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`).
|
|
74
|
+
|
|
75
|
+
If `AI_API_KEY` is empty, **`GEMINI_API_KEY`** is still read (same key, older name).
|
|
76
|
+
|
|
77
|
+
---
|
|
78
|
+
|
|
79
|
+
## Flags
|
|
80
|
+
|
|
81
|
+
| | |
|
|
82
|
+
|--|--|
|
|
83
|
+
| `--auto` | Apply suggested commands without a confirmation prompt. |
|
|
84
|
+
| `--staged-only` | Work with staged changes only (no `git add` from the tool). |
|
|
85
|
+
| `--cwd` | Use another directory as the git repo root. |
|
|
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
|
+
|
|
123
|
+
## GitAds Sponsored
|
|
124
|
+
[](https://gitads.dev/v1/ad-track?source=nazarli-shabnam/git-explain@github)
|
|
125
|
+
|
|
@@ -0,0 +1,92 @@
|
|
|
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
|
+
<!-- GitAds-Verify: 29ITVVWNRUVU524NJ5ZRR6DSZKIHP3EX -->
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## Install and upgrade
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pip install git-explain
|
|
15
|
+
pip install --upgrade git-explain
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Use the second command anytime you want the latest release from PyPI.
|
|
19
|
+
|
|
20
|
+
In a terminal, go to your project folder (the one that contains `.git`) and run:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
git-explain
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
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.
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## Configure (`.env`)
|
|
31
|
+
|
|
32
|
+
Put a file named **`.env` in the repo root** (next to `.git`). Typical variables:
|
|
33
|
+
|
|
34
|
+
| Variable | Role |
|
|
35
|
+
|----------|------|
|
|
36
|
+
| `AI_MODEL` | Gemini model id, e.g. `gemini-2.5-flash`. Set on first run if missing. |
|
|
37
|
+
| `AI_API_KEY` | From [Google AI Studio](https://aistudio.google.com/apikey). |
|
|
38
|
+
| `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. |
|
|
39
|
+
|
|
40
|
+
**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`).
|
|
41
|
+
|
|
42
|
+
If `AI_API_KEY` is empty, **`GEMINI_API_KEY`** is still read (same key, older name).
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
## Flags
|
|
47
|
+
|
|
48
|
+
| | |
|
|
49
|
+
|--|--|
|
|
50
|
+
| `--auto` | Apply suggested commands without a confirmation prompt. |
|
|
51
|
+
| `--staged-only` | Work with staged changes only (no `git add` from the tool). |
|
|
52
|
+
| `--cwd` | Use another directory as the git repo root. |
|
|
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
|
+
|
|
90
|
+
## GitAds Sponsored
|
|
91
|
+
[](https://gitads.dev/v1/ad-track?source=nazarli-shabnam/git-explain@github)
|
|
92
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "2.4.0"
|
|
@@ -1,18 +1,20 @@
|
|
|
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
|
|
|
9
11
|
import typer
|
|
10
|
-
from dotenv import
|
|
12
|
+
from dotenv import dotenv_values
|
|
11
13
|
from rich.console import Console
|
|
12
14
|
from rich.panel import Panel
|
|
13
15
|
from rich.text import Text
|
|
14
16
|
|
|
15
|
-
from git_explain.gemini import Suggestion, suggest_commands
|
|
17
|
+
from git_explain.gemini import DEFAULT_MODEL, Suggestion, suggest_commands
|
|
16
18
|
from git_explain.heuristics import suggest_from_changes
|
|
17
19
|
from git_explain.git import (
|
|
18
20
|
get_combined_diff,
|
|
@@ -25,11 +27,60 @@ from git_explain.run import (
|
|
|
25
27
|
normalize_commit_subject_for_dash_m,
|
|
26
28
|
)
|
|
27
29
|
|
|
28
|
-
load_dotenv()
|
|
29
30
|
app = typer.Typer()
|
|
30
31
|
console = Console()
|
|
31
32
|
|
|
32
33
|
_DIFF_INFER_MAX_CHARS = 50_000
|
|
34
|
+
_AI_ENV_KEYS = (
|
|
35
|
+
"AI_MODEL",
|
|
36
|
+
"AI_API_KEY",
|
|
37
|
+
"GEMINI_API_KEY",
|
|
38
|
+
"AI_MODEL_FALLBACKS",
|
|
39
|
+
)
|
|
40
|
+
# Terminal hyperlinks (OSC 8) for first-run setup — Ctrl+click in supported terminals.
|
|
41
|
+
_GOOGLE_AI_API_KEY_URL = "https://aistudio.google.com/apikey"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _gemini_fallback_notifier(
|
|
45
|
+
group_label: str | None = None,
|
|
46
|
+
) -> Callable[[str], None]:
|
|
47
|
+
"""Dim one-line notices when switching models after rate limits / overload.
|
|
48
|
+
|
|
49
|
+
``group_label`` prefixes the line in split-commit mode (one AI call per group),
|
|
50
|
+
so repeated "primary busy" messages are distinguishable.
|
|
51
|
+
"""
|
|
52
|
+
first: list[bool] = [True]
|
|
53
|
+
prefix = f"{group_label}: " if group_label else ""
|
|
54
|
+
|
|
55
|
+
def _notify(next_model: str) -> None:
|
|
56
|
+
if first[0]:
|
|
57
|
+
console.print(
|
|
58
|
+
Text(
|
|
59
|
+
f"{prefix}Primary model busy; trying fallback: {next_model}",
|
|
60
|
+
style="dim",
|
|
61
|
+
)
|
|
62
|
+
)
|
|
63
|
+
first[0] = False
|
|
64
|
+
else:
|
|
65
|
+
console.print(
|
|
66
|
+
Text(f"{prefix}Model busy; trying fallback: {next_model}", style="dim")
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
return _notify
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _model_picker_line(
|
|
73
|
+
num: int, label: str, link_url: str, model_id: str | None = None
|
|
74
|
+
) -> Text:
|
|
75
|
+
"""OSC 8 link on `label`; optional cyan tail in parentheses (e.g. model id)."""
|
|
76
|
+
line = Text()
|
|
77
|
+
line.append(f" {num}. ")
|
|
78
|
+
line.append(label, style=f"link {link_url}")
|
|
79
|
+
if model_id:
|
|
80
|
+
line.append(" (")
|
|
81
|
+
line.append(model_id, style="cyan")
|
|
82
|
+
line.append(")")
|
|
83
|
+
return line
|
|
33
84
|
|
|
34
85
|
|
|
35
86
|
@dataclass(frozen=True)
|
|
@@ -89,6 +140,87 @@ def _parse_combined(combined: str) -> tuple[bool | None, list[Change]]:
|
|
|
89
140
|
return has_commits, changes
|
|
90
141
|
|
|
91
142
|
|
|
143
|
+
def _load_ai_env_from_dotenv(dotenv_path: Path) -> None:
|
|
144
|
+
"""Load only AI-related vars from .env, overriding existing process values."""
|
|
145
|
+
values = dotenv_values(dotenv_path)
|
|
146
|
+
for key in _AI_ENV_KEYS:
|
|
147
|
+
raw = values.get(key)
|
|
148
|
+
if raw is None:
|
|
149
|
+
continue
|
|
150
|
+
val = str(raw).strip()
|
|
151
|
+
if val:
|
|
152
|
+
os.environ[key] = val
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _upsert_env_var(dotenv_path: Path, key: str, value: str) -> None:
|
|
156
|
+
lines: list[str] = []
|
|
157
|
+
if dotenv_path.exists():
|
|
158
|
+
lines = dotenv_path.read_text(encoding="utf-8").splitlines()
|
|
159
|
+
replaced = False
|
|
160
|
+
out: list[str] = []
|
|
161
|
+
prefix = key + "="
|
|
162
|
+
for ln in lines:
|
|
163
|
+
if ln.startswith(prefix):
|
|
164
|
+
out.append(f"{key}={value}")
|
|
165
|
+
replaced = True
|
|
166
|
+
else:
|
|
167
|
+
out.append(ln)
|
|
168
|
+
if not replaced:
|
|
169
|
+
if out and out[-1].strip():
|
|
170
|
+
out.append("")
|
|
171
|
+
out.append(f"{key}={value}")
|
|
172
|
+
dotenv_path.write_text("\n".join(out) + "\n", encoding="utf-8")
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _ensure_repo_env_file(repo_env: Path) -> bool:
|
|
176
|
+
if repo_env.is_file():
|
|
177
|
+
return True
|
|
178
|
+
create = (
|
|
179
|
+
typer.prompt("No .env found. Create one now? (y/n)", default="y")
|
|
180
|
+
.strip()
|
|
181
|
+
.lower()
|
|
182
|
+
)
|
|
183
|
+
if create not in ("y", "yes"):
|
|
184
|
+
return False
|
|
185
|
+
repo_env.write_text("", encoding="utf-8")
|
|
186
|
+
return True
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _choose_and_persist_ai_model(repo_env: Path) -> str:
|
|
190
|
+
"""First run: set default Gemini model and reload .env for API keys."""
|
|
191
|
+
console.print(
|
|
192
|
+
Text(
|
|
193
|
+
"Add your API key to .env (create one in Google AI Studio if needed):",
|
|
194
|
+
style="dim",
|
|
195
|
+
)
|
|
196
|
+
)
|
|
197
|
+
console.print(
|
|
198
|
+
_model_picker_line(
|
|
199
|
+
1,
|
|
200
|
+
"Google AI Studio",
|
|
201
|
+
_GOOGLE_AI_API_KEY_URL,
|
|
202
|
+
model_id=DEFAULT_MODEL,
|
|
203
|
+
)
|
|
204
|
+
)
|
|
205
|
+
model = DEFAULT_MODEL
|
|
206
|
+
_upsert_env_var(repo_env, "AI_MODEL", model)
|
|
207
|
+
os.environ["AI_MODEL"] = model
|
|
208
|
+
if repo_env.is_file():
|
|
209
|
+
_load_ai_env_from_dotenv(repo_env)
|
|
210
|
+
return model
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _resolve_project_ai_model(repo_env: Path, model_override: str | None) -> str | None:
|
|
214
|
+
if model_override:
|
|
215
|
+
return model_override
|
|
216
|
+
model = (os.environ.get("AI_MODEL") or "").strip()
|
|
217
|
+
if model:
|
|
218
|
+
return model
|
|
219
|
+
if not _ensure_repo_env_file(repo_env):
|
|
220
|
+
return None
|
|
221
|
+
return _choose_and_persist_ai_model(repo_env)
|
|
222
|
+
|
|
223
|
+
|
|
92
224
|
def _render_combined(
|
|
93
225
|
has_commits: bool | None, items: Iterable[tuple[str, str]], title: str
|
|
94
226
|
) -> str:
|
|
@@ -199,7 +331,6 @@ def _validate_suggest_flags(
|
|
|
199
331
|
*,
|
|
200
332
|
suggest: bool,
|
|
201
333
|
auto: bool,
|
|
202
|
-
ai: bool,
|
|
203
334
|
staged_only: bool,
|
|
204
335
|
model: str | None,
|
|
205
336
|
with_diff: bool,
|
|
@@ -209,14 +340,10 @@ def _validate_suggest_flags(
|
|
|
209
340
|
bad: list[str] = []
|
|
210
341
|
if auto:
|
|
211
342
|
bad.append("--auto")
|
|
212
|
-
if ai:
|
|
213
|
-
bad.append("--ai")
|
|
214
343
|
if staged_only:
|
|
215
344
|
bad.append("--staged-only")
|
|
216
345
|
if with_diff:
|
|
217
346
|
bad.append("--with-diff")
|
|
218
|
-
if model is not None:
|
|
219
|
-
bad.append("--model")
|
|
220
347
|
if bad:
|
|
221
348
|
raise typer.BadParameter(
|
|
222
349
|
"--suggest is a dedicated mode and cannot be combined with: "
|
|
@@ -230,9 +357,6 @@ def main(
|
|
|
230
357
|
auto: bool = typer.Option(
|
|
231
358
|
False, "--auto", help="Apply suggestion without prompting"
|
|
232
359
|
),
|
|
233
|
-
ai: bool = typer.Option(
|
|
234
|
-
False, "--ai", help="Use Gemini to suggest commit message (default: off)"
|
|
235
|
-
),
|
|
236
360
|
staged_only: bool = typer.Option(
|
|
237
361
|
False,
|
|
238
362
|
"--staged-only",
|
|
@@ -244,15 +368,12 @@ def main(
|
|
|
244
368
|
model: str | None = typer.Option(
|
|
245
369
|
None,
|
|
246
370
|
"--model",
|
|
247
|
-
help=(
|
|
248
|
-
"Override Gemini model name for --ai "
|
|
249
|
-
"(defaults to GEMINI_MODEL env var or internal default)."
|
|
250
|
-
),
|
|
371
|
+
help="Override AI model for this run (defaults to AI_MODEL from repo .env).",
|
|
251
372
|
),
|
|
252
373
|
with_diff: bool = typer.Option(
|
|
253
374
|
False,
|
|
254
375
|
"--with-diff",
|
|
255
|
-
help="
|
|
376
|
+
help="Send full diff to the configured AI model for more specific messages (opt-in).",
|
|
256
377
|
),
|
|
257
378
|
suggest: bool = typer.Option(
|
|
258
379
|
False,
|
|
@@ -265,7 +386,6 @@ def main(
|
|
|
265
386
|
_validate_suggest_flags(
|
|
266
387
|
suggest=suggest,
|
|
267
388
|
auto=auto,
|
|
268
|
-
ai=ai,
|
|
269
389
|
staged_only=staged_only,
|
|
270
390
|
model=model,
|
|
271
391
|
with_diff=with_diff,
|
|
@@ -273,7 +393,6 @@ def main(
|
|
|
273
393
|
run(
|
|
274
394
|
cwd=Path(cwd) if cwd else None,
|
|
275
395
|
auto=auto,
|
|
276
|
-
ai=ai,
|
|
277
396
|
staged_only=staged_only,
|
|
278
397
|
model=model,
|
|
279
398
|
with_diff=with_diff,
|
|
@@ -284,32 +403,29 @@ def main(
|
|
|
284
403
|
def run(
|
|
285
404
|
cwd: Path | None = None,
|
|
286
405
|
auto: bool = False,
|
|
287
|
-
ai: bool = False,
|
|
288
406
|
staged_only: bool = False,
|
|
289
407
|
model: str | None = None,
|
|
290
408
|
with_diff: bool = False,
|
|
291
409
|
suggest: bool = False,
|
|
292
410
|
) -> None:
|
|
293
411
|
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
412
|
|
|
305
413
|
try:
|
|
306
414
|
combined, repo_root = get_combined_diff(cwd=cwd)
|
|
307
415
|
except RuntimeError as e:
|
|
308
416
|
console.print(f"[red]Error:[/red] {e}")
|
|
309
417
|
raise typer.Exit(1)
|
|
418
|
+
|
|
419
|
+
repo_env = repo_root / ".env"
|
|
420
|
+
if repo_env.is_file():
|
|
421
|
+
_load_ai_env_from_dotenv(repo_env)
|
|
422
|
+
|
|
310
423
|
if not combined.strip():
|
|
311
424
|
console.print("[yellow]No staged, unstaged, or untracked changes.[/yellow]")
|
|
312
425
|
return
|
|
426
|
+
ai_model = _resolve_project_ai_model(repo_env, model)
|
|
427
|
+
if repo_env.is_file():
|
|
428
|
+
_load_ai_env_from_dotenv(repo_env)
|
|
313
429
|
has_commits, changes = _parse_combined(combined)
|
|
314
430
|
console.print(Panel(combined, title="Changed files", border_style="dim"))
|
|
315
431
|
|
|
@@ -332,12 +448,19 @@ def run(
|
|
|
332
448
|
if len(staged_diff) > _DIFF_INFER_MAX_CHARS
|
|
333
449
|
else staged_diff
|
|
334
450
|
)
|
|
451
|
+
if not ai_model:
|
|
452
|
+
console.print(
|
|
453
|
+
"[red]Error:[/red] --suggest requires an AI model. "
|
|
454
|
+
"Set AI_MODEL in repo .env or pass --model."
|
|
455
|
+
)
|
|
456
|
+
raise typer.Exit(1)
|
|
335
457
|
try:
|
|
336
458
|
sug, _raw = suggest_commands(
|
|
337
459
|
payload,
|
|
338
|
-
model=
|
|
460
|
+
model=ai_model,
|
|
339
461
|
with_diff=bool(staged_diff),
|
|
340
462
|
unified_diff_for_infer=infer_diff,
|
|
463
|
+
fallback_notifier=_gemini_fallback_notifier(),
|
|
341
464
|
)
|
|
342
465
|
if sug is None:
|
|
343
466
|
raise RuntimeError("Could not parse AI suggestion.")
|
|
@@ -436,7 +559,7 @@ def run(
|
|
|
436
559
|
else raw_d
|
|
437
560
|
)
|
|
438
561
|
|
|
439
|
-
if
|
|
562
|
+
if ai_model:
|
|
440
563
|
payload = _render_combined(has_commits, change_items, title=title)
|
|
441
564
|
if with_diff:
|
|
442
565
|
paths_for_diff = [p for _, p in change_items]
|
|
@@ -444,11 +567,13 @@ def run(
|
|
|
444
567
|
if diff_text:
|
|
445
568
|
payload = payload + "\n\n## Diff\n" + diff_text
|
|
446
569
|
try:
|
|
570
|
+
fb_label = title if mode == "split" else None
|
|
447
571
|
sug, _raw = suggest_commands(
|
|
448
572
|
payload,
|
|
449
|
-
model=
|
|
573
|
+
model=ai_model,
|
|
450
574
|
with_diff=with_diff,
|
|
451
575
|
unified_diff_for_infer=infer_diff,
|
|
576
|
+
fallback_notifier=_gemini_fallback_notifier(fb_label),
|
|
452
577
|
)
|
|
453
578
|
if sug is None:
|
|
454
579
|
raise RuntimeError("Could not parse AI suggestion.")
|
|
@@ -500,9 +625,10 @@ def run(
|
|
|
500
625
|
if fb:
|
|
501
626
|
ai_fallback_notes.append(("", fb))
|
|
502
627
|
|
|
503
|
-
if
|
|
628
|
+
if ai_model and ai_fallback_notes:
|
|
629
|
+
key_help = "Check AI_API_KEY, AI_MODEL, AI_MODEL_FALLBACKS, quota/model availability, and network."
|
|
504
630
|
lines = [
|
|
505
|
-
"[bold]
|
|
631
|
+
"[bold]Configured AI was not used for the suggestion below.[/bold]",
|
|
506
632
|
"Commit message(s) come from [bold]local heuristics[/bold] instead.",
|
|
507
633
|
"",
|
|
508
634
|
]
|
|
@@ -512,9 +638,7 @@ def run(
|
|
|
512
638
|
else:
|
|
513
639
|
lines.append(ai_fallback_notes[0][1])
|
|
514
640
|
lines.append("")
|
|
515
|
-
lines.append(
|
|
516
|
-
"[dim]Check API key (GEMINI_API_KEY / GOOGLE_API_KEY), quota, model name, and network.[/dim]"
|
|
517
|
-
)
|
|
641
|
+
lines.append(f"[dim]{key_help}[/dim]")
|
|
518
642
|
console.print(
|
|
519
643
|
Panel(
|
|
520
644
|
"\n".join(lines),
|
|
@@ -595,12 +719,12 @@ def run(
|
|
|
595
719
|
do_apply = True
|
|
596
720
|
else:
|
|
597
721
|
prompt = (
|
|
598
|
-
"Apply these commit(s)? (y/n
|
|
722
|
+
"Apply these commit(s)? (y/n)"
|
|
599
723
|
if len(plan) > 1
|
|
600
|
-
else "Apply these commands? (y/n
|
|
724
|
+
else "Apply these commands? (y/n)"
|
|
601
725
|
)
|
|
602
|
-
choice = typer.prompt(prompt, default="
|
|
603
|
-
do_apply = choice
|
|
726
|
+
choice = typer.prompt(prompt, default="y").strip().lower()
|
|
727
|
+
do_apply = choice in ("y", "yes")
|
|
604
728
|
|
|
605
729
|
if do_apply:
|
|
606
730
|
for name, sug in plan:
|