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.
- {git_explain-1.1.3 → git_explain-2.1.4}/PKG-INFO +4 -3
- {git_explain-1.1.3 → git_explain-2.1.4}/README.md +2 -2
- {git_explain-1.1.3 → git_explain-2.1.4}/git_explain/cli.py +88 -44
- git_explain-2.1.4/git_explain/commit_infer.py +66 -0
- {git_explain-1.1.3 → git_explain-2.1.4}/git_explain/gemini.py +109 -10
- {git_explain-1.1.3 → git_explain-2.1.4}/git_explain/heuristics.py +62 -24
- git_explain-2.1.4/git_explain/path_topics.py +178 -0
- {git_explain-1.1.3 → git_explain-2.1.4}/git_explain/run.py +11 -0
- {git_explain-1.1.3 → git_explain-2.1.4}/git_explain.egg-info/PKG-INFO +4 -3
- {git_explain-1.1.3 → git_explain-2.1.4}/git_explain.egg-info/SOURCES.txt +3 -0
- {git_explain-1.1.3 → git_explain-2.1.4}/git_explain.egg-info/requires.txt +1 -0
- {git_explain-1.1.3 → git_explain-2.1.4}/pyproject.toml +2 -1
- git_explain-2.1.4/tests/test_commit_infer.py +72 -0
- git_explain-2.1.4/tests/test_gemini.py +73 -0
- git_explain-2.1.4/tests/test_heuristics.py +94 -0
- git_explain-1.1.3/tests/test_gemini.py +0 -37
- git_explain-1.1.3/tests/test_heuristics.py +0 -41
- {git_explain-1.1.3 → git_explain-2.1.4}/LICENSE +0 -0
- {git_explain-1.1.3 → git_explain-2.1.4}/git_explain/__init__.py +0 -0
- {git_explain-1.1.3 → git_explain-2.1.4}/git_explain/git.py +0 -0
- {git_explain-1.1.3 → git_explain-2.1.4}/git_explain.egg-info/dependency_links.txt +0 -0
- {git_explain-1.1.3 → git_explain-2.1.4}/git_explain.egg-info/entry_points.txt +0 -0
- {git_explain-1.1.3 → git_explain-2.1.4}/git_explain.egg-info/top_level.txt +0 -0
- {git_explain-1.1.3 → git_explain-2.1.4}/setup.cfg +0 -0
- {git_explain-1.1.3 → git_explain-2.1.4}/tests/test_cli_utils.py +0 -0
- {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:
|
|
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
|
|
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
|
|
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
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
369
|
+
"",
|
|
370
|
+
str(e),
|
|
372
371
|
)
|
|
373
|
-
h = suggest_from_changes(
|
|
374
|
-
|
|
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
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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(
|
|
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.
|
|
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,
|
|
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
|
|
43
|
+
def _is_plain_config(path: str) -> bool:
|
|
36
44
|
p = path.lower()
|
|
37
45
|
base = os.path.basename(p)
|
|
38
|
-
|
|
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
|
-
|
|
52
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
101
|
-
|
|
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
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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:
|
|
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
|
|
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
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "git-explain"
|
|
7
|
-
version = "
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|