git-explain 1.1.4__tar.gz → 2.1.5__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 (25) hide show
  1. {git_explain-1.1.4 → git_explain-2.1.5}/PKG-INFO +7 -4
  2. {git_explain-1.1.4 → git_explain-2.1.5}/README.md +5 -3
  3. git_explain-2.1.5/git_explain/__init__.py +1 -0
  4. {git_explain-1.1.4 → git_explain-2.1.5}/git_explain/cli.py +148 -14
  5. git_explain-2.1.5/git_explain/commit_infer.py +66 -0
  6. {git_explain-1.1.4 → git_explain-2.1.5}/git_explain/gemini.py +134 -21
  7. {git_explain-1.1.4 → git_explain-2.1.5}/git_explain/git.py +16 -0
  8. {git_explain-1.1.4 → git_explain-2.1.5}/git_explain/heuristics.py +61 -4
  9. {git_explain-1.1.4 → git_explain-2.1.5}/git_explain/run.py +11 -0
  10. {git_explain-1.1.4 → git_explain-2.1.5}/git_explain.egg-info/PKG-INFO +7 -4
  11. {git_explain-1.1.4 → git_explain-2.1.5}/git_explain.egg-info/SOURCES.txt +2 -0
  12. {git_explain-1.1.4 → git_explain-2.1.5}/git_explain.egg-info/requires.txt +1 -0
  13. {git_explain-1.1.4 → git_explain-2.1.5}/pyproject.toml +2 -1
  14. {git_explain-1.1.4 → git_explain-2.1.5}/tests/test_cli_utils.py +29 -2
  15. git_explain-2.1.5/tests/test_commit_infer.py +72 -0
  16. {git_explain-1.1.4 → git_explain-2.1.5}/tests/test_gemini.py +57 -0
  17. {git_explain-1.1.4 → git_explain-2.1.5}/tests/test_heuristics.py +17 -1
  18. git_explain-1.1.4/git_explain/__init__.py +0 -1
  19. {git_explain-1.1.4 → git_explain-2.1.5}/LICENSE +0 -0
  20. {git_explain-1.1.4 → git_explain-2.1.5}/git_explain/path_topics.py +0 -0
  21. {git_explain-1.1.4 → git_explain-2.1.5}/git_explain.egg-info/dependency_links.txt +0 -0
  22. {git_explain-1.1.4 → git_explain-2.1.5}/git_explain.egg-info/entry_points.txt +0 -0
  23. {git_explain-1.1.4 → git_explain-2.1.5}/git_explain.egg-info/top_level.txt +0 -0
  24. {git_explain-1.1.4 → git_explain-2.1.5}/setup.cfg +0 -0
  25. {git_explain-1.1.4 → git_explain-2.1.5}/tests/test_run_apply.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: git-explain
3
- Version: 1.1.4
3
+ Version: 2.1.5
4
4
  Summary: CLI that suggests git add/commit from diffs using Gemini
5
5
  Author: git-explain contributors
6
6
  License-Expression: MIT
@@ -26,6 +26,7 @@ Requires-Dist: python-dotenv>=1.0.0
26
26
  Requires-Dist: prompt_toolkit>=3.0.0
27
27
  Provides-Extra: dev
28
28
  Requires-Dist: pytest>=8.0.0; extra == "dev"
29
+ Requires-Dist: ruff>=0.8.0; extra == "dev"
29
30
  Dynamic: license-file
30
31
 
31
32
  # git-explain
@@ -104,13 +105,14 @@ You’ll see a list of changed files, choose which to include, then get suggeste
104
105
 
105
106
  ---
106
107
 
107
- ## Modes: `git-explain` vs `git-explain --ai` vs `git-explain --with-diff --ai`
108
+ ## Modes: `git-explain` vs `git-explain --ai` vs `git-explain --with-diff --ai` vs `git-explain --suggest`
108
109
 
109
110
  | Command | What it does |
110
111
  |---------|--------------|
111
112
  | `git-explain` | **Heuristics only.** No API call. Suggests commit type and message from file names and status (e.g. docs, tests, config, code). Fast and private. |
112
113
  | `git-explain --ai` | **AI (paths only).** Sends only file paths and statuses (A/M/D) to Gemini. No file contents. Good for smarter messages without sharing code. |
113
114
  | `git-explain --with-diff --ai` | **AI (full diff).** Sends file list **plus** the full diff (staged, unstaged, untracked content) to Gemini. Produces detailed, specific messages (e.g. `feat: add opt-in --with-diff for detailed AI commit messages`). Opt-in; use when you want maximum accuracy and are okay sending diff content to the API. |
115
+ | `git-explain --suggest` | **AI staged-only suggestion mode.** Requires staged changes; sends staged file list + staged diff to Gemini and prints only `git commit -m ...`. It never applies changes and cannot be combined with other flags. |
114
116
 
115
117
  **Summary:** Use plain `git-explain` for speed and privacy. Use `--ai` for better suggestions without sharing code. Use `--with-diff --ai` when you want the most accurate, context-aware messages and accept sending diff content to Gemini.
116
118
 
@@ -125,8 +127,9 @@ You’ll see a list of changed files, choose which to include, then get suggeste
125
127
  | `--ai` | Use Gemini for commit type/message (file paths only). |
126
128
  | `--with-diff` | With `--ai`: send full diff to the model for detailed messages. |
127
129
  | `--model NAME` | Override Gemini model (e.g. `--model gemini-2.0-flash`). |
128
- | `--staged-only` | Commit only what’s already staged (no `git add`). |
130
+ | `--staged-only` | Commit only what’s already staged (no `git add`). Always one commit for the whole index—split-by-group mode is disabled, because Git would commit the entire index on the first step and later steps would have nothing left staged. |
129
131
  | `--cwd PATH` | Run as if current directory is `PATH`. |
132
+ | `--suggest` | Dedicated staged-only AI suggestion mode; prints only commit command and exits. Cannot be combined with other flags. |
130
133
  | `--install-completion [SHELL]` | Install shell completion (`bash`, `zsh`). |
131
134
  | `--show-completion [SHELL]` | Print completion script for `SHELL`. |
132
135
 
@@ -134,7 +137,7 @@ You’ll see a list of changed files, choose which to include, then get suggeste
134
137
 
135
138
  ## Workflow
136
139
 
137
- 1. **Changed files** — Shows staged, unstaged, and untracked files. Untracked folders are grouped (e.g. `foo/ (untracked folder; 5 files)`).
140
+ 1. **Changed files** — Shows staged, unstaged, and untracked files. Untracked directories are expanded so you still see per-file paths.
138
141
  2. **Select files** — Enter numbers (e.g. `1,2,5-7`), `all`, or a path (e.g. `main.py`, `src/utils/`).
139
142
  3. **Commit mode** — If you selected 2+ files: choose `one` (single commit) or `split` (separate commits by docs/tests/config/code).
140
143
  4. **Suggested commands** — Panel with `git add` and `git commit` lines.
@@ -74,13 +74,14 @@ You’ll see a list of changed files, choose which to include, then get suggeste
74
74
 
75
75
  ---
76
76
 
77
- ## Modes: `git-explain` vs `git-explain --ai` vs `git-explain --with-diff --ai`
77
+ ## Modes: `git-explain` vs `git-explain --ai` vs `git-explain --with-diff --ai` vs `git-explain --suggest`
78
78
 
79
79
  | Command | What it does |
80
80
  |---------|--------------|
81
81
  | `git-explain` | **Heuristics only.** No API call. Suggests commit type and message from file names and status (e.g. docs, tests, config, code). Fast and private. |
82
82
  | `git-explain --ai` | **AI (paths only).** Sends only file paths and statuses (A/M/D) to Gemini. No file contents. Good for smarter messages without sharing code. |
83
83
  | `git-explain --with-diff --ai` | **AI (full diff).** Sends file list **plus** the full diff (staged, unstaged, untracked content) to Gemini. Produces detailed, specific messages (e.g. `feat: add opt-in --with-diff for detailed AI commit messages`). Opt-in; use when you want maximum accuracy and are okay sending diff content to the API. |
84
+ | `git-explain --suggest` | **AI staged-only suggestion mode.** Requires staged changes; sends staged file list + staged diff to Gemini and prints only `git commit -m ...`. It never applies changes and cannot be combined with other flags. |
84
85
 
85
86
  **Summary:** Use plain `git-explain` for speed and privacy. Use `--ai` for better suggestions without sharing code. Use `--with-diff --ai` when you want the most accurate, context-aware messages and accept sending diff content to Gemini.
86
87
 
@@ -95,8 +96,9 @@ You’ll see a list of changed files, choose which to include, then get suggeste
95
96
  | `--ai` | Use Gemini for commit type/message (file paths only). |
96
97
  | `--with-diff` | With `--ai`: send full diff to the model for detailed messages. |
97
98
  | `--model NAME` | Override Gemini model (e.g. `--model gemini-2.0-flash`). |
98
- | `--staged-only` | Commit only what’s already staged (no `git add`). |
99
+ | `--staged-only` | Commit only what’s already staged (no `git add`). Always one commit for the whole index—split-by-group mode is disabled, because Git would commit the entire index on the first step and later steps would have nothing left staged. |
99
100
  | `--cwd PATH` | Run as if current directory is `PATH`. |
101
+ | `--suggest` | Dedicated staged-only AI suggestion mode; prints only commit command and exits. Cannot be combined with other flags. |
100
102
  | `--install-completion [SHELL]` | Install shell completion (`bash`, `zsh`). |
101
103
  | `--show-completion [SHELL]` | Print completion script for `SHELL`. |
102
104
 
@@ -104,7 +106,7 @@ You’ll see a list of changed files, choose which to include, then get suggeste
104
106
 
105
107
  ## Workflow
106
108
 
107
- 1. **Changed files** — Shows staged, unstaged, and untracked files. Untracked folders are grouped (e.g. `foo/ (untracked folder; 5 files)`).
109
+ 1. **Changed files** — Shows staged, unstaged, and untracked files. Untracked directories are expanded so you still see per-file paths.
108
110
  2. **Select files** — Enter numbers (e.g. `1,2,5-7`), `all`, or a path (e.g. `main.py`, `src/utils/`).
109
111
  3. **Commit mode** — If you selected 2+ files: choose `one` (single commit) or `split` (separate commits by docs/tests/config/code).
110
112
  4. **Suggested commands** — Panel with `git add` and `git commit` lines.
@@ -0,0 +1 @@
1
+ __version__ = "2.1.4"
@@ -13,13 +13,19 @@ from rich.text import Text
13
13
 
14
14
  from git_explain.gemini import suggest_commands
15
15
  from git_explain.heuristics import suggest_from_changes
16
- from git_explain.git import get_combined_diff, get_diff_for_paths
16
+ from git_explain.git import (
17
+ get_combined_diff,
18
+ get_diff_for_paths,
19
+ get_staged_diff_for_paths,
20
+ )
17
21
  from git_explain.run import apply_commands
18
22
 
19
23
  load_dotenv()
20
24
  app = typer.Typer()
21
25
  console = Console()
22
26
 
27
+ _DIFF_INFER_MAX_CHARS = 50_000
28
+
23
29
 
24
30
  @dataclass(frozen=True)
25
31
  class Change:
