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.
Files changed (29) hide show
  1. git_explain-2.4.0/PKG-INFO +125 -0
  2. git_explain-2.4.0/README.md +92 -0
  3. git_explain-2.4.0/git_explain/__init__.py +1 -0
  4. {git_explain-2.2.0 → git_explain-2.4.0}/git_explain/cli.py +165 -41
  5. {git_explain-2.2.0 → git_explain-2.4.0}/git_explain/gemini.py +130 -39
  6. {git_explain-2.2.0 → git_explain-2.4.0}/git_explain/heuristics.py +25 -2
  7. git_explain-2.4.0/git_explain.egg-info/PKG-INFO +125 -0
  8. {git_explain-2.2.0 → git_explain-2.4.0}/pyproject.toml +2 -0
  9. {git_explain-2.2.0 → git_explain-2.4.0}/tests/test_cli_utils.py +123 -3
  10. {git_explain-2.2.0 → git_explain-2.4.0}/tests/test_gemini.py +55 -0
  11. {git_explain-2.2.0 → git_explain-2.4.0}/tests/test_heuristics.py +17 -0
  12. git_explain-2.2.0/PKG-INFO +0 -111
  13. git_explain-2.2.0/README.md +0 -80
  14. git_explain-2.2.0/git_explain/__init__.py +0 -1
  15. git_explain-2.2.0/git_explain.egg-info/PKG-INFO +0 -111
  16. {git_explain-2.2.0 → git_explain-2.4.0}/LICENSE +0 -0
  17. {git_explain-2.2.0 → git_explain-2.4.0}/git_explain/__main__.py +0 -0
  18. {git_explain-2.2.0 → git_explain-2.4.0}/git_explain/commit_infer.py +0 -0
  19. {git_explain-2.2.0 → git_explain-2.4.0}/git_explain/git.py +0 -0
  20. {git_explain-2.2.0 → git_explain-2.4.0}/git_explain/path_topics.py +0 -0
  21. {git_explain-2.2.0 → git_explain-2.4.0}/git_explain/run.py +0 -0
  22. {git_explain-2.2.0 → git_explain-2.4.0}/git_explain.egg-info/SOURCES.txt +0 -0
  23. {git_explain-2.2.0 → git_explain-2.4.0}/git_explain.egg-info/dependency_links.txt +0 -0
  24. {git_explain-2.2.0 → git_explain-2.4.0}/git_explain.egg-info/entry_points.txt +0 -0
  25. {git_explain-2.2.0 → git_explain-2.4.0}/git_explain.egg-info/requires.txt +0 -0
  26. {git_explain-2.2.0 → git_explain-2.4.0}/git_explain.egg-info/top_level.txt +0 -0
  27. {git_explain-2.2.0 → git_explain-2.4.0}/setup.cfg +0 -0
  28. {git_explain-2.2.0 → git_explain-2.4.0}/tests/test_commit_infer.py +0 -0
  29. {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
+ [![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
+ <!-- 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
+ [![Sponsored by GitAds](https://gitads.dev/v1/ad-serve?source=nazarli-shabnam/git-explain@github)](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
+ [![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
+ <!-- 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
+ [![Sponsored by GitAds](https://gitads.dev/v1/ad-serve?source=nazarli-shabnam/git-explain@github)](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 load_dotenv
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="With --ai: send full diff to the model for detailed, specific commit messages (opt-in).",
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=None,
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 ai:
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=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 ai and ai_fallback_notes:
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]You used --ai, but Gemini was not used for the suggestion below.[/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/auto)"
722
+ "Apply these commit(s)? (y/n)"
599
723
  if len(plan) > 1
600
- else "Apply these commands? (y/n/auto)"
724
+ else "Apply these commands? (y/n)"
601
725
  )
602
- choice = typer.prompt(prompt, default="n").strip().lower()
603
- do_apply = choice == "auto" or choice in ("y", "yes")
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: