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.
Files changed (31) hide show
  1. git_explain-2.5.0/PKG-INFO +122 -0
  2. git_explain-2.5.0/README.md +89 -0
  3. git_explain-2.5.0/git_explain/__init__.py +1 -0
  4. {git_explain-2.2.1 → git_explain-2.5.0}/git_explain/cli.py +179 -42
  5. {git_explain-2.2.1 → git_explain-2.5.0}/git_explain/gemini.py +131 -78
  6. {git_explain-2.2.1 → git_explain-2.5.0}/git_explain/heuristics.py +25 -39
  7. {git_explain-2.2.1 → git_explain-2.5.0}/git_explain/path_topics.py +40 -0
  8. git_explain-2.5.0/git_explain.egg-info/PKG-INFO +122 -0
  9. {git_explain-2.2.1 → git_explain-2.5.0}/git_explain.egg-info/SOURCES.txt +2 -0
  10. {git_explain-2.2.1 → git_explain-2.5.0}/git_explain.egg-info/requires.txt +1 -1
  11. {git_explain-2.2.1 → git_explain-2.5.0}/pyproject.toml +3 -1
  12. {git_explain-2.2.1 → git_explain-2.5.0}/tests/test_cli_utils.py +123 -3
  13. {git_explain-2.2.1 → git_explain-2.5.0}/tests/test_gemini.py +55 -0
  14. git_explain-2.5.0/tests/test_gemini_suggest_commands.py +127 -0
  15. git_explain-2.5.0/tests/test_git.py +209 -0
  16. {git_explain-2.2.1 → git_explain-2.5.0}/tests/test_heuristics.py +17 -0
  17. git_explain-2.2.1/PKG-INFO +0 -111
  18. git_explain-2.2.1/README.md +0 -80
  19. git_explain-2.2.1/git_explain/__init__.py +0 -1
  20. git_explain-2.2.1/git_explain.egg-info/PKG-INFO +0 -111
  21. {git_explain-2.2.1 → git_explain-2.5.0}/LICENSE +0 -0
  22. {git_explain-2.2.1 → git_explain-2.5.0}/git_explain/__main__.py +0 -0
  23. {git_explain-2.2.1 → git_explain-2.5.0}/git_explain/commit_infer.py +0 -0
  24. {git_explain-2.2.1 → git_explain-2.5.0}/git_explain/git.py +0 -0
  25. {git_explain-2.2.1 → git_explain-2.5.0}/git_explain/run.py +0 -0
  26. {git_explain-2.2.1 → git_explain-2.5.0}/git_explain.egg-info/dependency_links.txt +0 -0
  27. {git_explain-2.2.1 → git_explain-2.5.0}/git_explain.egg-info/entry_points.txt +0 -0
  28. {git_explain-2.2.1 → git_explain-2.5.0}/git_explain.egg-info/top_level.txt +0 -0
  29. {git_explain-2.2.1 → git_explain-2.5.0}/setup.cfg +0 -0
  30. {git_explain-2.2.1 → git_explain-2.5.0}/tests/test_commit_infer.py +0 -0
  31. {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
+ [![PyPI](https://img.shields.io/pypi/v/git-explain.svg?label=pypi)](https://pypi.org/project/git-explain/)
39
+ [![GitHub tag](https://img.shields.io/github/v/tag/nazarli-shabnam/git-explain?label=repo)](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
+ [![PyPI](https://img.shields.io/pypi/v/git-explain.svg?label=pypi)](https://pypi.org/project/git-explain/)
6
+ [![GitHub tag](https://img.shields.io/github/v/tag/nazarli-shabnam/git-explain?label=repo)](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 load_dotenv
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="With --ai: send full diff to the model for detailed, specific commit messages (opt-in).",
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=None,
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 ai:
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=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 Exception as e:
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 ai and ai_fallback_notes:
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]You used --ai, but Gemini was not used for the suggestion below.[/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/auto)"
735
+ "Apply these commit(s)? (y/n)"
599
736
  if len(plan) > 1
600
- else "Apply these commands? (y/n/auto)"
737
+ else "Apply these commands? (y/n)"
601
738
  )
602
- choice = typer.prompt(prompt, default="n").strip().lower()
603
- do_apply = choice == "auto" or choice in ("y", "yes")
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: