git-explain 1.1.3__tar.gz → 2.1.4__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 (26) hide show
  1. {git_explain-1.1.3 → git_explain-2.1.4}/PKG-INFO +4 -3
  2. {git_explain-1.1.3 → git_explain-2.1.4}/README.md +2 -2
  3. {git_explain-1.1.3 → git_explain-2.1.4}/git_explain/cli.py +88 -44
  4. git_explain-2.1.4/git_explain/commit_infer.py +66 -0
  5. {git_explain-1.1.3 → git_explain-2.1.4}/git_explain/gemini.py +109 -10
  6. {git_explain-1.1.3 → git_explain-2.1.4}/git_explain/heuristics.py +62 -24
  7. git_explain-2.1.4/git_explain/path_topics.py +178 -0
  8. {git_explain-1.1.3 → git_explain-2.1.4}/git_explain/run.py +11 -0
  9. {git_explain-1.1.3 → git_explain-2.1.4}/git_explain.egg-info/PKG-INFO +4 -3
  10. {git_explain-1.1.3 → git_explain-2.1.4}/git_explain.egg-info/SOURCES.txt +3 -0
  11. {git_explain-1.1.3 → git_explain-2.1.4}/git_explain.egg-info/requires.txt +1 -0
  12. {git_explain-1.1.3 → git_explain-2.1.4}/pyproject.toml +2 -1
  13. git_explain-2.1.4/tests/test_commit_infer.py +72 -0
  14. git_explain-2.1.4/tests/test_gemini.py +73 -0
  15. git_explain-2.1.4/tests/test_heuristics.py +94 -0
  16. git_explain-1.1.3/tests/test_gemini.py +0 -37
  17. git_explain-1.1.3/tests/test_heuristics.py +0 -41
  18. {git_explain-1.1.3 → git_explain-2.1.4}/LICENSE +0 -0
  19. {git_explain-1.1.3 → git_explain-2.1.4}/git_explain/__init__.py +0 -0
  20. {git_explain-1.1.3 → git_explain-2.1.4}/git_explain/git.py +0 -0
  21. {git_explain-1.1.3 → git_explain-2.1.4}/git_explain.egg-info/dependency_links.txt +0 -0
  22. {git_explain-1.1.3 → git_explain-2.1.4}/git_explain.egg-info/entry_points.txt +0 -0
  23. {git_explain-1.1.3 → git_explain-2.1.4}/git_explain.egg-info/top_level.txt +0 -0
  24. {git_explain-1.1.3 → git_explain-2.1.4}/setup.cfg +0 -0
  25. {git_explain-1.1.3 → git_explain-2.1.4}/tests/test_cli_utils.py +0 -0
  26. {git_explain-1.1.3 → git_explain-2.1.4}/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.3
3
+ Version: 2.1.4
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
@@ -125,7 +126,7 @@ You’ll see a list of changed files, choose which to include, then get suggeste
125
126
  | `--ai` | Use Gemini for commit type/message (file paths only). |
126
127
  | `--with-diff` | With `--ai`: send full diff to the model for detailed messages. |
127
128
  | `--model NAME` | Override Gemini model (e.g. `--model gemini-2.0-flash`). |
128
- | `--staged-only` | Commit only what’s already staged (no `git add`). |
129
+ | `--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
130
  | `--cwd PATH` | Run as if current directory is `PATH`. |
130
131
  | `--install-completion [SHELL]` | Install shell completion (`bash`, `zsh`). |
131
132
  | `--show-completion [SHELL]` | Print completion script for `SHELL`. |
@@ -134,7 +135,7 @@ You’ll see a list of changed files, choose which to include, then get suggeste
134
135
 
135
136
  ## Workflow
136
137
 
137
- 1. **Changed files** — Shows staged, unstaged, and untracked files. Untracked folders are grouped (e.g. `foo/ (untracked folder; 5 files)`).
138
+ 1. **Changed files** — Shows staged, unstaged, and untracked files. Untracked directories are expanded so you still see per-file paths.
138
139
  2. **Select files** — Enter numbers (e.g. `1,2,5-7`), `all`, or a path (e.g. `main.py`, `src/utils/`).
139
140
  3. **Commit mode** — If you selected 2+ files: choose `one` (single commit) or `split` (separate commits by docs/tests/config/code).
140
141
  4. **Suggested commands** — Panel with `git add` and `git commit` lines.
@@ -95,7 +95,7 @@ You’ll see a list of changed files, choose which to include, then get suggeste
95
95
  | `--ai` | Use Gemini for commit type/message (file paths only). |
96
96
  | `--with-diff` | With `--ai`: send full diff to the model for detailed messages. |
97
97
  | `--model NAME` | Override Gemini model (e.g. `--model gemini-2.0-flash`). |
98
- | `--staged-only` | Commit only what’s already staged (no `git add`). |
98
+ | `--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
99
  | `--cwd PATH` | Run as if current directory is `PATH`. |
100
100
  | `--install-completion [SHELL]` | Install shell completion (`bash`, `zsh`). |
101
101
  | `--show-completion [SHELL]` | Print completion script for `SHELL`. |
@@ -104,7 +104,7 @@ You’ll see a list of changed files, choose which to include, then get suggeste
104
104
 
105
105
  ## Workflow
106
106
 
107
- 1. **Changed files** — Shows staged, unstaged, and untracked files. Untracked folders are grouped (e.g. `foo/ (untracked folder; 5 files)`).
107
+ 1. **Changed files** — Shows staged, unstaged, and untracked files. Untracked directories are expanded so you still see per-file paths.
108
108
  2. **Select files** — Enter numbers (e.g. `1,2,5-7`), `all`, or a path (e.g. `main.py`, `src/utils/`).
109
109
  3. **Commit mode** — If you selected 2+ files: choose `one` (single commit) or `split` (separate commits by docs/tests/config/code).
110
110
  4. **Suggested commands** — Panel with `git add` and `git commit` lines.
@@ -20,6 +20,8 @@ load_dotenv()
20
20
  app = typer.Typer()
21
21
  console = Console()
22
22
 
23
+ _DIFF_INFER_MAX_CHARS = 50_000
24
+
23
25
 
24
26
  @dataclass(frozen=True)
25
27
  class Change:
@@ -264,36 +266,11 @@ def run(
264
266
  return
265
267
 
266
268
  norm_paths = [c.path.replace("\\", "/") for c in changes]
267
- untracked_indices_by_root: dict[str, list[int]] = {}
268
- for idx, (ch, np) in enumerate(zip(changes, norm_paths)):
269
- if (
270
- "Untracked" in ch.sections
271
- and "Staged" not in ch.sections
272
- and "Unstaged" not in ch.sections
273
- and "/" in np
274
- ):
275
- root = np.split("/", 1)[0]
276
- untracked_indices_by_root.setdefault(root, []).append(idx)
277
- folder_groups = {
278
- root: idxs for root, idxs in untracked_indices_by_root.items() if len(idxs) > 1
279
- }
280
-
281
269
  display_items: list[tuple[str, list[int]]] = []
282
- seen_untracked_roots: set[str] = set()
283
270
  for idx, ch in enumerate(changes):
284
- np = norm_paths[idx]
285
- root = np.split("/", 1)[0] if "/" in np else None
286
- if root and root in folder_groups:
287
- if root in seen_untracked_roots:
288
- continue
289
- seen_untracked_roots.add(root)
290
- count = len(folder_groups[root])
291
- label = f"{root}/ (untracked folder; {count} files)"
292
- display_items.append((label, folder_groups[root]))
293
- else:
294
- sec = ",".join([s.lower() for s in ch.sections if s and s != "Meta"])
295
- label = f"[{ch.status}] ({sec}) {ch.path}"
296
- display_items.append((label, [idx]))
271
+ sec = ",".join([s.lower() for s in ch.sections if s and s != "Meta"])
272
+ label = f"[{ch.status}] ({sec}) {ch.path}"
273
+ display_items.append((label, [idx]))
297
274
 
298
275
  lines = []
299
276
  for idx, (label, _idxs) in enumerate(display_items, start=1):
@@ -347,8 +324,20 @@ def run(
347
324
 
348
325
  def suggest_for(
349
326
  change_items: list[tuple[str, str]], title: str
350
- ) -> tuple[list[str], str, str, str]:
351
- # Returns (paths, type, message, raw_text)
327
+ ) -> tuple[list[str], str, str, str, str | None]:
328
+ # Returns (paths, type, message, raw_text, ai_fallback_reason).
329
+ # ai_fallback_reason is set when --ai was used but heuristics were used instead.
330
+ paths_for_infer = [p for _, p in change_items]
331
+ infer_diff: str | None = None
332
+ if paths_for_infer:
333
+ raw_d = get_diff_for_paths(paths_for_infer, cwd=repo_root)
334
+ if raw_d.strip():
335
+ infer_diff = (
336
+ raw_d[:_DIFF_INFER_MAX_CHARS]
337
+ if len(raw_d) > _DIFF_INFER_MAX_CHARS
338
+ else raw_d
339
+ )
340
+
352
341
  if ai:
353
342
  payload = _render_combined(has_commits, change_items, title=title)
354
343
  if with_diff:
@@ -357,42 +346,91 @@ def run(
357
346
  if diff_text:
358
347
  payload = payload + "\n\n## Diff\n" + diff_text
359
348
  try:
360
- sug, raw = suggest_commands(payload, model=model, with_diff=with_diff)
349
+ sug, raw = suggest_commands(
350
+ payload,
351
+ model=model,
352
+ with_diff=with_diff,
353
+ unified_diff_for_infer=infer_diff,
354
+ )
361
355
  if sug is None:
362
356
  raise RuntimeError("Could not parse AI suggestion.")
363
- return sug.add_args, sug.commit_type, sug.commit_message, raw
357
+ return sug.add_args, sug.commit_type, sug.commit_message, raw, None
364
358
  except Exception as e:
365
359
  # Fall back to heuristics on quota / API errors
366
- h = suggest_from_changes(changes=change_items, has_commits=has_commits)
360
+ h = suggest_from_changes(
361
+ changes=change_items,
362
+ has_commits=has_commits,
363
+ diff_text=infer_diff,
364
+ )
367
365
  return (
368
366
  h.add_args,
369
367
  h.commit_type,
370
368
  h.commit_message,
371
- f"AI unavailable: {e}",
369
+ "",
370
+ str(e),
372
371
  )
373
- h = suggest_from_changes(changes=change_items, has_commits=has_commits)
374
- return h.add_args, h.commit_type, h.commit_message, ""
372
+ h = suggest_from_changes(
373
+ changes=change_items,
374
+ has_commits=has_commits,
375
+ diff_text=infer_diff,
376
+ )
377
+ return h.add_args, h.commit_type, h.commit_message, "", None
375
378
 
376
379
  selected_pairs = [(ch.status, ch.path) for ch in selected]
377
380
  unique_paths = {p for _, p in selected_pairs}
378
381
 
379
382
  mode = "one"
380
383
  if len(unique_paths) > 1:
381
- mode_input = (
382
- typer.prompt("Commit mode: one or split", default="one").strip().lower()
383
- )
384
- if mode_input in ("one", "split"):
385
- mode = mode_input
384
+ if staged_only:
385
+ console.print(
386
+ "[dim]Note:[/dim] split commits are not available with --staged-only: "
387
+ "each commit would need its own staging, but this mode skips git add. "
388
+ "Using a single commit for everything currently staged."
389
+ )
390
+ else:
391
+ mode_input = (
392
+ typer.prompt("Commit mode: one or split", default="one").strip().lower()
393
+ )
394
+ if mode_input in ("one", "split"):
395
+ mode = mode_input
386
396
 
387
397
  plan: list[tuple[str, list[str], str, str]] = []
398
+ ai_fallback_notes: list[tuple[str, str]] = []
388
399
  if mode == "split":
389
400
  groups = _group_changes(selected_pairs)
390
401
  for gname, items in groups.items():
391
- paths, ctype, cmsg, _raw = suggest_for(items, title=gname.capitalize())
402
+ paths, ctype, cmsg, _raw, fb = suggest_for(items, title=gname.capitalize())
392
403
  plan.append((gname, paths, ctype, cmsg))
404
+ if fb:
405
+ ai_fallback_notes.append((gname, fb))
393
406
  else:
394
- paths, ctype, cmsg, _raw = suggest_for(selected_pairs, title="Selected")
407
+ paths, ctype, cmsg, _raw, fb = suggest_for(selected_pairs, title="Selected")
395
408
  plan.append(("one", paths, ctype, cmsg))
409
+ if fb:
410
+ ai_fallback_notes.append(("", fb))
411
+
412
+ if ai and ai_fallback_notes:
413
+ lines = [
414
+ "[bold]You used --ai, but Gemini was not used for the suggestion below.[/bold]",
415
+ "Commit message(s) come from [bold]local heuristics[/bold] instead.",
416
+ "",
417
+ ]
418
+ if mode == "split":
419
+ for gname, reason in ai_fallback_notes:
420
+ lines.append(f"[dim]{gname}:[/dim] {reason}")
421
+ else:
422
+ lines.append(ai_fallback_notes[0][1])
423
+ lines.append("")
424
+ lines.append(
425
+ "[dim]Check API key (GEMINI_API_KEY / GOOGLE_API_KEY), quota, model name, and network.[/dim]"
426
+ )
427
+ console.print(
428
+ Panel(
429
+ "\n".join(lines),
430
+ title="[yellow]Warning: AI unavailable[/yellow]",
431
+ border_style="yellow",
432
+ )
433
+ )
396
434
 
397
435
  def _render_plan(pl: list[tuple[str, list[str], str, str]]) -> str:
398
436
  rendered: list[str] = []
@@ -466,7 +504,13 @@ def run(
466
504
  if do_apply:
467
505
  for name, paths, ctype, cmsg in plan:
468
506
  try:
469
- apply_commands(repo_root, [] if staged_only else paths, ctype, cmsg)
507
+ apply_commands(
508
+ repo_root,
509
+ [] if staged_only else paths,
510
+ ctype,
511
+ cmsg,
512
+ staged_only=staged_only,
513
+ )
470
514
  console.print(f"[green]Commit created ({name}).[/green]")
471
515
  except subprocess.CalledProcessError as e:
472
516
  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,15 @@ 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
12
+ from git_explain.path_topics import (
13
+ area_scope_suffix,
14
+ basename_fallback_topic,
15
+ infra_deploy_topics,
16
+ is_test_path,
17
+ test_subject_hints,
18
+ )
19
+
11
20
  SYSTEM_PROMPT = """You are given a list of changed/added files under ## Staged, ## Unstaged, ## Untracked.
12
21
  Each file line is: <STATUS> <PATH> where STATUS is one of:
13
22
  - A = added/new file
@@ -20,13 +29,19 @@ Suggest one commit that includes ALL of these files.
20
29
 
21
30
  Rules:
22
31
  1. Line 1 must be: git add <path1> <path2> ... with EVERY PATH from the list (all sections). Do not omit any file. Do not truncate. Do not include status letters.
23
- 2. Line 2 must be: git commit -m "[TYPE] Message" with TYPE one of: FEAT, FIX, DOCS, REFACTOR, TEST.
32
+ 2. Line 2 must be: git commit -m "[TYPE] Message" with TYPE one of: FEAT, FIX, DOCS, REFACTOR, TEST, CHORE.
24
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").
25
- 4. Use imperative, no period at end. Maximum one short line.
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".
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.
26
37
 
27
38
  Example for files README.md, FEATURES.md, git_explain/gemini.py:
28
39
  git add README.md FEATURES.md git_explain/gemini.py
29
40
  git commit -m "[DOCS] Add README and FEATURES doc, tune Gemini prompt"
41
+
42
+ Example for Docker + nginx + env templates under api/ and apps/frontend/:
43
+ git add api/app/Dockerfile apps/frontend/nginx.conf
44
+ git commit -m "[CHORE] Add Docker and nginx config with env examples for api and frontend"
30
45
  """
31
46
 
32
47
  SYSTEM_PROMPT_WITH_DIFF = """You are given:
@@ -34,10 +49,12 @@ SYSTEM_PROMPT_WITH_DIFF = """You are given:
34
49
  2. The full diff (## Staged diff, ## Unstaged diff, ## Untracked) showing exact code changes.
35
50
 
36
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").
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.
37
54
 
38
55
  Output format (conventional commits style):
39
56
  - Line 1: git add <path1> <path2> ... with EVERY path from the file list. Do not omit any.
40
- - Line 2: git commit -m "type: subject" where type is exactly one of: feat, fix, docs, refactor, test.
57
+ - Line 2: git commit -m "type: subject" where type is exactly one of: feat, fix, docs, refactor, test, chore.
41
58
  The subject must be a short, specific summary in imperative mood, no period at end (e.g. "feat: allow editing commit message before apply", "fix: parse conventional commit line from AI").
42
59
 
43
60
  Example:
@@ -47,16 +64,39 @@ git commit -m "feat: add opt-in --with-diff for detailed AI commit messages"
47
64
 
48
65
  ADD_LINE_RE = re.compile(r"git\s+add\s+(.+)", re.IGNORECASE)
49
66
  COMMIT_LINE_RE = re.compile(
50
- r'git\s+commit\s+-m\s+["\']\[(FEAT|FIX|DOCS|REFACTOR|TESTS)\]\s*(.+?)["\']',
67
+ r'git\s+commit\s+-m\s+["\']\[(FEAT|FIX|DOCS|REFACTOR|TESTS|CHORE)\]\s*(.+?)["\']',
51
68
  re.IGNORECASE,
52
69
  )
53
70
  # Conventional: "feat: subject" or "fix: subject" (use "tests" not "test")
54
71
  COMMIT_LINE_CONVENTIONAL_RE = re.compile(
55
- r'git\s+commit\s+-m\s+["\'](feat|fix|docs|refactor|tests)\s*:\s*(.+?)["\']',
72
+ r'git\s+commit\s+-m\s+["\'](feat|fix|docs|refactor|tests|chore)\s*:\s*(.+?)["\']',
56
73
  re.IGNORECASE,
57
74
  )
58
75
  DEFAULT_MODEL = "gemini-2.5-flash"
59
76
 
77
+ _VAGUE_VERB_NOUN = re.compile(
78
+ r"^(add|update|modify|make)\s+(changes?|updates?|stuff|things)\s*$",
79
+ re.IGNORECASE,
80
+ )
81
+
82
+ # After add/update/modify/make, these tails are too vague to keep as the final message.
83
+ _VAGUE_TAIL_AFTER_VERB = frozenset(
84
+ {
85
+ "project files",
86
+ "the project",
87
+ "the codebase",
88
+ "codebase",
89
+ "code",
90
+ "files",
91
+ "file",
92
+ "some files",
93
+ "various files",
94
+ "multiple files",
95
+ "dependencies",
96
+ "deps",
97
+ }
98
+ )
99
+
60
100
  _GENERIC_MESSAGES = {
61
101
  "update",
62
102
  "updates",
@@ -80,6 +120,30 @@ def _is_generic_message(message: str) -> bool:
80
120
  return True
81
121
  if msg in _GENERIC_MESSAGES:
82
122
  return True
123
+ if _VAGUE_VERB_NOUN.match(msg):
124
+ return True
125
+ parts = msg.split()
126
+ if (
127
+ len(parts) == 2
128
+ and parts[0]
129
+ in (
130
+ "add",
131
+ "update",
132
+ "modify",
133
+ "make",
134
+ )
135
+ and parts[1] in ("changes", "change", "updates", "update", "files", "file")
136
+ ):
137
+ return True
138
+ if len(parts) >= 2 and parts[0] in (
139
+ "add",
140
+ "update",
141
+ "modify",
142
+ "make",
143
+ ):
144
+ tail = " ".join(parts[1:]).strip()
145
+ if tail in _VAGUE_TAIL_AFTER_VERB:
146
+ return True
83
147
  # "update X" is okay, but bare "update" or "update stuff" isn't
84
148
  if re.fullmatch(
85
149
  r"(update|updates|change|changes|refactor|refactoring|misc)(\s+.+)?", msg
@@ -127,8 +191,12 @@ def _fallback_type_and_message_with_context(
127
191
 
128
192
  verb = "Add" if (added_any or has_commits is False) else "Update"
129
193
 
194
+ all_test_paths = bool(files) and all(is_test_path(f) for f in files)
195
+
130
196
  if docs_only:
131
197
  commit_type = "DOCS"
198
+ elif all_test_paths:
199
+ commit_type = "TEST"
132
200
  elif verb == "Add":
133
201
  commit_type = "FEAT"
134
202
  else:
@@ -139,6 +207,17 @@ def _fallback_type_and_message_with_context(
139
207
  topics.append("README")
140
208
  if any(f.endswith("features.md") for f in lower):
141
209
  topics.append("FEATURES doc")
210
+ topics.extend(infra_deploy_topics(files))
211
+ test_files = [f for f in files if is_test_path(f)]
212
+ if test_files:
213
+ all_tests_only = len(test_files) == len(files)
214
+ hints = test_subject_hints(files)
215
+ if all_tests_only and hints:
216
+ head = " and ".join(hints[:3])
217
+ tail = f" (+{len(hints) - 3} more)" if len(hints) > 3 else ""
218
+ topics.append(f"tests for {head}{tail}")
219
+ else:
220
+ topics.append("tests")
142
221
  if touches_docs and not docs_only:
143
222
  topics.append("docs")
144
223
  if any(f.startswith("git_explain/") for f in lower) or any(
@@ -154,13 +233,14 @@ def _fallback_type_and_message_with_context(
154
233
  if touches_packaging:
155
234
  topics.append("packaging config")
156
235
 
157
- if not topics:
158
- topics = ["project files"]
159
-
160
236
  # Dedupe while keeping order
161
237
  seen: set[str] = set()
162
238
  topics = [t for t in topics if not (t in seen or seen.add(t))]
163
239
 
240
+ if not topics:
241
+ fb = basename_fallback_topic(files)
242
+ topics = [fb] if fb else ["project files"]
243
+
164
244
  if len(topics) == 1:
165
245
  msg = f"{verb} {topics[0]}"
166
246
  elif len(topics) == 2:
@@ -168,6 +248,8 @@ def _fallback_type_and_message_with_context(
168
248
  else:
169
249
  msg = f"{verb} {topics[0]}, {topics[1]}, and {topics[2]}"
170
250
 
251
+ msg += area_scope_suffix(files)
252
+
171
253
  if verb == "Add" and (has_commits is False):
172
254
  # Make initial commits a little clearer but still "Add …"
173
255
  msg = msg.replace("Add ", "Add initial ", 1) if msg.startswith("Add ") else msg
@@ -221,9 +303,18 @@ def _get_client() -> genai.Client:
221
303
 
222
304
 
223
305
  def suggest_commands(
224
- diff: str, model: str | None = None, with_diff: bool = False
306
+ diff: str,
307
+ model: str | None = None,
308
+ with_diff: bool = False,
309
+ *,
310
+ unified_diff_for_infer: str | None = None,
225
311
  ) -> tuple[Suggestion | None, str]:
226
- """Call Gemini with the file list (and optionally full diff); return (suggestion, raw_response). suggestion is None if unparseable."""
312
+ """Call Gemini with the file list (and optionally full diff); return (suggestion, raw_response). suggestion is None if unparseable.
313
+
314
+ ``unified_diff_for_infer`` optional text (staged+unstaged unified diff) used to
315
+ refine REFACTOR/FEAT into FIX when the diff matches behavior-fix patterns
316
+ (e.g. ``--staged-only``), including when ``with_diff`` is False.
317
+ """
227
318
  if not diff or not diff.strip():
228
319
  return None, ""
229
320
  model = model or os.environ.get("GEMINI_MODEL") or DEFAULT_MODEL
@@ -319,6 +410,14 @@ def suggest_commands(
319
410
  commit_type, commit_message = _fallback_type_and_message_with_context(
320
411
  files=add_args, added_any=added_any, has_commits=has_commits
321
412
  )
413
+
414
+ infer_body = unified_diff_for_infer
415
+ if not (infer_body and infer_body.strip()) and with_diff and "\n## Diff" in diff:
416
+ infer_body = diff.split("\n## Diff", 1)[1]
417
+ commit_type, commit_message = refine_type_and_message_from_diff(
418
+ commit_type, commit_message, infer_body
419
+ )
420
+
322
421
  return Suggestion(
323
422
  add_args=add_args, commit_type=commit_type, commit_message=commit_message
324
423
  ), raw
@@ -4,11 +4,19 @@ from __future__ import annotations
4
4
 
5
5
  import os
6
6
 
7
+ from git_explain.commit_infer import refine_type_and_message_from_diff
7
8
  from git_explain.gemini import Suggestion
9
+ from git_explain.path_topics import (
10
+ area_scope_suffix,
11
+ basename_fallback_topic,
12
+ infra_deploy_topics,
13
+ is_infra_deploy_path,
14
+ is_test_path,
15
+ test_subject_hints,
16
+ )
8
17
 
9
18
 
10
19
  DOC_EXTS = {".md", ".rst", ".txt"}
11
- TEST_HINTS = ("test", "tests", "pytest", "unittest")
12
20
  CONFIG_FILES = {
13
21
  "pyproject.toml",
14
22
  "requirements.txt",
@@ -32,38 +40,30 @@ def _is_doc(path: str) -> bool:
32
40
  }
33
41
 
34
42
 
35
- def _is_test(path: str) -> bool:
43
+ def _is_plain_config(path: str) -> bool:
36
44
  p = path.lower()
37
45
  base = os.path.basename(p)
38
- if p.startswith("tests/") or "/tests/" in p:
39
- return True
40
- if (
41
- base.startswith("test_")
42
- or base.endswith("_test.py")
43
- or base.endswith(".spec.ts")
44
- or base.endswith(".spec.tsx")
45
- ):
46
- return True
47
- return any(h in p for h in TEST_HINTS)
46
+ return base in CONFIG_FILES or os.path.splitext(p)[1] in CONFIG_EXTS
48
47
 
49
48
 
50
49
  def _is_config(path: str) -> bool:
51
- p = path.lower()
52
- base = os.path.basename(p)
53
- return base in CONFIG_FILES or os.path.splitext(p)[1] in CONFIG_EXTS
50
+ """Packaging/config files plus Docker, Compose, nginx, env templates."""
51
+ return _is_plain_config(path) or is_infra_deploy_path(path)
54
52
 
55
53
 
56
54
  def suggest_from_changes(
57
55
  *,
58
56
  changes: list[tuple[str, str]],
59
57
  has_commits: bool | None,
58
+ diff_text: str | None = None,
60
59
  ) -> Suggestion:
61
60
  """Create a Suggestion from [(status, path)] without calling AI."""
62
61
  paths = [p for _, p in changes]
63
62
  added_any = any(s.upper() == "A" for s, _ in changes) or has_commits is False
63
+ modified_any = any(s.upper() == "M" for s, _ in changes)
64
64
 
65
65
  docs = [p for p in paths if _is_doc(p)]
66
- tests = [p for p in paths if _is_test(p)]
66
+ tests = [p for p in paths if is_test_path(p)]
67
67
  configs = [p for p in paths if _is_config(p)]
68
68
  has_tests = bool(tests)
69
69
  has_configs = bool(configs)
@@ -75,7 +75,12 @@ def suggest_from_changes(
75
75
  tc = len([p for p in non_docs if p in tests or p in configs])
76
76
  mostly_tests_or_config = tc / max(1, len(non_docs)) >= 0.6
77
77
 
78
- verb = "Add" if added_any else "Update"
78
+ if has_commits is False:
79
+ verb = "Add"
80
+ elif added_any and not modified_any:
81
+ verb = "Add"
82
+ else:
83
+ verb = "Update"
79
84
 
80
85
  if docs_only:
81
86
  commit_type = "DOCS"
@@ -96,20 +101,44 @@ def suggest_from_changes(
96
101
  topics.append("README")
97
102
  if any(os.path.basename(p).lower() == "features.md" for p in paths):
98
103
  topics.append("FEATURES doc")
104
+ topics.extend(infra_deploy_topics(paths))
99
105
  if tests:
100
- topics.append("tests")
101
- if configs:
106
+ all_tests_only = bool(paths) and len(tests) == len(paths)
107
+ hints = test_subject_hints(paths)
108
+ if all_tests_only and hints:
109
+ head = " and ".join(hints[:3])
110
+ tail = f" (+{len(hints) - 3} more)" if len(hints) > 3 else ""
111
+ topics.append(f"tests for {head}{tail}")
112
+ else:
113
+ topics.append("tests")
114
+ if any(_is_plain_config(p) for p in paths):
102
115
  topics.append("config")
103
- if any("git_explain/" in p.replace("\\", "/").lower() for p in paths):
104
- topics.append("git-explain CLI")
105
-
106
- if not topics:
107
- topics = ["changes"]
116
+ ge_paths = [p for p in paths if "git_explain/" in p.replace("\\", "/").lower()]
117
+ if ge_paths:
118
+ if len(ge_paths) <= 2:
119
+ topics.append("git-explain CLI")
120
+ else:
121
+ stems: list[str] = []
122
+ seen_stem: set[str] = set()
123
+ for p in sorted(ge_paths, key=lambda x: x.lower()):
124
+ stem, _ext = os.path.splitext(os.path.basename(p))
125
+ key = stem.lower()
126
+ if key not in seen_stem:
127
+ seen_stem.add(key)
128
+ stems.append(stem.replace("_", " "))
129
+ label = ", ".join(stems[:4])
130
+ if len(stems) > 4:
131
+ label += f" (+{len(stems) - 4} more)"
132
+ topics.append(label)
108
133
 
109
134
  # Dedupe while preserving order
110
135
  seen: set[str] = set()
111
136
  topics = [t for t in topics if not (t in seen or seen.add(t))]
112
137
 
138
+ if not topics:
139
+ fb = basename_fallback_topic(paths)
140
+ topics = [fb] if fb else ["project files"]
141
+
113
142
  if len(topics) == 1:
114
143
  message = f"{verb} {topics[0]}"
115
144
  elif len(topics) == 2:
@@ -117,7 +146,16 @@ def suggest_from_changes(
117
146
  else:
118
147
  message = f"{verb} {topics[0]}, {topics[1]}, and {topics[2]}"
119
148
 
149
+ message += area_scope_suffix(paths)
150
+
120
151
  if added_any and has_commits is False and message.startswith("Add "):
121
152
  message = message.replace("Add ", "Add initial ", 1)
122
153
 
154
+ if len(message) > 72:
155
+ message = message[:72].rstrip()
156
+
157
+ commit_type, message = refine_type_and_message_from_diff(
158
+ commit_type, message, diff_text
159
+ )
160
+
123
161
  return Suggestion(add_args=paths, commit_type=commit_type, commit_message=message)
@@ -0,0 +1,178 @@
1
+ """Derive concrete commit-message topics from paths (Docker, nginx, env templates, areas)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+
7
+
8
+ def _norm(p: str) -> str:
9
+ return p.replace("\\", "/").strip()
10
+
11
+
12
+ _TEST_HINTS = ("pytest", "unittest", "tests/", "/tests/")
13
+
14
+
15
+ def is_test_path(path: str) -> bool:
16
+ """True if path looks like a test file (mirrors heuristics rules; paths normalized)."""
17
+ p = _norm(path).lower()
18
+ base = os.path.basename(p)
19
+ if p.startswith("tests/") or "/tests/" in p:
20
+ return True
21
+ if (
22
+ base.startswith("test_")
23
+ or base.endswith("_test.py")
24
+ or base.endswith(".spec.ts")
25
+ or base.endswith(".spec.tsx")
26
+ ):
27
+ return True
28
+ return any(h in p for h in _TEST_HINTS)
29
+
30
+
31
+ def test_subject_hints(paths: list[str]) -> list[str]:
32
+ """Short labels from test filenames, e.g. test_gemini.py -> gemini (deduped, stable order)."""
33
+ hints: list[str] = []
34
+ seen: set[str] = set()
35
+ for raw in paths:
36
+ if not is_test_path(raw):
37
+ continue
38
+ base = os.path.basename(_norm(raw))
39
+ stem, _ext = os.path.splitext(base)
40
+ s = stem
41
+ low = s.lower()
42
+ if low.startswith("test_"):
43
+ core = s[5:]
44
+ elif low.endswith("_test"):
45
+ core = s[: -len("_test")]
46
+ else:
47
+ core = s
48
+ core = core.strip().replace("_", " ").strip()
49
+ if not core:
50
+ continue
51
+ key = core.lower()
52
+ if key not in seen:
53
+ seen.add(key)
54
+ hints.append(core)
55
+ return hints
56
+
57
+
58
+ def is_infra_deploy_path(path: str) -> bool:
59
+ """True if path looks like Docker, Compose, nginx, or env-template deploy config."""
60
+ p = _norm(path).lower()
61
+ base = os.path.basename(p)
62
+ if base == "dockerfile" or base.endswith(".dockerfile"):
63
+ return True
64
+ if base == ".dockerignore":
65
+ return True
66
+ if base.startswith("docker-compose") and base.endswith((".yml", ".yaml")):
67
+ return True
68
+ if base in ("compose.yaml", "compose.yml"):
69
+ return True
70
+ if "nginx" in base and base.endswith(".conf"):
71
+ return True
72
+ if base.endswith((".env.example", ".env.sample")):
73
+ return True
74
+ if base in ("compose.env.example", ".env.example"):
75
+ return True
76
+ return False
77
+
78
+
79
+ def infra_deploy_topics(paths: list[str]) -> list[str]:
80
+ """Ordered, deduplicated topic phrases (no leading verb)."""
81
+ has_dockerfile = False
82
+ has_dockerignore = False
83
+ has_compose = False
84
+ has_nginx = False
85
+ has_env_example = False
86
+
87
+ for raw in paths:
88
+ p = _norm(raw).lower()
89
+ base = os.path.basename(p)
90
+ if base == "dockerfile" or base.endswith(".dockerfile"):
91
+ has_dockerfile = True
92
+ if base == ".dockerignore":
93
+ has_dockerignore = True
94
+ if base.startswith("docker-compose") and base.endswith((".yml", ".yaml")):
95
+ has_compose = True
96
+ if base in ("compose.yaml", "compose.yml"):
97
+ has_compose = True
98
+ if "nginx" in base and base.endswith(".conf"):
99
+ has_nginx = True
100
+ if base.endswith((".env.example", ".env.sample")):
101
+ has_env_example = True
102
+ if base in ("compose.env.example", ".env.example"):
103
+ has_env_example = True
104
+
105
+ topics: list[str] = []
106
+ seen: set[str] = set()
107
+
108
+ def add(s: str) -> None:
109
+ if s not in seen:
110
+ seen.add(s)
111
+ topics.append(s)
112
+
113
+ if has_dockerfile or has_dockerignore:
114
+ add("Docker")
115
+ if has_compose:
116
+ add("Docker Compose")
117
+ if has_nginx:
118
+ add("nginx")
119
+ if has_env_example:
120
+ add("env examples")
121
+
122
+ return topics
123
+
124
+
125
+ def area_scope_suffix(paths: list[str]) -> str:
126
+ """Return ' for api and frontend' style suffix, or ''."""
127
+ labels: list[str] = []
128
+ seen: set[str] = set()
129
+
130
+ for raw in paths:
131
+ p = _norm(raw)
132
+ if not p or p == ".":
133
+ continue
134
+ parts = [x for x in p.split("/") if x]
135
+ if len(parts) < 2:
136
+ continue
137
+ first, second = parts[0], parts[1]
138
+ if first.lower() in {"apps", "packages", "services"}:
139
+ label = second
140
+ else:
141
+ label = first
142
+ low = label.lower()
143
+ if low not in seen:
144
+ seen.add(low)
145
+ labels.append(label)
146
+
147
+ if not labels:
148
+ return ""
149
+
150
+ def fmt(lbl: str) -> str:
151
+ return "API" if lbl.lower() == "api" else lbl
152
+
153
+ labels = [fmt(x) for x in labels]
154
+ if len(labels) == 1:
155
+ return f" for {labels[0]}"
156
+ if len(labels) == 2:
157
+ return f" for {labels[0]} and {labels[1]}"
158
+ return f" for {labels[0]}, {labels[1]}, and {labels[2]}"
159
+
160
+
161
+ def basename_fallback_topic(paths: list[str], max_names: int = 4) -> str | None:
162
+ """Short description from basenames when no other topic matched."""
163
+ bases: list[str] = []
164
+ seen: set[str] = set()
165
+ for raw in paths:
166
+ b = os.path.basename(_norm(raw))
167
+ if not b:
168
+ continue
169
+ key = b.lower()
170
+ if key not in seen:
171
+ seen.add(key)
172
+ bases.append(b)
173
+ if not bases:
174
+ return None
175
+ if len(bases) <= max_names:
176
+ return ", ".join(bases)
177
+ head = ", ".join(bases[:max_names])
178
+ return f"{head} (+{len(bases) - max_names} more)"
@@ -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.3
3
+ Version: 2.1.4
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
@@ -125,7 +126,7 @@ You’ll see a list of changed files, choose which to include, then get suggeste
125
126
  | `--ai` | Use Gemini for commit type/message (file paths only). |
126
127
  | `--with-diff` | With `--ai`: send full diff to the model for detailed messages. |
127
128
  | `--model NAME` | Override Gemini model (e.g. `--model gemini-2.0-flash`). |
128
- | `--staged-only` | Commit only what’s already staged (no `git add`). |
129
+ | `--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
130
  | `--cwd PATH` | Run as if current directory is `PATH`. |
130
131
  | `--install-completion [SHELL]` | Install shell completion (`bash`, `zsh`). |
131
132
  | `--show-completion [SHELL]` | Print completion script for `SHELL`. |
@@ -134,7 +135,7 @@ You’ll see a list of changed files, choose which to include, then get suggeste
134
135
 
135
136
  ## Workflow
136
137
 
137
- 1. **Changed files** — Shows staged, unstaged, and untracked files. Untracked folders are grouped (e.g. `foo/ (untracked folder; 5 files)`).
138
+ 1. **Changed files** — Shows staged, unstaged, and untracked files. Untracked directories are expanded so you still see per-file paths.
138
139
  2. **Select files** — Enter numbers (e.g. `1,2,5-7`), `all`, or a path (e.g. `main.py`, `src/utils/`).
139
140
  3. **Commit mode** — If you selected 2+ files: choose `one` (single commit) or `split` (separate commits by docs/tests/config/code).
140
141
  4. **Suggested commands** — Panel with `git add` and `git commit` lines.
@@ -3,9 +3,11 @@ 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
10
+ git_explain/path_topics.py
9
11
  git_explain/run.py
10
12
  git_explain.egg-info/PKG-INFO
11
13
  git_explain.egg-info/SOURCES.txt
@@ -14,6 +16,7 @@ git_explain.egg-info/entry_points.txt
14
16
  git_explain.egg-info/requires.txt
15
17
  git_explain.egg-info/top_level.txt
16
18
  tests/test_cli_utils.py
19
+ tests/test_commit_infer.py
17
20
  tests/test_gemini.py
18
21
  tests/test_heuristics.py
19
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.3"
7
+ version = "v2.1.4"
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]
@@ -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()
@@ -0,0 +1,73 @@
1
+ from git_explain.gemini import (
2
+ COMMIT_LINE_CONVENTIONAL_RE,
3
+ COMMIT_LINE_RE,
4
+ _fallback_type_and_message_with_context,
5
+ _is_generic_message,
6
+ )
7
+
8
+
9
+ def test_commit_line_re_matches_tests_not_test() -> None:
10
+ """COMMIT_LINE_RE should match [TESTS] but not [TEST]."""
11
+ line_tests = 'git commit -m "[TESTS] Add unit tests"'
12
+ m = COMMIT_LINE_RE.match(line_tests)
13
+ assert m is not None
14
+ assert m.group(1).upper() == "TESTS"
15
+ assert "Add unit tests" in m.group(2)
16
+
17
+ line_test = 'git commit -m "[TEST] Add unit test"'
18
+ m = COMMIT_LINE_RE.match(line_test)
19
+ assert m is None
20
+
21
+
22
+ def test_commit_line_conventional_re_matches_tests() -> None:
23
+ """COMMIT_LINE_CONVENTIONAL_RE should match 'tests:' not 'test:'."""
24
+ line = 'git commit -m "tests: add unit tests"'
25
+ m = COMMIT_LINE_CONVENTIONAL_RE.match(line)
26
+ assert m is not None
27
+ assert m.group(1).lower() == "tests"
28
+
29
+ line_test = 'git commit -m "test: add unit test"'
30
+ m = COMMIT_LINE_CONVENTIONAL_RE.match(line_test)
31
+ assert m is None
32
+
33
+
34
+ def test_commit_line_re_matches_other_types() -> None:
35
+ for line in [
36
+ 'git commit -m "[FEAT] Add feature"',
37
+ 'git commit -m "[FIX] Fix bug"',
38
+ 'git commit -m "[DOCS] Update readme"',
39
+ 'git commit -m "[REFACTOR] Simplify logic"',
40
+ 'git commit -m "[CHORE] Add Docker and nginx config"',
41
+ ]:
42
+ m = COMMIT_LINE_RE.match(line)
43
+ assert m is not None, f"Expected match for {line}"
44
+
45
+
46
+ def test_commit_line_conventional_matches_chore() -> None:
47
+ line = 'git commit -m "chore: add docker compose"'
48
+ m = COMMIT_LINE_CONVENTIONAL_RE.match(line)
49
+ assert m is not None
50
+ assert m.group(1).lower() == "chore"
51
+
52
+
53
+ def test_is_generic_message_flags_vague_add_changes() -> None:
54
+ assert _is_generic_message("Add changes") is True
55
+ assert _is_generic_message("Update changes") is True
56
+ assert _is_generic_message("Add Docker and nginx for api") is False
57
+
58
+
59
+ def test_is_generic_message_flags_update_project_files() -> None:
60
+ assert _is_generic_message("Update project files") is True
61
+ assert _is_generic_message("Add project files") is True
62
+ assert _is_generic_message("Update tests for gemini and heuristics") is False
63
+
64
+
65
+ def test_fallback_uses_test_hints_for_test_files() -> None:
66
+ ctype, msg = _fallback_type_and_message_with_context(
67
+ files=["tests/test_gemini.py", "tests/test_heuristics.py"],
68
+ added_any=False,
69
+ has_commits=True,
70
+ )
71
+ assert ctype == "TEST"
72
+ assert "gemini" in msg.lower()
73
+ assert "heuristics" in msg.lower()
@@ -0,0 +1,94 @@
1
+ from git_explain.heuristics import suggest_from_changes
2
+
3
+
4
+ def test_docs_only_is_docs() -> None:
5
+ s = suggest_from_changes(
6
+ changes=[("M", "README.md"), ("A", "FEATURES.md")],
7
+ has_commits=True,
8
+ )
9
+ assert s.commit_type == "DOCS"
10
+ assert s.commit_message.lower().startswith(
11
+ "add"
12
+ ) or s.commit_message.lower().startswith("update")
13
+
14
+
15
+ def test_added_files_prefer_feat() -> None:
16
+ s = suggest_from_changes(
17
+ changes=[("A", "git_explain/cli.py"), ("M", "pyproject.toml")],
18
+ has_commits=True,
19
+ )
20
+ assert s.commit_type == "FEAT"
21
+ assert s.commit_message.lower().startswith(
22
+ "add"
23
+ ) or s.commit_message.lower().startswith("update")
24
+
25
+
26
+ def test_many_git_explain_paths_use_module_names_not_umbrella_cli() -> None:
27
+ s = suggest_from_changes(
28
+ changes=[
29
+ ("M", "git_explain/cli.py"),
30
+ ("M", "git_explain/run.py"),
31
+ ("M", "git_explain/gemini.py"),
32
+ ],
33
+ has_commits=True,
34
+ )
35
+ m = s.commit_message.lower()
36
+ assert "git-explain cli" not in m
37
+ assert "cli" in m
38
+
39
+
40
+ def test_mostly_tests_or_config_is_test() -> None:
41
+ s = suggest_from_changes(
42
+ changes=[
43
+ ("M", "tests/test_cli.py"),
44
+ ("M", "pyproject.toml"),
45
+ ("M", "requirements.txt"),
46
+ ],
47
+ has_commits=True,
48
+ )
49
+ assert s.commit_type == "TEST"
50
+
51
+
52
+ def test_config_only_is_chore_not_test() -> None:
53
+ s = suggest_from_changes(
54
+ changes=[("M", ".gitignore"), ("M", "pyproject.toml")],
55
+ has_commits=True,
56
+ )
57
+ assert s.commit_type == "CHORE"
58
+
59
+
60
+ def test_test_only_paths_get_specific_test_message() -> None:
61
+ s = suggest_from_changes(
62
+ changes=[
63
+ ("M", "tests/test_gemini.py"),
64
+ ("M", "tests/test_heuristics.py"),
65
+ ],
66
+ has_commits=True,
67
+ )
68
+ assert s.commit_type == "TEST"
69
+ m = s.commit_message.lower()
70
+ assert "gemini" in m
71
+ assert "heuristics" in m
72
+ assert "project files" not in m
73
+
74
+
75
+ def test_docker_nginx_env_paths_get_specific_chore_message() -> None:
76
+ """Infra paths should not collapse to 'Add changes'."""
77
+ s = suggest_from_changes(
78
+ changes=[
79
+ ("A", "api/app/.env.example"),
80
+ ("A", "api/app/Dockerfile"),
81
+ ("A", "apps/frontend/.dockerignore"),
82
+ ("A", "apps/frontend/Dockerfile"),
83
+ ("A", "apps/frontend/nginx.conf"),
84
+ ("A", "compose.env.example"),
85
+ ],
86
+ has_commits=True,
87
+ )
88
+ assert s.commit_type == "CHORE"
89
+ m = s.commit_message.lower()
90
+ assert "docker" in m
91
+ assert "nginx" in m
92
+ assert "env" in m
93
+ assert "changes" not in m
94
+ assert "api" in m and "frontend" in m
@@ -1,37 +0,0 @@
1
- from git_explain.gemini import COMMIT_LINE_CONVENTIONAL_RE, COMMIT_LINE_RE
2
-
3
-
4
- def test_commit_line_re_matches_tests_not_test() -> None:
5
- """COMMIT_LINE_RE should match [TESTS] but not [TEST]."""
6
- line_tests = 'git commit -m "[TESTS] Add unit tests"'
7
- m = COMMIT_LINE_RE.match(line_tests)
8
- assert m is not None
9
- assert m.group(1).upper() == "TESTS"
10
- assert "Add unit tests" in m.group(2)
11
-
12
- line_test = 'git commit -m "[TEST] Add unit test"'
13
- m = COMMIT_LINE_RE.match(line_test)
14
- assert m is None
15
-
16
-
17
- def test_commit_line_conventional_re_matches_tests() -> None:
18
- """COMMIT_LINE_CONVENTIONAL_RE should match 'tests:' not 'test:'."""
19
- line = 'git commit -m "tests: add unit tests"'
20
- m = COMMIT_LINE_CONVENTIONAL_RE.match(line)
21
- assert m is not None
22
- assert m.group(1).lower() == "tests"
23
-
24
- line_test = 'git commit -m "test: add unit test"'
25
- m = COMMIT_LINE_CONVENTIONAL_RE.match(line_test)
26
- assert m is None
27
-
28
-
29
- def test_commit_line_re_matches_other_types() -> None:
30
- for line in [
31
- 'git commit -m "[FEAT] Add feature"',
32
- 'git commit -m "[FIX] Fix bug"',
33
- 'git commit -m "[DOCS] Update readme"',
34
- 'git commit -m "[REFACTOR] Simplify logic"',
35
- ]:
36
- m = COMMIT_LINE_RE.match(line)
37
- assert m is not None, f"Expected match for {line}"
@@ -1,41 +0,0 @@
1
- from git_explain.heuristics import suggest_from_changes
2
-
3
-
4
- def test_docs_only_is_docs() -> None:
5
- s = suggest_from_changes(
6
- changes=[("M", "README.md"), ("A", "FEATURES.md")],
7
- has_commits=True,
8
- )
9
- assert s.commit_type == "DOCS"
10
- assert s.commit_message.lower().startswith(
11
- "add"
12
- ) or s.commit_message.lower().startswith("update")
13
-
14
-
15
- def test_added_files_prefer_feat() -> None:
16
- s = suggest_from_changes(
17
- changes=[("A", "git_explain/cli.py"), ("M", "pyproject.toml")],
18
- has_commits=True,
19
- )
20
- assert s.commit_type == "FEAT"
21
- assert s.commit_message.lower().startswith("add")
22
-
23
-
24
- def test_mostly_tests_or_config_is_test() -> None:
25
- s = suggest_from_changes(
26
- changes=[
27
- ("M", "tests/test_cli.py"),
28
- ("M", "pyproject.toml"),
29
- ("M", "requirements.txt"),
30
- ],
31
- has_commits=True,
32
- )
33
- assert s.commit_type == "TEST"
34
-
35
-
36
- def test_config_only_is_chore_not_test() -> None:
37
- s = suggest_from_changes(
38
- changes=[("M", ".gitignore"), ("M", "pyproject.toml")],
39
- has_commits=True,
40
- )
41
- assert s.commit_type == "CHORE"
File without changes
File without changes