@@ -158,6 +164,12 @@ def _group_changes(changes: list[tuple[str, str]]) -> dict[str, list[tuple[str,
158
164
  ".gitignore",
159
165
  } or p2.endswith((".toml", ".yml", ".yaml", ".json", ".ini", ".cfg", ".lock"))
160
166
 
167
+ def is_code(p: str) -> bool:
168
+ p2 = p.lower()
169
+ return p2.endswith(
170
+ (".py", ".js", ".ts", ".tsx", ".go", ".rs", ".java", ".rb", ".php", ".cs")
171
+ )
172
+
161
173
  groups: dict[str, list[tuple[str, str]]] = {
162
174
  "docs": [],
163
175
  "tests": [],
@@ -172,13 +184,42 @@ def _group_changes(changes: list[tuple[str, str]]) -> dict[str, list[tuple[str,
172
184
  groups["tests"].append((st, p))
173
185
  elif is_config(p):
174
186
  groups["config"].append((st, p))
175
- elif p.lower().replace("\\", "/").startswith("git_explain/"):
187
+ elif is_code(p):
176
188
  groups["code"].append((st, p))
177
189
  else:
178
190
  groups["other"].append((st, p))
179
191
  return {k: v for k, v in groups.items() if v}
180
192
 
181
193
 
194
+ def _validate_suggest_flags(
195
+ *,
196
+ suggest: bool,
197
+ auto: bool,
198
+ ai: bool,
199
+ staged_only: bool,
200
+ model: str | None,
201
+ with_diff: bool,
202
+ ) -> None:
203
+ if not suggest:
204
+ return
205
+ bad: list[str] = []
206
+ if auto:
207
+ bad.append("--auto")
208
+ if ai:
209
+ bad.append("--ai")
210
+ if staged_only:
211
+ bad.append("--staged-only")
212
+ if with_diff:
213
+ bad.append("--with-diff")
214
+ if model is not None:
215
+ bad.append("--model")
216
+ if bad:
217
+ raise typer.BadParameter(
218
+ "--suggest is a dedicated mode and cannot be combined with: "
219
+ + ", ".join(bad)
220
+ )
221
+
222
+
182
223
  @app.callback(invoke_without_command=True)
183
224
  def main(
184
225
  ctx: typer.Context,
@@ -209,9 +250,22 @@ def main(
209
250
  "--with-diff",
210
251
  help="With --ai: send full diff to the model for detailed, specific commit messages (opt-in).",
211
252
  ),
253
+ suggest: bool = typer.Option(
254
+ False,
255
+ "--suggest",
256
+ help="AI suggestion-only mode: use staged files + staged diff and print only commit command.",
257
+ ),
212
258
  ) -> None:
213
259
  if ctx.invoked_subcommand is not None:
214
260
  return
261
+ _validate_suggest_flags(
262
+ suggest=suggest,
263
+ auto=auto,
264
+ ai=ai,
265
+ staged_only=staged_only,
266
+ model=model,
267
+ with_diff=with_diff,
268
+ )
215
269
  run(
216
270
  cwd=Path(cwd) if cwd else None,
217
271
  auto=auto,
@@ -219,6 +273,7 @@ def main(
219
273
  staged_only=staged_only,
220
274
  model=model,
221
275
  with_diff=with_diff,
276
+ suggest=suggest,
222
277
  )
223
278
 
224
279
 
@@ -229,6 +284,7 @@ def run(
229
284
  staged_only: bool = False,
230
285
  model: str | None = None,
231
286
  with_diff: bool = False,
287
+ suggest: bool = False,
232
288
  ) -> None:
233
289
  console.print(Text("git-explain", style="bold"))
234
290
  if with_diff and not ai:
@@ -253,6 +309,49 @@ def run(
253
309
  has_commits, changes = _parse_combined(combined)
254
310
  console.print(Panel(combined, title="Changed files", border_style="dim"))
255
311
 
312
+ if suggest:
313
+ staged_changes = [c for c in changes if "Staged" in c.sections]
314
+ if not staged_changes:
315
+ console.print(
316
+ "[yellow]Warning:[/yellow] --suggest requires staged changes. "
317
+ "Stage files first (git add ...), then run --suggest again."
318
+ )
319
+ raise typer.Exit(1)
320
+ selected_pairs = [(ch.status, ch.path) for ch in staged_changes]
321
+ payload = _render_combined(has_commits, selected_pairs, title="Staged")
322
+ paths = [p for _, p in selected_pairs]
323
+ staged_diff = get_staged_diff_for_paths(paths, cwd=repo_root)
324
+ if staged_diff:
325
+ payload = payload + "\n\n## Diff\n" + staged_diff
326
+ infer_diff = (
327
+ staged_diff[:_DIFF_INFER_MAX_CHARS]
328
+ if len(staged_diff) > _DIFF_INFER_MAX_CHARS
329
+ else staged_diff
330
+ )
331
+ try:
332
+ sug, _raw = suggest_commands(
333
+ payload,
334
+ model=None,
335
+ with_diff=True,
336
+ unified_diff_for_infer=infer_diff,
337
+ )
338
+ if sug is None:
339
+ raise RuntimeError("Could not parse AI suggestion.")
340
+ except Exception as e:
341
+ console.print(
342
+ f"[red]Error:[/red] --suggest requires AI and failed to get a suggestion: {e}"
343
+ )
344
+ raise typer.Exit(1)
345
+
346
+ console.print(
347
+ Panel(
348
+ f'git commit -m "[{sug.commit_type}] {sug.commit_message}"',
349
+ title="Suggested commit command",
350
+ border_style="green",
351
+ )
352
+ )
353
+ return
354
+
256
355
  if staged_only:
257
356
  changes = [c for c in changes if "Staged" in c.sections]
258
357
  console.print(
@@ -325,6 +424,17 @@ def run(
325
424
  ) -> tuple[list[str], str, str, str, str | None]:
326
425
  # Returns (paths, type, message, raw_text, ai_fallback_reason).
327
426
  # ai_fallback_reason is set when --ai was used but heuristics were used instead.
427
+ paths_for_infer = [p for _, p in change_items]
428
+ infer_diff: str | None = None
429
+ if paths_for_infer:
430
+ raw_d = get_diff_for_paths(paths_for_infer, cwd=repo_root)
431
+ if raw_d.strip():
432
+ infer_diff = (
433
+ raw_d[:_DIFF_INFER_MAX_CHARS]
434
+ if len(raw_d) > _DIFF_INFER_MAX_CHARS
435
+ else raw_d
436
+ )
437
+
328
438
  if ai:
329
439
  payload = _render_combined(has_commits, change_items, title=title)
330
440
  if with_diff:
@@ -333,13 +443,22 @@ def run(
333
443
  if diff_text:
334
444
  payload = payload + "\n\n## Diff\n" + diff_text
335
445
  try:
336
- sug, raw = suggest_commands(payload, model=model, with_diff=with_diff)
446
+ sug, raw = suggest_commands(
447
+ payload,
448
+ model=model,
449
+ with_diff=with_diff,
450
+ unified_diff_for_infer=infer_diff,
451
+ )
337
452
  if sug is None:
338
453
  raise RuntimeError("Could not parse AI suggestion.")
339
454
  return sug.add_args, sug.commit_type, sug.commit_message, raw, None
340
455
  except Exception as e:
341
456
  # Fall back to heuristics on quota / API errors
342
- h = suggest_from_changes(changes=change_items, has_commits=has_commits)
457
+ h = suggest_from_changes(
458
+ changes=change_items,
459
+ has_commits=has_commits,
460
+ diff_text=infer_diff,
461
+ )
343
462
  return (
344
463
  h.add_args,
345
464
  h.commit_type,
@@ -347,7 +466,11 @@ def run(
347
466
  "",
348
467
  str(e),
349
468
  )
350
- h = suggest_from_changes(changes=change_items, has_commits=has_commits)
469
+ h = suggest_from_changes(
470
+ changes=change_items,
471
+ has_commits=has_commits,
472
+ diff_text=infer_diff,
473
+ )
351
474
  return h.add_args, h.commit_type, h.commit_message, "", None
352
475
 
353
476
  selected_pairs = [(ch.status, ch.path) for ch in selected]
@@ -355,20 +478,25 @@ def run(
355
478
 
356
479
  mode = "one"
357
480
  if len(unique_paths) > 1:
358
- mode_input = (
359
- typer.prompt("Commit mode: one or split", default="one").strip().lower()
360
- )
361
- if mode_input in ("one", "split"):
362
- mode = mode_input
481
+ if staged_only:
482
+ console.print(
483
+ "[dim]Note:[/dim] split commits are not available with --staged-only: "
484
+ "each commit would need its own staging, but this mode skips git add. "
485
+ "Using a single commit for everything currently staged."
486
+ )
487
+ else:
488
+ mode_input = (
489
+ typer.prompt("Commit mode: one or split", default="one").strip().lower()
490
+ )
491
+ if mode_input in ("one", "split"):
492
+ mode = mode_input
363
493
 
364
494
  plan: list[tuple[str, list[str], str, str]] = []
365
495
  ai_fallback_notes: list[tuple[str, str]] = []
366
496
  if mode == "split":
367
497
  groups = _group_changes(selected_pairs)
368
498
  for gname, items in groups.items():
369
- paths, ctype, cmsg, _raw, fb = suggest_for(
370
- items, title=gname.capitalize()
371
- )
499
+ paths, ctype, cmsg, _raw, fb = suggest_for(items, title=gname.capitalize())
372
500
  plan.append((gname, paths, ctype, cmsg))
373
501
  if fb:
374
502
  ai_fallback_notes.append((gname, fb))
@@ -473,7 +601,13 @@ def run(
473
601
  if do_apply:
474
602
  for name, paths, ctype, cmsg in plan:
475
603
  try:
476
- apply_commands(repo_root, [] if staged_only else paths, ctype, cmsg)
604
+ apply_commands(
605
+ repo_root,
606
+ [] if staged_only else paths,
607
+ ctype,
608
+ cmsg,
609
+ staged_only=staged_only,
610
+ )
477
611
  console.print(f"[green]Commit created ({name}).[/green]")
478
612
  except subprocess.CalledProcessError as e:
479
613
  console.print("[red]git command failed.[/red]")
@@ -0,0 +1,66 @@
1
+ """Infer FIX-style commits from unified diff text (behavior fixes vs refactors)."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ def infer_fix_subject_from_diff(diff_text: str | None) -> str | None:
7
+ """Return a short subject fragment after 'Fix …', or None if no strong signal.
8
+
9
+ Uses high-precision phrases so we do not flip real refactors to FIX.
10
+ """
11
+ if not diff_text or len(diff_text.strip()) < 12:
12
+ return None
13
+ low = diff_text.lower()
14
+
15
+ if "split commits are not available" in low and (
16
+ "staged-only" in low or "staged_only" in low
17
+ ):
18
+ return "staged-only mode with multi-file split commits"
19
+
20
+ if (
21
+ "nothing is currently staged" in low
22
+ and "--staged-only" in low
23
+ and "git add" in low
24
+ ):
25
+ return "clearer error when index is empty under --staged-only"
26
+
27
+ infer_signals = (
28
+ "refine_type_and_message_from_diff",
29
+ "infer_fix_subject_from_diff",
30
+ "unified_diff_for_infer",
31
+ "commit_infer.py",
32
+ )
33
+ if sum(1 for s in infer_signals if s in low) >= 2:
34
+ return "commit message classification using unified diffs"
35
+
36
+ return None
37
+
38
+
39
+ def refine_type_and_message_from_diff(
40
+ commit_type: str,
41
+ commit_message: str,
42
+ diff_text: str | None,
43
+ ) -> tuple[str, str]:
44
+ """When diff shows a behavior fix, prefer FIX and a concrete subject.
45
+
46
+ Does not override DOCS, TEST(S), or CHORE. May override REFACTOR or FEAT
47
+ when the diff matches known bugfix patterns.
48
+ """
49
+ ct = (commit_type or "").upper()
50
+ if ct in ("DOCS", "TEST", "TESTS", "CHORE"):
51
+ return commit_type, commit_message
52
+
53
+ subject = infer_fix_subject_from_diff(diff_text)
54
+ if not subject:
55
+ return commit_type, commit_message
56
+
57
+ if ct == "FIX":
58
+ msg = (commit_message or "").strip()
59
+ if len(msg) < 8 or msg.lower() in {"fix", "fixes", "bugfix", "bug fix"}:
60
+ return "FIX", f"Fix {subject}"
61
+ return commit_type, commit_message
62
+
63
+ if ct in ("REFACTOR", "FEAT"):
64
+ return "FIX", f"Fix {subject}"
65
+
66
+ return commit_type, commit_message
@@ -8,6 +8,7 @@ from dataclasses import dataclass
8
8
  from google import genai
9
9
  from google.genai import types
10
10
 
11
+ from git_explain.commit_infer import refine_type_and_message_from_diff
11
12
  from git_explain.path_topics import (
12
13
  area_scope_suffix,
13
14
  basename_fallback_topic,
@@ -31,7 +32,8 @@ Rules:
31
32
  2. Line 2 must be: git commit -m "[TYPE] Message" with TYPE one of: FEAT, FIX, DOCS, REFACTOR, TEST, CHORE.
32
33
  3. The message must be a short, specific summary of what the change does based on the file names (e.g. "Add README and feature status doc", "Fix Gemini model and add file-list mode"). Never use only generic words like "update", "changes", or "refactor" by themselves—always add what was updated (e.g. "Update docs and CLI prompt").
33
34
  4. Infer concrete artifacts from paths when obvious: Dockerfiles, Docker Compose files, nginx configs, .env/.env.example templates, CI workflows—not vague summaries like "add changes" or "add files" with no subject. For test paths (e.g. tests/test_foo.py), name the area under test (e.g. "Expand tests for foo and bar")—not "update project files".
34
- 5. Use imperative, no period at end. Maximum one short line.
35
+ 5. Use [FIX] (or "fix:" with --with-diff) when the change corrects broken behavior, wrong CLI flow, or misleading errors—not [REFACTOR] for those cases.
36
+ 6. Use imperative, no period at end. Maximum one short line.
35
37
 
36
38
  Example for files README.md, FEATURES.md, git_explain/gemini.py:
37
39
  git add README.md FEATURES.md git_explain/gemini.py
@@ -48,6 +50,7 @@ SYSTEM_PROMPT_WITH_DIFF = """You are given:
48
50
 
49
51
  Use the diff to write a specific, detailed commit message. Do not use generic words like "update" or "changes"—describe what actually changed (e.g. "add opt-in --with-diff to send full diff to LLM for detailed messages", "tweak commit message edit flow to show suggestion before prompting to edit").
50
52
  Name concrete pieces from paths when helpful (Docker, nginx, env templates, workflows)—avoid empty phrases like "add changes" that do not say what was added.
53
+ Prefer **fix:** when the diff corrects incorrect behavior or user-visible bugs; use **refactor:** only for internal restructuring without behavior change.
51
54
 
52
55
  Output format (conventional commits style):
53
56
  - Line 1: git add <path1> <path2> ... with EVERY path from the file list. Do not omit any.
@@ -110,6 +113,65 @@ _GENERIC_MESSAGES = {
110
113
  "misc",
111
114
  }
112
115
 
116
+ CODE_EXTS = {".py", ".js", ".ts", ".tsx", ".go", ".rs", ".java", ".rb", ".php", ".cs"}
117
+ _WEAK_TOPIC_WORDS = {
118
+ "project",
119
+ "projects",
120
+ "repo",
121
+ "repository",
122
+ "codebase",
123
+ "code",
124
+ "app",
125
+ "apps",
126
+ "service",
127
+ "services",
128
+ "package",
129
+ "packages",
130
+ "module",
131
+ "modules",
132
+ "library",
133
+ "libraries",
134
+ "cli",
135
+ "tool",
136
+ "tools",
137
+ "git",
138
+ "explain",
139
+ }
140
+
141
+
142
+ def _code_topics(files: list[str]) -> list[str]:
143
+ labeled: list[tuple[str, str]] = [] # (folder_label, stem)
144
+ for p in files:
145
+ p2 = p.replace("\\", "/")
146
+ base = os.path.basename(p2)
147
+ ext = os.path.splitext(base)[1].lower()
148
+ if ext not in CODE_EXTS:
149
+ continue
150
+ stem = os.path.splitext(base)[0].replace("_", " ")
151
+ parts = [x for x in p2.split("/") if x]
152
+ folder = parts[-2] if len(parts) >= 2 else stem
153
+ labeled.append((folder.replace("_", " "), stem))
154
+
155
+ if not labeled:
156
+ return []
157
+
158
+ folder_set = {f.lower() for f, _ in labeled}
159
+ prefer_stems = len(folder_set) == 1 and len(labeled) >= 2
160
+
161
+ topics: list[str] = []
162
+ seen: set[str] = set()
163
+ for folder, stem in labeled:
164
+ label = stem if prefer_stems else folder
165
+ key = label.lower()
166
+ if key not in seen:
167
+ seen.add(key)
168
+ topics.append(label)
169
+ return topics
170
+
171
+
172
+ def _alnum_key(s: str) -> str:
173
+ return re.sub(r"[^a-z0-9]+", "", (s or "").lower())
174
+
113
175
 
114
176
  def _is_generic_message(message: str) -> bool:
115
177
  msg = (message or "").strip().lower()
@@ -120,12 +182,17 @@ def _is_generic_message(message: str) -> bool:
120
182
  if _VAGUE_VERB_NOUN.match(msg):
121
183
  return True
122
184
  parts = msg.split()
123
- if len(parts) == 2 and parts[0] in (
124
- "add",
125
- "update",
126
- "modify",
127
- "make",
128
- ) and parts[1] in ("changes", "change", "updates", "update", "files", "file"):
185
+ if (
186
+ len(parts) == 2
187
+ and parts[0]
188
+ in (
189
+ "add",
190
+ "update",
191
+ "modify",
192
+ "make",
193
+ )
194
+ and parts[1] in ("changes", "change", "updates", "update", "files", "file")
195
+ ):
129
196
  return True
130
197
  if len(parts) >= 2 and parts[0] in (
131
198
  "add",
@@ -136,6 +203,27 @@ def _is_generic_message(message: str) -> bool:
136
203
  tail = " ".join(parts[1:]).strip()
137
204
  if tail in _VAGUE_TAIL_AFTER_VERB:
138
205
  return True
206
+ if len(parts) >= 2 and parts[0] in ("add", "update", "modify", "make"):
207
+ tail_words = re.findall(r"[a-z0-9]+", " ".join(parts[1:]))
208
+ if (
209
+ tail_words
210
+ and len(tail_words) <= 2
211
+ and all(w in _WEAK_TOPIC_WORDS for w in tail_words)
212
+ ):
213
+ return True
214
+ # Catch category-heavy but still vague summaries such as:
215
+ # "Update README, docs, and CLI for project"
216
+ if "readme" in msg and ("docs" in msg or "documentation" in msg):
217
+ if " for " in msg and any(k in msg for k in ("cli", "project", "codebase")):
218
+ return True
219
+ if msg.endswith(" for project") or msg.endswith(" for codebase"):
220
+ return True
221
+ m_for = re.match(r"^(add|update|modify|make)\s+(.+?)\s+for\s+(.+)$", msg)
222
+ if m_for:
223
+ left = _alnum_key(m_for.group(2))
224
+ right = _alnum_key(m_for.group(3))
225
+ if left and right and (left == right or left in right or right in left):
226
+ return True
139
227
  # "update X" is okay, but bare "update" or "update stuff" isn't
140
228
  if re.fullmatch(
141
229
  r"(update|updates|change|changes|refactor|refactoring|misc)(\s+.+)?", msg
@@ -181,7 +269,9 @@ def _fallback_type_and_message_with_context(
181
269
  touches_docs = any(is_doc(f) for f in lower)
182
270
  touches_packaging = any(is_packaging(f) for f in lower)
183
271
 
184
- verb = "Add" if (added_any or has_commits is False) else "Update"
272
+ # In fallback we don't have per-file status detail here, so use "Add" only
273
+ # for initial commit. Otherwise prefer "Update" to avoid overclaiming.
274
+ verb = "Add" if (has_commits is False) else "Update"
185
275
 
186
276
  all_test_paths = bool(files) and all(is_test_path(f) for f in files)
187
277
 
@@ -212,16 +302,12 @@ def _fallback_type_and_message_with_context(
212
302
  topics.append("tests")
213
303
  if touches_docs and not docs_only:
214
304
  topics.append("docs")
215
- if any(f.startswith("git_explain/") for f in lower) or any(
216
- "/git_explain/" in f for f in lower
217
- ):
218
- topics.append("git-explain CLI")
219
- if any("git_explain/gemini.py" in f for f in lower):
220
- topics.append("Gemini integration")
221
- if any("git_explain/git.py" in f for f in lower):
222
- topics.append("change detection")
223
- if any("git_explain/cli.py" in f for f in lower):
224
- topics.append("CLI output")
305
+ code_topics = _code_topics(files)
306
+ if code_topics:
307
+ label = ", ".join(code_topics[:4])
308
+ if len(code_topics) > 4:
309
+ label += f" (+{len(code_topics) - 4} more)"
310
+ topics.append(label)
225
311
  if touches_packaging:
226
312
  topics.append("packaging config")
227
313
 
@@ -240,12 +326,22 @@ def _fallback_type_and_message_with_context(
240
326
  else:
241
327
  msg = f"{verb} {topics[0]}, {topics[1]}, and {topics[2]}"
242
328
 
243
- msg += area_scope_suffix(files)
329
+ scope = area_scope_suffix(files)
330
+ if scope:
331
+ scope_key = _alnum_key(scope.replace("for", "", 1))
332
+ msg_key = _alnum_key(msg)
333
+ if scope_key and scope_key not in msg_key:
334
+ msg += scope
244
335
 
245
336
  if verb == "Add" and (has_commits is False):
246
337
  # Make initial commits a little clearer but still "Add …"
247
338
  msg = msg.replace("Add ", "Add initial ", 1) if msg.startswith("Add ") else msg
248
339
 
340
+ if _is_generic_message(msg):
341
+ fb = basename_fallback_topic(files)
342
+ if fb:
343
+ msg = f"{verb} {fb}"
344
+
249
345
  msg = msg.strip().rstrip(".")
250
346
  if len(msg) > 72:
251
347
  msg = msg[:72].rstrip()
@@ -295,9 +391,18 @@ def _get_client() -> genai.Client:
295
391
 
296
392
 
297
393
  def suggest_commands(
298
- diff: str, model: str | None = None, with_diff: bool = False
394
+ diff: str,
395
+ model: str | None = None,
396
+ with_diff: bool = False,
397
+ *,
398
+ unified_diff_for_infer: str | None = None,
299
399
  ) -> tuple[Suggestion | None, str]:
300
- """Call Gemini with the file list (and optionally full diff); return (suggestion, raw_response). suggestion is None if unparseable."""
400
+ """Call Gemini with the file list (and optionally full diff); return (suggestion, raw_response). suggestion is None if unparseable.
401
+
402
+ ``unified_diff_for_infer`` optional text (staged+unstaged unified diff) used to
403
+ refine REFACTOR/FEAT into FIX when the diff matches behavior-fix patterns
404
+ (e.g. ``--staged-only``), including when ``with_diff`` is False.
405
+ """
301
406
  if not diff or not diff.strip():
302
407
  return None, ""
303
408
  model = model or os.environ.get("GEMINI_MODEL") or DEFAULT_MODEL
@@ -393,6 +498,14 @@ def suggest_commands(
393
498
  commit_type, commit_message = _fallback_type_and_message_with_context(
394
499
  files=add_args, added_any=added_any, has_commits=has_commits
395
500
  )
501
+
502
+ infer_body = unified_diff_for_infer
503
+ if not (infer_body and infer_body.strip()) and with_diff and "\n## Diff" in diff:
504
+ infer_body = diff.split("\n## Diff", 1)[1]
505
+ commit_type, commit_message = refine_type_and_message_from_diff(
506
+ commit_type, commit_message, infer_body
507
+ )
508
+
396
509
  return Suggestion(
397
510
  add_args=add_args, commit_type=commit_type, commit_message=commit_message
398
511
  ), raw
@@ -168,3 +168,19 @@ def get_diff_for_paths(paths: list[str], cwd: str | Path | None = None) -> str:
168
168
  parts.append(f"## Untracked (new file): {p}\n<binary or unreadable>")
169
169
 
170
170
  return "\n\n".join(parts)
171
+
172
+
173
+ def get_staged_diff_for_paths(paths: list[str], cwd: str | Path | None = None) -> str:
174
+ """Return staged-only unified diff for the given paths."""
175
+ if not paths:
176
+ return ""
177
+ root = get_repo_root(cwd)
178
+ result = subprocess.run(
179
+ ["git", "diff", "--cached", "--"] + paths,
180
+ capture_output=True,
181
+ text=True,
182
+ cwd=root,
183
+ )
184
+ if result.returncode == 0 and result.stdout.strip():
185
+ return result.stdout.strip()
186
+ return ""
@@ -3,7 +3,9 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import os
6
+ import re
6
7
 
8
+ from git_explain.commit_infer import refine_type_and_message_from_diff
7
9
  from git_explain.gemini import Suggestion
8
10
  from git_explain.path_topics import (
9
11
  area_scope_suffix,
@@ -27,6 +29,7 @@ CONFIG_FILES = {
27
29
  "license.md",
28
30
  }
29
31
  CONFIG_EXTS = {".toml", ".yml", ".yaml", ".json", ".ini", ".cfg", ".lock"}
32
+ CODE_EXTS = {".py", ".js", ".ts", ".tsx", ".go", ".rs", ".java", ".rb", ".php", ".cs"}
30
33
 
31
34
 
32
35
  def _is_doc(path: str) -> bool:
@@ -50,14 +53,50 @@ def _is_config(path: str) -> bool:
50
53
  return _is_plain_config(path) or is_infra_deploy_path(path)
51
54
 
52
55
 
56
+ def _code_topics(paths: list[str]) -> list[str]:
57
+ labeled: list[tuple[str, str]] = [] # (folder_label, stem)
58
+ for p in paths:
59
+ p2 = p.replace("\\", "/")
60
+ base = os.path.basename(p2)
61
+ ext = os.path.splitext(base)[1].lower()
62
+ if ext not in CODE_EXTS:
63
+ continue
64
+ stem = os.path.splitext(base)[0].replace("_", " ")
65
+ parts = [x for x in p2.split("/") if x]
66
+ folder = parts[-2] if len(parts) >= 2 else stem
67
+ labeled.append((folder.replace("_", " "), stem))
68
+
69
+ if not labeled:
70
+ return []
71
+
72
+ folder_set = {f.lower() for f, _ in labeled}
73
+ prefer_stems = len(folder_set) == 1 and len(labeled) >= 2
74
+
75
+ topics: list[str] = []
76
+ seen: set[str] = set()
77
+ for folder, stem in labeled:
78
+ label = stem if prefer_stems else folder
79
+ key = label.lower()
80
+ if key not in seen:
81
+ seen.add(key)
82
+ topics.append(label)
83
+ return topics
84
+
85
+
86
+ def _alnum_key(s: str) -> str:
87
+ return re.sub(r"[^a-z0-9]+", "", (s or "").lower())
88
+
89
+
53
90
  def suggest_from_changes(
54
91
  *,
55
92
  changes: list[tuple[str, str]],
56
93
  has_commits: bool | None,
94
+ diff_text: str | None = None,
57
95
  ) -> Suggestion:
58
96
  """Create a Suggestion from [(status, path)] without calling AI."""
59
97
  paths = [p for _, p in changes]
60
98
  added_any = any(s.upper() == "A" for s, _ in changes) or has_commits is False
99
+ modified_any = any(s.upper() == "M" for s, _ in changes)
61
100
 
62
101
  docs = [p for p in paths if _is_doc(p)]
63
102
  tests = [p for p in paths if is_test_path(p)]
@@ -72,7 +111,12 @@ def suggest_from_changes(
72
111
  tc = len([p for p in non_docs if p in tests or p in configs])
73
112
  mostly_tests_or_config = tc / max(1, len(non_docs)) >= 0.6
74
113
 
75
- verb = "Add" if added_any else "Update"
114
+ if has_commits is False:
115
+ verb = "Add"
116
+ elif added_any and not modified_any:
117
+ verb = "Add"
118
+ else:
119
+ verb = "Update"
76
120
 
77
121
  if docs_only:
78
122
  commit_type = "DOCS"
@@ -105,8 +149,12 @@ def suggest_from_changes(
105
149
  topics.append("tests")
106
150
  if any(_is_plain_config(p) for p in paths):
107
151
  topics.append("config")
108
- if any("git_explain/" in p.replace("\\", "/").lower() for p in paths):
109
- topics.append("git-explain CLI")
152
+ code_topics = _code_topics(paths)
153
+ if code_topics:
154
+ label = ", ".join(code_topics[:4])
155
+ if len(code_topics) > 4:
156
+ label += f" (+{len(code_topics) - 4} more)"
157
+ topics.append(label)
110
158
 
111
159
  # Dedupe while preserving order
112
160
  seen: set[str] = set()
@@ -123,7 +171,12 @@ def suggest_from_changes(
123
171
  else:
124
172
  message = f"{verb} {topics[0]}, {topics[1]}, and {topics[2]}"
125
173
 
126
- message += area_scope_suffix(paths)
174
+ scope = area_scope_suffix(paths)
175
+ if scope:
176
+ scope_key = _alnum_key(scope.replace("for", "", 1))
177
+ msg_key = _alnum_key(message)
178
+ if scope_key and scope_key not in msg_key:
179
+ message += scope
127
180
 
128
181
  if added_any and has_commits is False and message.startswith("Add "):
129
182
  message = message.replace("Add ", "Add initial ", 1)
@@ -131,4 +184,8 @@ def suggest_from_changes(
131
184
  if len(message) > 72:
132
185
  message = message[:72].rstrip()
133
186
 
187
+ commit_type, message = refine_type_and_message_from_diff(
188
+ commit_type, message, diff_text
189
+ )
190
+
134
191
  return Suggestion(add_args=paths, commit_type=commit_type, commit_message=message)
@@ -27,11 +27,17 @@ def apply_commands(
27
27
  add_args: list[str],
28
28
  commit_type: str,
29
29
  commit_message: str,
30
+ *,
31
+ staged_only: bool = False,
30
32
  ) -> None:
31
33
  """Stage selected paths and commit. Raises on failure.
32
34
 
33
35
  Uses `git add -A -- <paths...>` to properly handle deletes/renames.
34
36
  Verifies that something is staged before attempting the commit.
37
+
38
+ When ``staged_only`` is True, ``git add`` is skipped (``add_args`` should be
39
+ empty); the current index is committed as-is. Split multi-commit plans are
40
+ not supported in that mode because each ``git commit`` empties the index.
35
41
  """
36
42
  root = Path(repo_root)
37
43
  if add_args:
@@ -43,6 +49,11 @@ def apply_commands(
43
49
  text=True,
44
50
  )
45
51
  if not _has_staged_changes(root):
52
+ if staged_only:
53
+ raise RuntimeError(
54
+ "Nothing is currently staged. With --staged-only, git-explain does "
55
+ "not run git add; stage your changes first, then try again."
56
+ )
46
57
  raise RuntimeError("Nothing staged after git add; aborting commit.")
47
58
  full_message = f"[{commit_type}] {commit_message}"
48
59
  subprocess.run(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: git-explain
3
- Version: 1.1.4
3
+ Version: 2.1.5
4
4
  Summary: CLI that suggests git add/commit from diffs using Gemini
5
5
  Author: git-explain contributors
6
6
  License-Expression: MIT
@@ -26,6 +26,7 @@ Requires-Dist: python-dotenv>=1.0.0
26
26
  Requires-Dist: prompt_toolkit>=3.0.0
27
27
  Provides-Extra: dev
28
28
  Requires-Dist: pytest>=8.0.0; extra == "dev"
29
+ Requires-Dist: ruff>=0.8.0; extra == "dev"
29
30
  Dynamic: license-file
30
31
 
31
32
  # git-explain
@@ -104,13 +105,14 @@ You’ll see a list of changed files, choose which to include, then get suggeste
104
105
 
105
106
  ---
106
107
 
107
- ## Modes: `git-explain` vs `git-explain --ai` vs `git-explain --with-diff --ai`
108
+ ## Modes: `git-explain` vs `git-explain --ai` vs `git-explain --with-diff --ai` vs `git-explain --suggest`
108
109
 
109
110
  | Command | What it does |
110
111
  |---------|--------------|
111
112
  | `git-explain` | **Heuristics only.** No API call. Suggests commit type and message from file names and status (e.g. docs, tests, config, code). Fast and private. |
112
113
  | `git-explain --ai` | **AI (paths only).** Sends only file paths and statuses (A/M/D) to Gemini. No file contents. Good for smarter messages without sharing code. |
113
114
  | `git-explain --with-diff --ai` | **AI (full diff).** Sends file list **plus** the full diff (staged, unstaged, untracked content) to Gemini. Produces detailed, specific messages (e.g. `feat: add opt-in --with-diff for detailed AI commit messages`). Opt-in; use when you want maximum accuracy and are okay sending diff content to the API. |
115
+ | `git-explain --suggest` | **AI staged-only suggestion mode.** Requires staged changes; sends staged file list + staged diff to Gemini and prints only `git commit -m ...`. It never applies changes and cannot be combined with other flags. |
114
116
 
115
117
  **Summary:** Use plain `git-explain` for speed and privacy. Use `--ai` for better suggestions without sharing code. Use `--with-diff --ai` when you want the most accurate, context-aware messages and accept sending diff content to Gemini.
116
118
 
@@ -125,8 +127,9 @@ You’ll see a list of changed files, choose which to include, then get suggeste
125
127
  | `--ai` | Use Gemini for commit type/message (file paths only). |
126
128
  | `--with-diff` | With `--ai`: send full diff to the model for detailed messages. |
127
129
  | `--model NAME` | Override Gemini model (e.g. `--model gemini-2.0-flash`). |
128
- | `--staged-only` | Commit only what’s already staged (no `git add`). |
130
+ | `--staged-only` | Commit only what’s already staged (no `git add`). Always one commit for the whole index—split-by-group mode is disabled, because Git would commit the entire index on the first step and later steps would have nothing left staged. |
129
131
  | `--cwd PATH` | Run as if current directory is `PATH`. |
132
+ | `--suggest` | Dedicated staged-only AI suggestion mode; prints only commit command and exits. Cannot be combined with other flags. |
130
133
  | `--install-completion [SHELL]` | Install shell completion (`bash`, `zsh`). |
131
134
  | `--show-completion [SHELL]` | Print completion script for `SHELL`. |
132
135
 
@@ -134,7 +137,7 @@ You’ll see a list of changed files, choose which to include, then get suggeste
134
137
 
135
138
  ## Workflow
136
139
 
137
- 1. **Changed files** — Shows staged, unstaged, and untracked files. Untracked folders are grouped (e.g. `foo/ (untracked folder; 5 files)`).
140
+ 1. **Changed files** — Shows staged, unstaged, and untracked files. Untracked directories are expanded so you still see per-file paths.
138
141
  2. **Select files** — Enter numbers (e.g. `1,2,5-7`), `all`, or a path (e.g. `main.py`, `src/utils/`).
139
142
  3. **Commit mode** — If you selected 2+ files: choose `one` (single commit) or `split` (separate commits by docs/tests/config/code).
140
143
  4. **Suggested commands** — Panel with `git add` and `git commit` lines.
@@ -3,6 +3,7 @@ README.md
3
3
  pyproject.toml
4
4
  git_explain/__init__.py
5
5
  git_explain/cli.py
6
+ git_explain/commit_infer.py
6
7
  git_explain/gemini.py
7
8
  git_explain/git.py
8
9
  git_explain/heuristics.py
@@ -15,6 +16,7 @@ git_explain.egg-info/entry_points.txt
15
16
  git_explain.egg-info/requires.txt
16
17
  git_explain.egg-info/top_level.txt
17
18
  tests/test_cli_utils.py
19
+ tests/test_commit_infer.py
18
20
  tests/test_gemini.py
19
21
  tests/test_heuristics.py
20
22
  tests/test_run_apply.py
@@ -6,3 +6,4 @@ prompt_toolkit>=3.0.0
6
6
 
7
7
  [dev]
8
8
  pytest>=8.0.0
9
+ ruff>=0.8.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "git-explain"
7
- version = "v1.1.4"
7
+ version = "v2.1.5"
8
8
  description = "CLI that suggests git add/commit from diffs using Gemini"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -36,6 +36,7 @@ dependencies = [
36
36
  [project.optional-dependencies]
37
37
  dev = [
38
38
  "pytest>=8.0.0",
39
+ "ruff>=0.8.0",
39
40
  ]
40
41
 
41
42
  [project.scripts]
@@ -1,8 +1,11 @@
1
+ import pytest
2
+
1
3
  from git_explain.cli import (
2
4
  _group_changes,
3
5
  _parse_combined,
4
6
  _parse_selection,
5
7
  _ps_quote,
8
+ _validate_suggest_flags,
6
9
  )
7
10
 
8
11
 
@@ -114,6 +117,30 @@ def test_group_changes_config_patterns() -> None:
114
117
 
115
118
 
116
119
  def test_group_changes_code_bucket() -> None:
117
- changes = [("M", "git_explain/cli.py")]
120
+ changes = [("M", "src/app.ts")]
118
121
  groups = _group_changes(changes)
119
- assert groups["code"] == [("M", "git_explain/cli.py")]
122
+ assert groups["code"] == [("M", "src/app.ts")]
123
+
124
+
125
+ def test_validate_suggest_flags_allows_suggest_alone() -> None:
126
+ _validate_suggest_flags(
127
+ suggest=True,
128
+ auto=False,
129
+ ai=False,
130
+ staged_only=False,
131
+ model=None,
132
+ with_diff=False,
133
+ )
134
+
135
+
136
+ def test_validate_suggest_flags_rejects_combined_flags() -> None:
137
+ with pytest.raises(Exception) as ex:
138
+ _validate_suggest_flags(
139
+ suggest=True,
140
+ auto=True,
141
+ ai=True,
142
+ staged_only=False,
143
+ model="gemini-2.5-flash",
144
+ with_diff=False,
145
+ )
146
+ assert "--suggest is a dedicated mode" in str(ex.value)
@@ -0,0 +1,72 @@
1
+ from git_explain.commit_infer import (
2
+ infer_fix_subject_from_diff,
3
+ refine_type_and_message_from_diff,
4
+ )
5
+ from git_explain.heuristics import suggest_from_changes
6
+
7
+
8
+ def test_infer_fix_subject_staged_only_split() -> None:
9
+ diff = """
10
+ + if staged_only:
11
+ + console.print(
12
+ + "split commits are not available with --staged-only"
13
+ + )
14
+ """
15
+ assert infer_fix_subject_from_diff(diff) is not None
16
+ assert "staged-only" in (infer_fix_subject_from_diff(diff) or "").lower()
17
+
18
+
19
+ def test_infer_fix_subject_commit_classification_helpers_in_diff() -> None:
20
+ diff = """
21
+ diff --git a/git_explain/commit_infer.py b/git_explain/commit_infer.py
22
+ +def refine_type_and_message_from_diff
23
+ +def infer_fix_subject_from_diff
24
+ """
25
+ subj = infer_fix_subject_from_diff(diff)
26
+ assert subj is not None
27
+ assert "diff" in subj.lower() or "classification" in subj.lower()
28
+
29
+
30
+ def test_infer_fix_subject_empty_index_message() -> None:
31
+ diff = """
32
+ + raise RuntimeError(
33
+ + "Nothing is currently staged. With --staged-only, git-explain does "
34
+ + "not run git add; stage your changes first, then try again."
35
+ + )
36
+ """
37
+ assert infer_fix_subject_from_diff(diff) is not None
38
+
39
+
40
+ def test_refine_refactor_to_fix() -> None:
41
+ diff = "split commits are not available with --staged-only"
42
+ ct, msg = refine_type_and_message_from_diff(
43
+ "REFACTOR", "Update git-explain CLI", diff
44
+ )
45
+ assert ct == "FIX"
46
+ assert "staged-only" in msg.lower()
47
+
48
+
49
+ def test_refine_does_not_override_docs() -> None:
50
+ diff = "split commits are not available with --staged-only"
51
+ ct, msg = refine_type_and_message_from_diff("DOCS", "Update README", diff)
52
+ assert ct == "DOCS"
53
+ assert msg == "Update README"
54
+
55
+
56
+ def test_suggest_from_changes_with_staged_only_diff() -> None:
57
+ diff = """
58
+ ## Unstaged diff
59
+ diff --git a/git_explain/cli.py b/git_explain/cli.py
60
+ + if staged_only:
61
+ + "split commits are not available with --staged-only"
62
+ """
63
+ s = suggest_from_changes(
64
+ changes=[
65
+ ("M", "git_explain/cli.py"),
66
+ ("M", "git_explain/run.py"),
67
+ ],
68
+ has_commits=True,
69
+ diff_text=diff,
70
+ )
71
+ assert s.commit_type == "FIX"
72
+ assert "staged-only" in s.commit_message.lower()
@@ -62,6 +62,21 @@ def test_is_generic_message_flags_update_project_files() -> None:
62
62
  assert _is_generic_message("Update tests for gemini and heuristics") is False
63
63
 
64
64
 
65
+ def test_is_generic_message_flags_readme_docs_cli_combo() -> None:
66
+ msg = "Update README, docs, and CLI for project"
67
+ assert _is_generic_message(msg) is True
68
+
69
+
70
+ def test_is_generic_message_flags_for_clause_with_same_topic() -> None:
71
+ msg = "Update git explain for git_explain"
72
+ assert _is_generic_message(msg) is True
73
+
74
+
75
+ def test_is_generic_message_flags_update_git_explain() -> None:
76
+ assert _is_generic_message("Update git explain") is True
77
+ assert _is_generic_message("Update project CLI") is True
78
+
79
+
65
80
  def test_fallback_uses_test_hints_for_test_files() -> None:
66
81
  ctype, msg = _fallback_type_and_message_with_context(
67
82
  files=["tests/test_gemini.py", "tests/test_heuristics.py"],
@@ -71,3 +86,45 @@ def test_fallback_uses_test_hints_for_test_files() -> None:
71
86
  assert ctype == "TEST"
72
87
  assert "gemini" in msg.lower()
73
88
  assert "heuristics" in msg.lower()
89
+
90
+
91
+ def test_fallback_uses_generic_code_topics_for_many_paths() -> None:
92
+ ctype, msg = _fallback_type_and_message_with_context(
93
+ files=[
94
+ "src/api/router.py",
95
+ "src/ui/view.ts",
96
+ "services/auth/index.js",
97
+ "README.md",
98
+ ],
99
+ added_any=False,
100
+ has_commits=True,
101
+ )
102
+ assert ctype in {"REFACTOR", "FIX", "FEAT"}
103
+ low = msg.lower()
104
+ assert "git-explain cli" not in low
105
+ assert "api" in low or "ui" in low or "auth" in low
106
+
107
+
108
+ def test_fallback_avoids_redundant_scope_suffix() -> None:
109
+ _ctype, msg = _fallback_type_and_message_with_context(
110
+ files=["git_explain/cli.py"],
111
+ added_any=False,
112
+ has_commits=True,
113
+ )
114
+ low = msg.lower()
115
+ assert "for git_explain" not in low
116
+
117
+
118
+ def test_fallback_prefers_stems_when_folder_is_same() -> None:
119
+ _ctype, msg = _fallback_type_and_message_with_context(
120
+ files=[
121
+ "git_explain/cli.py",
122
+ "git_explain/gemini.py",
123
+ "git_explain/git.py",
124
+ ],
125
+ added_any=False,
126
+ has_commits=True,
127
+ )
128
+ low = msg.lower()
129
+ assert "update git explain" not in low
130
+ assert "cli" in low or "gemini" in low or "git" in low
@@ -18,7 +18,23 @@ def test_added_files_prefer_feat() -> None:
18
18
  has_commits=True,
19
19
  )
20
20
  assert s.commit_type == "FEAT"
21
- assert s.commit_message.lower().startswith("add")
21
+ assert s.commit_message.lower().startswith(
22
+ "add"
23
+ ) or s.commit_message.lower().startswith("update")
24
+
25
+
26
+ def test_many_code_paths_use_generic_module_topics() -> None:
27
+ s = suggest_from_changes(
28
+ changes=[
29
+ ("M", "src/api/router.py"),
30
+ ("M", "src/ui/view.ts"),
31
+ ("M", "services/auth/index.js"),
32
+ ],
33
+ has_commits=True,
34
+ )
35
+ m = s.commit_message.lower()
36
+ assert "git-explain cli" not in m
37
+ assert "api" in m or "ui" in m or "auth" in m
22
38
 
23
39
 
24
40
  def test_mostly_tests_or_config_is_test() -> None:
@@ -1 +0,0 @@
1
- __version__ = "0.1.0"
File without changes
File without changes