git-explain 2.1.9__tar.gz → 2.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {git_explain-2.1.9 → git_explain-2.2.0}/PKG-INFO +13 -3
- {git_explain-2.1.9 → git_explain-2.2.0}/README.md +11 -1
- git_explain-2.2.0/git_explain/__init__.py +1 -0
- git_explain-2.2.0/git_explain/__main__.py +3 -0
- {git_explain-2.1.9 → git_explain-2.2.0}/git_explain/cli.py +52 -47
- {git_explain-2.1.9 → git_explain-2.2.0}/git_explain/commit_infer.py +1 -1
- {git_explain-2.1.9 → git_explain-2.2.0}/git_explain/gemini.py +97 -45
- {git_explain-2.1.9 → git_explain-2.2.0}/git_explain/heuristics.py +34 -20
- {git_explain-2.1.9 → git_explain-2.2.0}/git_explain/path_topics.py +80 -0
- {git_explain-2.1.9 → git_explain-2.2.0}/git_explain/run.py +37 -2
- {git_explain-2.1.9 → git_explain-2.2.0}/git_explain.egg-info/PKG-INFO +13 -3
- {git_explain-2.1.9 → git_explain-2.2.0}/git_explain.egg-info/SOURCES.txt +1 -0
- {git_explain-2.1.9 → git_explain-2.2.0}/pyproject.toml +1 -1
- {git_explain-2.1.9 → git_explain-2.2.0}/tests/test_gemini.py +54 -31
- {git_explain-2.1.9 → git_explain-2.2.0}/tests/test_heuristics.py +3 -3
- {git_explain-2.1.9 → git_explain-2.2.0}/tests/test_run_apply.py +1 -1
- git_explain-2.1.9/git_explain/__init__.py +0 -1
- {git_explain-2.1.9 → git_explain-2.2.0}/LICENSE +0 -0
- {git_explain-2.1.9 → git_explain-2.2.0}/git_explain/git.py +0 -0
- {git_explain-2.1.9 → git_explain-2.2.0}/git_explain.egg-info/dependency_links.txt +0 -0
- {git_explain-2.1.9 → git_explain-2.2.0}/git_explain.egg-info/entry_points.txt +0 -0
- {git_explain-2.1.9 → git_explain-2.2.0}/git_explain.egg-info/requires.txt +0 -0
- {git_explain-2.1.9 → git_explain-2.2.0}/git_explain.egg-info/top_level.txt +0 -0
- {git_explain-2.1.9 → git_explain-2.2.0}/setup.cfg +0 -0
- {git_explain-2.1.9 → git_explain-2.2.0}/tests/test_cli_utils.py +0 -0
- {git_explain-2.1.9 → git_explain-2.2.0}/tests/test_commit_infer.py +0 -0
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: git-explain
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.2.0
|
|
4
4
|
Summary: CLI that suggests git add/commit from diffs using Gemini
|
|
5
|
-
Author:
|
|
5
|
+
Author: nazarli-shabnam
|
|
6
6
|
License-Expression: MIT
|
|
7
7
|
Project-URL: Homepage, https://github.com/nazarli-shabnam/git-explain
|
|
8
8
|
Project-URL: Source, https://github.com/nazarli-shabnam/git-explain
|
|
@@ -51,6 +51,14 @@ pip install git-explain
|
|
|
51
51
|
pip install -e .
|
|
52
52
|
```
|
|
53
53
|
|
|
54
|
+
**Clone:** install deps (`requirements.txt` is fine), `cd` into the repo, then:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
python -m git_explain
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Run that from the repo root so Python picks up the `git_explain` folder—no `pip install -e .` needed.
|
|
61
|
+
|
|
54
62
|
Optional: install a specific tag from GitHub instead of PyPI:
|
|
55
63
|
|
|
56
64
|
```bash
|
|
@@ -70,6 +78,8 @@ pip install "git+https://github.com/nazarli-shabnam/git-explain.git@v2.1.8"
|
|
|
70
78
|
|
|
71
79
|
Heuristics guess a sensible type and message from paths and statuses. **No account, no key, no network** for that path.
|
|
72
80
|
|
|
81
|
+
Suggested commits follow **[Conventional Commits](https://www.conventionalcommits.org/)**—`feat: …`, `fix: …`, optional `(scope)`, and so on—so changelogs and release tools can read them.
|
|
82
|
+
|
|
73
83
|
---
|
|
74
84
|
|
|
75
85
|
## Optional: Gemini
|
|
@@ -80,7 +90,7 @@ If you want sharper messages, set **`GEMINI_API_KEY`** (or `GOOGLE_API_KEY`) in
|
|
|
80
90
|
|--------|----------------|
|
|
81
91
|
| `git-explain --ai` | AI sees **paths and change type** only (no file contents). |
|
|
82
92
|
| `git-explain --ai --with-diff` | AI also sees the **diff**—better detail; only use if you’re OK sending that to the API. |
|
|
83
|
-
| `git-explain --suggest` | **Staged
|
|
93
|
+
| `git-explain --suggest` | **Staged only**; prints one plain `git commit -m "…"` line (easy to copy). Needs AI; don’t combine with other flags. |
|
|
84
94
|
|
|
85
95
|
Everything else (`--auto`, `--staged-only`, `--cwd`, model override, shell completion): **`git-explain --help`**.
|
|
86
96
|
|
|
@@ -20,6 +20,14 @@ pip install git-explain
|
|
|
20
20
|
pip install -e .
|
|
21
21
|
```
|
|
22
22
|
|
|
23
|
+
**Clone:** install deps (`requirements.txt` is fine), `cd` into the repo, then:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
python -m git_explain
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Run that from the repo root so Python picks up the `git_explain` folder—no `pip install -e .` needed.
|
|
30
|
+
|
|
23
31
|
Optional: install a specific tag from GitHub instead of PyPI:
|
|
24
32
|
|
|
25
33
|
```bash
|
|
@@ -39,6 +47,8 @@ pip install "git+https://github.com/nazarli-shabnam/git-explain.git@v2.1.8"
|
|
|
39
47
|
|
|
40
48
|
Heuristics guess a sensible type and message from paths and statuses. **No account, no key, no network** for that path.
|
|
41
49
|
|
|
50
|
+
Suggested commits follow **[Conventional Commits](https://www.conventionalcommits.org/)**—`feat: …`, `fix: …`, optional `(scope)`, and so on—so changelogs and release tools can read them.
|
|
51
|
+
|
|
42
52
|
---
|
|
43
53
|
|
|
44
54
|
## Optional: Gemini
|
|
@@ -49,7 +59,7 @@ If you want sharper messages, set **`GEMINI_API_KEY`** (or `GOOGLE_API_KEY`) in
|
|
|
49
59
|
|--------|----------------|
|
|
50
60
|
| `git-explain --ai` | AI sees **paths and change type** only (no file contents). |
|
|
51
61
|
| `git-explain --ai --with-diff` | AI also sees the **diff**—better detail; only use if you’re OK sending that to the API. |
|
|
52
|
-
| `git-explain --suggest` | **Staged
|
|
62
|
+
| `git-explain --suggest` | **Staged only**; prints one plain `git commit -m "…"` line (easy to copy). Needs AI; don’t combine with other flags. |
|
|
53
63
|
|
|
54
64
|
Everything else (`--auto`, `--staged-only`, `--cwd`, model override, shell completion): **`git-explain --help`**.
|
|
55
65
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "2.2.0"
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import re
|
|
4
4
|
import subprocess
|
|
5
|
-
from dataclasses import dataclass
|
|
5
|
+
from dataclasses import dataclass, replace
|
|
6
6
|
from pathlib import Path
|
|
7
7
|
from typing import Iterable
|
|
8
8
|
|
|
@@ -12,14 +12,18 @@ from rich.console import Console
|
|
|
12
12
|
from rich.panel import Panel
|
|
13
13
|
from rich.text import Text
|
|
14
14
|
|
|
15
|
-
from git_explain.gemini import suggest_commands
|
|
15
|
+
from git_explain.gemini import Suggestion, suggest_commands
|
|
16
16
|
from git_explain.heuristics import suggest_from_changes
|
|
17
17
|
from git_explain.git import (
|
|
18
18
|
get_combined_diff,
|
|
19
19
|
get_diff_for_paths,
|
|
20
20
|
get_staged_diff_for_paths,
|
|
21
21
|
)
|
|
22
|
-
from git_explain.run import
|
|
22
|
+
from git_explain.run import (
|
|
23
|
+
apply_commands,
|
|
24
|
+
format_commit_message,
|
|
25
|
+
normalize_commit_subject_for_dash_m,
|
|
26
|
+
)
|
|
23
27
|
|
|
24
28
|
load_dotenv()
|
|
25
29
|
app = typer.Typer()
|
|
@@ -344,13 +348,10 @@ def run(
|
|
|
344
348
|
raise typer.Exit(1)
|
|
345
349
|
|
|
346
350
|
cmsg = normalize_commit_subject_for_dash_m(sug.commit_message)
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
f'git commit -m "[{sug.commit_type}] {cmsg}"',
|
|
350
|
-
title="Suggested commit command",
|
|
351
|
-
border_style="green",
|
|
352
|
-
)
|
|
351
|
+
full = format_commit_message(
|
|
352
|
+
sug.commit_type, cmsg, scope=sug.scope, breaking=sug.breaking
|
|
353
353
|
)
|
|
354
|
+
print(f'git commit -m "{full}"')
|
|
354
355
|
return
|
|
355
356
|
|
|
356
357
|
if staged_only:
|
|
@@ -422,9 +423,8 @@ def run(
|
|
|
422
423
|
|
|
423
424
|
def suggest_for(
|
|
424
425
|
change_items: list[tuple[str, str]], title: str
|
|
425
|
-
) -> tuple[
|
|
426
|
-
|
|
427
|
-
# ai_fallback_reason is set when --ai was used but heuristics were used instead.
|
|
426
|
+
) -> tuple[Suggestion, str | None]:
|
|
427
|
+
"""Return (suggestion, ai_fallback_reason)."""
|
|
428
428
|
paths_for_infer = [p for _, p in change_items]
|
|
429
429
|
infer_diff: str | None = None
|
|
430
430
|
if paths_for_infer:
|
|
@@ -444,7 +444,7 @@ def run(
|
|
|
444
444
|
if diff_text:
|
|
445
445
|
payload = payload + "\n\n## Diff\n" + diff_text
|
|
446
446
|
try:
|
|
447
|
-
sug,
|
|
447
|
+
sug, _raw = suggest_commands(
|
|
448
448
|
payload,
|
|
449
449
|
model=model,
|
|
450
450
|
with_diff=with_diff,
|
|
@@ -452,27 +452,20 @@ def run(
|
|
|
452
452
|
)
|
|
453
453
|
if sug is None:
|
|
454
454
|
raise RuntimeError("Could not parse AI suggestion.")
|
|
455
|
-
return sug
|
|
455
|
+
return sug, None
|
|
456
456
|
except Exception as e:
|
|
457
|
-
# Fall back to heuristics on quota / API errors
|
|
458
457
|
h = suggest_from_changes(
|
|
459
458
|
changes=change_items,
|
|
460
459
|
has_commits=has_commits,
|
|
461
460
|
diff_text=infer_diff,
|
|
462
461
|
)
|
|
463
|
-
return (
|
|
464
|
-
h.add_args,
|
|
465
|
-
h.commit_type,
|
|
466
|
-
h.commit_message,
|
|
467
|
-
"",
|
|
468
|
-
str(e),
|
|
469
|
-
)
|
|
462
|
+
return h, str(e)
|
|
470
463
|
h = suggest_from_changes(
|
|
471
464
|
changes=change_items,
|
|
472
465
|
has_commits=has_commits,
|
|
473
466
|
diff_text=infer_diff,
|
|
474
467
|
)
|
|
475
|
-
return h
|
|
468
|
+
return h, None
|
|
476
469
|
|
|
477
470
|
selected_pairs = [(ch.status, ch.path) for ch in selected]
|
|
478
471
|
unique_paths = {p for _, p in selected_pairs}
|
|
@@ -492,18 +485,18 @@ def run(
|
|
|
492
485
|
if mode_input in ("one", "split"):
|
|
493
486
|
mode = mode_input
|
|
494
487
|
|
|
495
|
-
plan: list[tuple[str,
|
|
488
|
+
plan: list[tuple[str, Suggestion]] = []
|
|
496
489
|
ai_fallback_notes: list[tuple[str, str]] = []
|
|
497
490
|
if mode == "split":
|
|
498
491
|
groups = _group_changes(selected_pairs)
|
|
499
492
|
for gname, items in groups.items():
|
|
500
|
-
|
|
501
|
-
plan.append((gname,
|
|
493
|
+
sug, fb = suggest_for(items, title=gname.capitalize())
|
|
494
|
+
plan.append((gname, sug))
|
|
502
495
|
if fb:
|
|
503
496
|
ai_fallback_notes.append((gname, fb))
|
|
504
497
|
else:
|
|
505
|
-
|
|
506
|
-
plan.append(("one",
|
|
498
|
+
sug, fb = suggest_for(selected_pairs, title="Selected")
|
|
499
|
+
plan.append(("one", sug))
|
|
507
500
|
if fb:
|
|
508
501
|
ai_fallback_notes.append(("", fb))
|
|
509
502
|
|
|
@@ -530,12 +523,15 @@ def run(
|
|
|
530
523
|
)
|
|
531
524
|
)
|
|
532
525
|
|
|
533
|
-
def _render_plan(pl: list[tuple[str,
|
|
526
|
+
def _render_plan(pl: list[tuple[str, Suggestion]]) -> str:
|
|
534
527
|
rendered: list[str] = []
|
|
535
|
-
for name,
|
|
536
|
-
add_line = "git add -A -- " + " ".join(_ps_quote(p) for p in
|
|
537
|
-
subj = normalize_commit_subject_for_dash_m(
|
|
538
|
-
|
|
528
|
+
for name, sug in pl:
|
|
529
|
+
add_line = "git add -A -- " + " ".join(_ps_quote(p) for p in sug.add_args)
|
|
530
|
+
subj = normalize_commit_subject_for_dash_m(sug.commit_message)
|
|
531
|
+
full = format_commit_message(
|
|
532
|
+
sug.commit_type, subj, scope=sug.scope, breaking=sug.breaking
|
|
533
|
+
)
|
|
534
|
+
commit_line = f'git commit -m "{full}"'
|
|
539
535
|
rendered.append(f"### {name}\n{add_line}\n{commit_line}")
|
|
540
536
|
return "\n\n".join(rendered)
|
|
541
537
|
|
|
@@ -557,29 +553,35 @@ def run(
|
|
|
557
553
|
.lower()
|
|
558
554
|
)
|
|
559
555
|
if edit_choice in ("y", "yes"):
|
|
560
|
-
updated: list[tuple[str,
|
|
561
|
-
for name,
|
|
556
|
+
updated: list[tuple[str, Suggestion]] = []
|
|
557
|
+
for name, sug in plan:
|
|
558
|
+
current = format_commit_message(
|
|
559
|
+
sug.commit_type,
|
|
560
|
+
sug.commit_message,
|
|
561
|
+
scope=sug.scope,
|
|
562
|
+
breaking=sug.breaking,
|
|
563
|
+
)
|
|
562
564
|
console.print(
|
|
563
|
-
f"[dim]{name}:[/dim] current message: [bold]
|
|
565
|
+
f"[dim]{name}:[/dim] current message: [bold]{current}[/bold]"
|
|
564
566
|
)
|
|
565
567
|
try:
|
|
566
568
|
from prompt_toolkit import prompt as pt_prompt
|
|
567
569
|
|
|
568
570
|
new_msg = (
|
|
569
571
|
pt_prompt(
|
|
570
|
-
"New commit message (subject only,
|
|
571
|
-
default=
|
|
572
|
+
"New commit message (subject only, type/scope added automatically): ",
|
|
573
|
+
default=sug.commit_message,
|
|
572
574
|
).strip()
|
|
573
|
-
or
|
|
575
|
+
or sug.commit_message
|
|
574
576
|
)
|
|
575
577
|
except Exception:
|
|
576
578
|
new_msg = (
|
|
577
579
|
typer.prompt(
|
|
578
|
-
"New commit message (subject only,
|
|
579
|
-
default=
|
|
580
|
+
"New commit message (subject only, type/scope added automatically)",
|
|
581
|
+
default=sug.commit_message,
|
|
580
582
|
).strip()
|
|
581
|
-
) or
|
|
582
|
-
updated.append((name,
|
|
583
|
+
) or sug.commit_message
|
|
584
|
+
updated.append((name, replace(sug, commit_message=new_msg)))
|
|
583
585
|
plan = updated
|
|
584
586
|
console.print(
|
|
585
587
|
Panel(
|
|
@@ -601,13 +603,16 @@ def run(
|
|
|
601
603
|
do_apply = choice == "auto" or choice in ("y", "yes")
|
|
602
604
|
|
|
603
605
|
if do_apply:
|
|
604
|
-
for name,
|
|
606
|
+
for name, sug in plan:
|
|
605
607
|
try:
|
|
606
608
|
apply_commands(
|
|
607
609
|
repo_root,
|
|
608
|
-
[] if staged_only else
|
|
609
|
-
|
|
610
|
-
|
|
610
|
+
[] if staged_only else sug.add_args,
|
|
611
|
+
sug.commit_type,
|
|
612
|
+
sug.commit_message,
|
|
613
|
+
scope=sug.scope,
|
|
614
|
+
body=sug.body,
|
|
615
|
+
breaking=sug.breaking,
|
|
611
616
|
staged_only=staged_only,
|
|
612
617
|
)
|
|
613
618
|
console.print(f"[green]Commit created ({name}).[/green]")
|
|
@@ -47,7 +47,7 @@ def refine_type_and_message_from_diff(
|
|
|
47
47
|
when the diff matches known bugfix patterns.
|
|
48
48
|
"""
|
|
49
49
|
ct = (commit_type or "").upper()
|
|
50
|
-
if ct in ("DOCS", "TEST", "TESTS", "CHORE"):
|
|
50
|
+
if ct in ("DOCS", "TEST", "TESTS", "CHORE", "BUILD", "CI", "STYLE", "PERF"):
|
|
51
51
|
return commit_type, commit_message
|
|
52
52
|
|
|
53
53
|
subject = infer_fix_subject_from_diff(diff_text)
|
|
@@ -12,12 +12,31 @@ from git_explain.commit_infer import refine_type_and_message_from_diff
|
|
|
12
12
|
from git_explain.path_topics import (
|
|
13
13
|
area_scope_suffix,
|
|
14
14
|
basename_fallback_topic,
|
|
15
|
+
infer_scope,
|
|
15
16
|
infra_deploy_topics,
|
|
16
17
|
is_test_path,
|
|
17
18
|
test_subject_hints,
|
|
18
19
|
)
|
|
19
20
|
|
|
20
|
-
|
|
21
|
+
VALID_TYPES = frozenset(
|
|
22
|
+
{
|
|
23
|
+
"FEAT",
|
|
24
|
+
"FIX",
|
|
25
|
+
"DOCS",
|
|
26
|
+
"REFACTOR",
|
|
27
|
+
"TEST",
|
|
28
|
+
"CHORE",
|
|
29
|
+
"BUILD",
|
|
30
|
+
"CI",
|
|
31
|
+
"STYLE",
|
|
32
|
+
"PERF",
|
|
33
|
+
"REVERT",
|
|
34
|
+
}
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
_TYPE_RE_ALT = "|".join(sorted(VALID_TYPES | {"TESTS"}, key=len, reverse=True))
|
|
38
|
+
|
|
39
|
+
SYSTEM_PROMPT = f"""You are given a list of changed/added files under ## Staged, ## Unstaged, ## Untracked.
|
|
21
40
|
Each file line is: <STATUS> <PATH> where STATUS is one of:
|
|
22
41
|
- A = added/new file
|
|
23
42
|
- M = modified
|
|
@@ -25,56 +44,81 @@ Each file line is: <STATUS> <PATH> where STATUS is one of:
|
|
|
25
44
|
- R = renamed
|
|
26
45
|
- C = copied
|
|
27
46
|
|
|
28
|
-
Suggest one commit that includes ALL of these files.
|
|
47
|
+
Suggest one commit that includes ALL of these files using Conventional Commits format.
|
|
29
48
|
|
|
30
49
|
Rules:
|
|
31
50
|
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.
|
|
32
|
-
2. Line 2 must be: git commit -m "
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
51
|
+
2. Line 2 must be: git commit -m "type(scope): description"
|
|
52
|
+
- type: exactly one of: feat, fix, docs, refactor, test, chore, build, ci, style, perf
|
|
53
|
+
- scope (optional): a noun in parentheses describing the area of the codebase (e.g., cli, api, parser). Omit if the change spans many unrelated areas.
|
|
54
|
+
- If the change introduces a breaking API change, add ! after the type/scope: feat!: or feat(api)!:
|
|
55
|
+
- description: imperative mood, lowercase first letter, no period at end. Up to about 200 characters—finish the thought completely.
|
|
56
|
+
3. The description must state **what the change does** (behavior, feature, fix)—not a comma-separated list of folders or path segments. You may mention one path if it disambiguates. Never use only generic words like "update", "changes", or "refactor" by themselves.
|
|
57
|
+
4. Infer concrete artifacts from paths when obvious: Dockerfiles, Docker Compose files, nginx configs, .env/.env.example templates, CI workflows—not vague summaries. For test paths (e.g. tests/test_foo.py), name the area under test (e.g. "expand tests for foo and bar").
|
|
58
|
+
5. Use fix when the change corrects broken behavior, wrong CLI flow, or misleading errors—not refactor for those cases.
|
|
59
|
+
6. Use build for build system / dependency changes (Dockerfile, pyproject.toml, requirements.txt, Makefile). Use ci for CI/CD config (.github/workflows, .gitlab-ci.yml, etc.).
|
|
37
60
|
|
|
38
61
|
Example for files README.md, FEATURES.md, git_explain/gemini.py:
|
|
39
62
|
git add README.md FEATURES.md git_explain/gemini.py
|
|
40
|
-
git commit -m "
|
|
63
|
+
git commit -m "docs: add README and FEATURES doc, tune Gemini prompt"
|
|
41
64
|
|
|
42
|
-
Example for Docker + nginx
|
|
43
|
-
git add api/
|
|
44
|
-
git commit -m "
|
|
65
|
+
Example for Docker + nginx under api/:
|
|
66
|
+
git add api/Dockerfile api/nginx.conf
|
|
67
|
+
git commit -m "build(api): add Docker and nginx configuration"
|
|
45
68
|
"""
|
|
46
69
|
|
|
47
|
-
SYSTEM_PROMPT_WITH_DIFF = """You are given:
|
|
70
|
+
SYSTEM_PROMPT_WITH_DIFF = f"""You are given:
|
|
48
71
|
1. A list of changed/added files (## Staged, ## Unstaged, ## Untracked) with <STATUS> <PATH>.
|
|
49
|
-
2. The full diff (##
|
|
72
|
+
2. The full diff (## Diff) showing exact code changes.
|
|
50
73
|
|
|
51
|
-
Use the diff to write a **specific, detailed** commit message about **what changed in behavior, UI, data flow, or APIs**. Quote or paraphrase the actual diff: new props, renamed state, conditional logic, extracted components, bug fixes, etc.
|
|
52
|
-
**Do not** summarize by only listing directories, modules, or file names
|
|
53
|
-
Avoid hollow words like "update" or "changes" without saying what moved or why.
|
|
54
|
-
Prefer
|
|
74
|
+
Use the diff to write a **specific, detailed** commit message in Conventional Commits format about **what changed in behavior, UI, data flow, or APIs**. Quote or paraphrase the actual diff: new props, renamed state, conditional logic, extracted components, bug fixes, etc.
|
|
75
|
+
**Do not** summarize by only listing directories, modules, or file names. If many files move together, state the **theme** of the change in plain language.
|
|
76
|
+
Avoid hollow words like "update" or "changes" without saying what moved or why.
|
|
77
|
+
Prefer fix when the diff corrects incorrect behavior or user-visible bugs; use refactor only for internal restructuring without behavior change.
|
|
78
|
+
Use build for build/dependency changes, ci for CI/CD config changes.
|
|
55
79
|
|
|
56
|
-
Output format
|
|
80
|
+
Output format:
|
|
57
81
|
- Line 1: git add <path1> <path2> ... with EVERY path from the file list. Do not omit any.
|
|
58
|
-
- Line 2: git commit -m "type:
|
|
59
|
-
|
|
82
|
+
- Line 2: git commit -m "type(scope): description"
|
|
83
|
+
- type: exactly one of: feat, fix, docs, refactor, test, chore, build, ci, style, perf
|
|
84
|
+
- scope (optional): noun in parentheses describing the area. Omit if change spans many areas.
|
|
85
|
+
- If breaking change, add ! after type/scope: feat!: or feat(api)!:
|
|
86
|
+
- description: imperative mood, lowercase first letter, no period at end. Up to 200 characters—complete the sentence, never cut mid-word.
|
|
60
87
|
|
|
61
88
|
Example:
|
|
62
89
|
git add git_explain/cli.py git_explain/gemini.py
|
|
63
|
-
git commit -m "feat: add opt-in --with-diff for detailed AI commit messages"
|
|
90
|
+
git commit -m "feat(cli): add opt-in --with-diff for detailed AI commit messages"
|
|
64
91
|
"""
|
|
65
92
|
|
|
66
93
|
ADD_LINE_RE = re.compile(r"git\s+add\s+(.+)", re.IGNORECASE)
|
|
94
|
+
|
|
67
95
|
COMMIT_LINE_RE = re.compile(
|
|
68
|
-
r
|
|
96
|
+
r"git\s+commit\s+-m\s+[\"']"
|
|
97
|
+
rf"({_TYPE_RE_ALT})"
|
|
98
|
+
r"(?:\(([^)]*)\))?"
|
|
99
|
+
r"(!?)"
|
|
100
|
+
r"\s*:\s*(.+?)"
|
|
101
|
+
r"[\"']",
|
|
69
102
|
re.IGNORECASE,
|
|
70
103
|
)
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
r
|
|
104
|
+
|
|
105
|
+
_COMMIT_LINE_BRACKET_RE = re.compile(
|
|
106
|
+
r"git\s+commit\s+-m\s+[\"']"
|
|
107
|
+
rf"\[({_TYPE_RE_ALT})\]"
|
|
108
|
+
r"\s*(.+?)"
|
|
109
|
+
r"[\"']",
|
|
74
110
|
re.IGNORECASE,
|
|
75
111
|
)
|
|
112
|
+
|
|
76
113
|
DEFAULT_MODEL = "gemini-2.5-flash"
|
|
77
114
|
|
|
115
|
+
|
|
116
|
+
def _normalize_type(t: str) -> str:
|
|
117
|
+
upper = (t or "").upper()
|
|
118
|
+
if upper == "TESTS":
|
|
119
|
+
return "TEST"
|
|
120
|
+
return upper if upper in VALID_TYPES else "CHORE"
|
|
121
|
+
|
|
78
122
|
# Single-line subject for `git commit -m` (no body); allow longer than classic 72 when users want detail.
|
|
79
123
|
MAX_COMMIT_SUBJECT_CHARS = 200
|
|
80
124
|
|
|
@@ -318,22 +362,18 @@ def _fallback_type_and_message_with_context(
|
|
|
318
362
|
all_tests_only = len(test_files) == len(files)
|
|
319
363
|
hints = test_subject_hints(files)
|
|
320
364
|
if all_tests_only and hints:
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
365
|
+
if len(hints) <= 4:
|
|
366
|
+
head = ", ".join(hints[:-1]) + " and " + hints[-1] if len(hints) > 1 else hints[0]
|
|
367
|
+
else:
|
|
368
|
+
head = ", ".join(hints[:4])
|
|
369
|
+
topics.append(f"tests for {head}")
|
|
324
370
|
else:
|
|
325
371
|
topics.append("tests")
|
|
326
372
|
if touches_docs and not docs_only:
|
|
327
373
|
topics.append("docs")
|
|
328
374
|
code_topics = _code_topics(files)
|
|
329
375
|
if code_topics:
|
|
330
|
-
|
|
331
|
-
label = ", ".join(code_topics)
|
|
332
|
-
else:
|
|
333
|
-
head = ", ".join(code_topics[:3])
|
|
334
|
-
rest = len(code_topics) - 3
|
|
335
|
-
label = f"{head} and {rest} related areas"
|
|
336
|
-
topics.append(label)
|
|
376
|
+
topics.append(", ".join(code_topics[:5]))
|
|
337
377
|
if touches_packaging:
|
|
338
378
|
topics.append("packaging config")
|
|
339
379
|
|
|
@@ -405,6 +445,9 @@ class Suggestion:
|
|
|
405
445
|
add_args: list[str]
|
|
406
446
|
commit_type: str
|
|
407
447
|
commit_message: str
|
|
448
|
+
scope: str | None = None
|
|
449
|
+
body: str | None = None
|
|
450
|
+
breaking: bool = False
|
|
408
451
|
|
|
409
452
|
|
|
410
453
|
def _get_client() -> genai.Client:
|
|
@@ -481,20 +524,24 @@ def suggest_commands(
|
|
|
481
524
|
add_args: list[str] = []
|
|
482
525
|
commit_type = "REFACTOR"
|
|
483
526
|
commit_message = "update"
|
|
527
|
+
scope: str | None = None
|
|
528
|
+
breaking = False
|
|
484
529
|
for line in lines:
|
|
485
530
|
add_m = ADD_LINE_RE.match(line)
|
|
486
531
|
if add_m:
|
|
487
532
|
add_args = [f.strip() for f in add_m.group(1).split() if f.strip()]
|
|
488
533
|
continue
|
|
489
|
-
commit_m = COMMIT_LINE_CONVENTIONAL_RE.match(line) if with_diff else None
|
|
490
|
-
if commit_m:
|
|
491
|
-
commit_type = commit_m.group(1).upper()
|
|
492
|
-
commit_message = commit_m.group(2).strip().rstrip(".")
|
|
493
|
-
break
|
|
494
534
|
commit_m = COMMIT_LINE_RE.match(line)
|
|
495
535
|
if commit_m:
|
|
496
|
-
commit_type = commit_m.group(1)
|
|
497
|
-
|
|
536
|
+
commit_type = _normalize_type(commit_m.group(1))
|
|
537
|
+
scope = commit_m.group(2) or None
|
|
538
|
+
breaking = commit_m.group(3) == "!"
|
|
539
|
+
commit_message = commit_m.group(4).strip().rstrip(".")
|
|
540
|
+
break
|
|
541
|
+
bracket_m = _COMMIT_LINE_BRACKET_RE.match(line)
|
|
542
|
+
if bracket_m:
|
|
543
|
+
commit_type = _normalize_type(bracket_m.group(1))
|
|
544
|
+
commit_message = bracket_m.group(2).strip().rstrip(".")
|
|
498
545
|
break
|
|
499
546
|
if not add_args or not commit_message:
|
|
500
547
|
return None, raw
|
|
@@ -507,11 +554,9 @@ def suggest_commands(
|
|
|
507
554
|
all_paths = [p for _, p in entries]
|
|
508
555
|
added_any = any(s == "A" for s, _ in entries)
|
|
509
556
|
|
|
510
|
-
# Always use the full path list we sent (model may truncate or omit)
|
|
511
557
|
if all_paths:
|
|
512
558
|
add_args = all_paths
|
|
513
559
|
|
|
514
|
-
# If we're adding new files (or this is an initial commit), don't label it REFACTOR
|
|
515
560
|
docs_only = all_paths and all(
|
|
516
561
|
os.path.splitext(p)[1].lower() in {".md", ".rst", ".txt"} for p in all_paths
|
|
517
562
|
)
|
|
@@ -523,6 +568,9 @@ def suggest_commands(
|
|
|
523
568
|
files=add_args, added_any=added_any, has_commits=has_commits
|
|
524
569
|
)
|
|
525
570
|
|
|
571
|
+
if scope is None:
|
|
572
|
+
scope = infer_scope(add_args)
|
|
573
|
+
|
|
526
574
|
infer_body = unified_diff_for_infer
|
|
527
575
|
if not (infer_body and infer_body.strip()) and with_diff and "\n## Diff" in diff:
|
|
528
576
|
infer_body = diff.split("\n## Diff", 1)[1]
|
|
@@ -531,5 +579,9 @@ def suggest_commands(
|
|
|
531
579
|
)
|
|
532
580
|
|
|
533
581
|
return Suggestion(
|
|
534
|
-
add_args=add_args,
|
|
582
|
+
add_args=add_args,
|
|
583
|
+
commit_type=commit_type,
|
|
584
|
+
commit_message=commit_message,
|
|
585
|
+
scope=scope,
|
|
586
|
+
breaking=breaking,
|
|
535
587
|
), raw
|
|
@@ -14,7 +14,10 @@ from git_explain.gemini import (
|
|
|
14
14
|
from git_explain.path_topics import (
|
|
15
15
|
area_scope_suffix,
|
|
16
16
|
basename_fallback_topic,
|
|
17
|
+
infer_scope,
|
|
17
18
|
infra_deploy_topics,
|
|
19
|
+
is_build_path,
|
|
20
|
+
is_ci_path,
|
|
18
21
|
is_infra_deploy_path,
|
|
19
22
|
is_test_path,
|
|
20
23
|
test_subject_hints,
|
|
@@ -105,25 +108,33 @@ def suggest_from_changes(
|
|
|
105
108
|
docs = [p for p in paths if _is_doc(p)]
|
|
106
109
|
tests = [p for p in paths if is_test_path(p)]
|
|
107
110
|
configs = [p for p in paths if _is_config(p)]
|
|
111
|
+
ci_files = [p for p in paths if is_ci_path(p)]
|
|
112
|
+
build_files = [p for p in paths if is_build_path(p)]
|
|
108
113
|
has_tests = bool(tests)
|
|
109
114
|
has_configs = bool(configs)
|
|
110
115
|
non_docs = [p for p in paths if p not in docs]
|
|
111
116
|
|
|
112
117
|
docs_only = bool(paths) and len(docs) == len(paths)
|
|
118
|
+
ci_only = bool(paths) and len(ci_files) == len(paths)
|
|
119
|
+
build_only = bool(paths) and len(build_files) == len(paths)
|
|
113
120
|
mostly_tests_or_config = False
|
|
114
121
|
if non_docs:
|
|
115
122
|
tc = len([p for p in non_docs if p in tests or p in configs])
|
|
116
123
|
mostly_tests_or_config = tc / max(1, len(non_docs)) >= 0.6
|
|
117
124
|
|
|
118
125
|
if has_commits is False:
|
|
119
|
-
verb = "
|
|
126
|
+
verb = "add"
|
|
120
127
|
elif added_any and not modified_any:
|
|
121
|
-
verb = "
|
|
128
|
+
verb = "add"
|
|
122
129
|
else:
|
|
123
|
-
verb = "
|
|
130
|
+
verb = "update"
|
|
124
131
|
|
|
125
132
|
if docs_only:
|
|
126
133
|
commit_type = "DOCS"
|
|
134
|
+
elif ci_only:
|
|
135
|
+
commit_type = "CI"
|
|
136
|
+
elif build_only:
|
|
137
|
+
commit_type = "BUILD"
|
|
127
138
|
elif mostly_tests_or_config:
|
|
128
139
|
if has_tests and not has_configs:
|
|
129
140
|
commit_type = "TEST"
|
|
@@ -146,22 +157,18 @@ def suggest_from_changes(
|
|
|
146
157
|
all_tests_only = bool(paths) and len(tests) == len(paths)
|
|
147
158
|
hints = test_subject_hints(paths)
|
|
148
159
|
if all_tests_only and hints:
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
160
|
+
if len(hints) <= 4:
|
|
161
|
+
head = ", ".join(hints[:-1]) + " and " + hints[-1] if len(hints) > 1 else hints[0]
|
|
162
|
+
else:
|
|
163
|
+
head = ", ".join(hints[:4])
|
|
164
|
+
topics.append(f"tests for {head}")
|
|
152
165
|
else:
|
|
153
166
|
topics.append("tests")
|
|
154
167
|
if any(_is_plain_config(p) for p in paths):
|
|
155
168
|
topics.append("config")
|
|
156
169
|
code_topics = _code_topics(paths)
|
|
157
170
|
if code_topics:
|
|
158
|
-
|
|
159
|
-
label = ", ".join(code_topics)
|
|
160
|
-
else:
|
|
161
|
-
head = ", ".join(code_topics[:3])
|
|
162
|
-
rest = len(code_topics) - 3
|
|
163
|
-
label = f"{head} and {rest} related areas"
|
|
164
|
-
topics.append(label)
|
|
171
|
+
topics.append(", ".join(code_topics[:5]))
|
|
165
172
|
|
|
166
173
|
# Dedupe while preserving order
|
|
167
174
|
seen: set[str] = set()
|
|
@@ -178,15 +185,15 @@ def suggest_from_changes(
|
|
|
178
185
|
else:
|
|
179
186
|
message = f"{verb} {topics[0]}, {topics[1]}, and {topics[2]}"
|
|
180
187
|
|
|
181
|
-
|
|
182
|
-
if
|
|
183
|
-
scope_key = _alnum_key(
|
|
188
|
+
scope_suffix = area_scope_suffix(paths)
|
|
189
|
+
if scope_suffix:
|
|
190
|
+
scope_key = _alnum_key(scope_suffix.replace("for", "", 1))
|
|
184
191
|
msg_key = _alnum_key(message)
|
|
185
192
|
if scope_key and scope_key not in msg_key:
|
|
186
|
-
message +=
|
|
193
|
+
message += scope_suffix
|
|
187
194
|
|
|
188
|
-
if added_any and has_commits is False and message.startswith("
|
|
189
|
-
message = message.replace("
|
|
195
|
+
if added_any and has_commits is False and message.startswith("add "):
|
|
196
|
+
message = message.replace("add ", "add initial ", 1)
|
|
190
197
|
|
|
191
198
|
message = truncate_commit_subject(message, MAX_COMMIT_SUBJECT_CHARS)
|
|
192
199
|
|
|
@@ -194,4 +201,11 @@ def suggest_from_changes(
|
|
|
194
201
|
commit_type, message, diff_text
|
|
195
202
|
)
|
|
196
203
|
|
|
197
|
-
|
|
204
|
+
cc_scope = infer_scope(paths)
|
|
205
|
+
|
|
206
|
+
return Suggestion(
|
|
207
|
+
add_args=paths,
|
|
208
|
+
commit_type=commit_type,
|
|
209
|
+
commit_message=message,
|
|
210
|
+
scope=cc_scope,
|
|
211
|
+
)
|
|
@@ -158,6 +158,86 @@ def area_scope_suffix(paths: list[str]) -> str:
|
|
|
158
158
|
return f" for {labels[0]}, {labels[1]}, and {labels[2]}"
|
|
159
159
|
|
|
160
160
|
|
|
161
|
+
_CI_FILES = {
|
|
162
|
+
".gitlab-ci.yml",
|
|
163
|
+
".travis.yml",
|
|
164
|
+
"azure-pipelines.yml",
|
|
165
|
+
"jenkinsfile",
|
|
166
|
+
"bitbucket-pipelines.yml",
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def is_ci_path(path: str) -> bool:
|
|
171
|
+
"""True if path looks like a CI/CD configuration file."""
|
|
172
|
+
p = _norm(path).lower()
|
|
173
|
+
base = os.path.basename(p)
|
|
174
|
+
if ".github/workflows/" in p or ".github/actions/" in p:
|
|
175
|
+
return True
|
|
176
|
+
if base in _CI_FILES:
|
|
177
|
+
return True
|
|
178
|
+
if ".circleci/" in p:
|
|
179
|
+
return True
|
|
180
|
+
return False
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
_BUILD_FILES = {
|
|
184
|
+
"pyproject.toml",
|
|
185
|
+
"setup.py",
|
|
186
|
+
"setup.cfg",
|
|
187
|
+
"requirements.txt",
|
|
188
|
+
"requirements-dev.txt",
|
|
189
|
+
"makefile",
|
|
190
|
+
"gnumakefile",
|
|
191
|
+
"package.json",
|
|
192
|
+
"package-lock.json",
|
|
193
|
+
"yarn.lock",
|
|
194
|
+
"pnpm-lock.yaml",
|
|
195
|
+
"cargo.toml",
|
|
196
|
+
"cargo.lock",
|
|
197
|
+
"go.mod",
|
|
198
|
+
"go.sum",
|
|
199
|
+
"gemfile",
|
|
200
|
+
"gemfile.lock",
|
|
201
|
+
"build.gradle",
|
|
202
|
+
"pom.xml",
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def is_build_path(path: str) -> bool:
|
|
207
|
+
"""True if path is a build system, packaging, or containerization file."""
|
|
208
|
+
p = _norm(path).lower()
|
|
209
|
+
base = os.path.basename(p)
|
|
210
|
+
if base in _BUILD_FILES:
|
|
211
|
+
return True
|
|
212
|
+
return is_infra_deploy_path(path)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def infer_scope(paths: list[str]) -> str | None:
|
|
216
|
+
"""Infer a conventional commits scope from file paths.
|
|
217
|
+
|
|
218
|
+
Returns a single scope when all non-root files share one top-level
|
|
219
|
+
directory, otherwise None.
|
|
220
|
+
"""
|
|
221
|
+
if not paths:
|
|
222
|
+
return None
|
|
223
|
+
|
|
224
|
+
candidates: set[str] = set()
|
|
225
|
+
for raw in paths:
|
|
226
|
+
p = _norm(raw).lower()
|
|
227
|
+
parts = [x for x in p.split("/") if x]
|
|
228
|
+
if len(parts) < 2:
|
|
229
|
+
continue
|
|
230
|
+
first = parts[0]
|
|
231
|
+
if first in ("apps", "packages", "services") and len(parts) >= 3:
|
|
232
|
+
candidates.add(parts[1])
|
|
233
|
+
else:
|
|
234
|
+
candidates.add(first)
|
|
235
|
+
|
|
236
|
+
if len(candidates) == 1:
|
|
237
|
+
return candidates.pop().replace("_", "-")
|
|
238
|
+
return None
|
|
239
|
+
|
|
240
|
+
|
|
161
241
|
def basename_fallback_topic(paths: list[str], max_names: int = 4) -> str | None:
|
|
162
242
|
"""Short description from basenames when no other topic matched."""
|
|
163
243
|
bases: list[str] = []
|
|
@@ -7,6 +7,32 @@ from pathlib import Path
|
|
|
7
7
|
_GIT_TEXT = {"encoding": "utf-8", "errors": "replace"}
|
|
8
8
|
|
|
9
9
|
|
|
10
|
+
def _lowercase_first(s: str) -> str:
|
|
11
|
+
"""Lowercase the first character unless the first word is an acronym."""
|
|
12
|
+
if not s:
|
|
13
|
+
return s
|
|
14
|
+
first_space = s.find(" ")
|
|
15
|
+
first_word = s[:first_space] if first_space > 0 else s
|
|
16
|
+
if first_word == first_word.upper() and len(first_word) > 1:
|
|
17
|
+
return s
|
|
18
|
+
return s[0].lower() + s[1:]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def format_commit_message(
|
|
22
|
+
commit_type: str,
|
|
23
|
+
commit_message: str,
|
|
24
|
+
*,
|
|
25
|
+
scope: str | None = None,
|
|
26
|
+
breaking: bool = False,
|
|
27
|
+
) -> str:
|
|
28
|
+
"""Format a conventional commit subject: type(scope)!: description."""
|
|
29
|
+
type_str = commit_type.lower()
|
|
30
|
+
scope_str = f"({scope})" if scope else ""
|
|
31
|
+
bang = "!" if breaking else ""
|
|
32
|
+
msg = _lowercase_first(commit_message)
|
|
33
|
+
return f"{type_str}{scope_str}{bang}: {msg}"
|
|
34
|
+
|
|
35
|
+
|
|
10
36
|
def normalize_commit_subject_for_dash_m(message: str | None) -> str:
|
|
11
37
|
"""Single line for ``git commit -m``: newlines/tabs become spaces, strip ends.
|
|
12
38
|
|
|
@@ -39,6 +65,9 @@ def apply_commands(
|
|
|
39
65
|
commit_type: str,
|
|
40
66
|
commit_message: str,
|
|
41
67
|
*,
|
|
68
|
+
scope: str | None = None,
|
|
69
|
+
body: str | None = None,
|
|
70
|
+
breaking: bool = False,
|
|
42
71
|
staged_only: bool = False,
|
|
43
72
|
) -> None:
|
|
44
73
|
"""Stage selected paths and commit. Raises on failure.
|
|
@@ -67,9 +96,15 @@ def apply_commands(
|
|
|
67
96
|
)
|
|
68
97
|
raise RuntimeError("Nothing staged after git add; aborting commit.")
|
|
69
98
|
safe_msg = normalize_commit_subject_for_dash_m(commit_message)
|
|
70
|
-
full_message =
|
|
99
|
+
full_message = format_commit_message(
|
|
100
|
+
commit_type, safe_msg, scope=scope, breaking=breaking
|
|
101
|
+
)
|
|
102
|
+
cmd = ["git", "commit", "-m", full_message]
|
|
103
|
+
if body:
|
|
104
|
+
safe_body = normalize_commit_subject_for_dash_m(body)
|
|
105
|
+
cmd.extend(["-m", safe_body])
|
|
71
106
|
subprocess.run(
|
|
72
|
-
|
|
107
|
+
cmd,
|
|
73
108
|
check=True,
|
|
74
109
|
cwd=root,
|
|
75
110
|
capture_output=True,
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: git-explain
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.2.0
|
|
4
4
|
Summary: CLI that suggests git add/commit from diffs using Gemini
|
|
5
|
-
Author:
|
|
5
|
+
Author: nazarli-shabnam
|
|
6
6
|
License-Expression: MIT
|
|
7
7
|
Project-URL: Homepage, https://github.com/nazarli-shabnam/git-explain
|
|
8
8
|
Project-URL: Source, https://github.com/nazarli-shabnam/git-explain
|
|
@@ -51,6 +51,14 @@ pip install git-explain
|
|
|
51
51
|
pip install -e .
|
|
52
52
|
```
|
|
53
53
|
|
|
54
|
+
**Clone:** install deps (`requirements.txt` is fine), `cd` into the repo, then:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
python -m git_explain
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Run that from the repo root so Python picks up the `git_explain` folder—no `pip install -e .` needed.
|
|
61
|
+
|
|
54
62
|
Optional: install a specific tag from GitHub instead of PyPI:
|
|
55
63
|
|
|
56
64
|
```bash
|
|
@@ -70,6 +78,8 @@ pip install "git+https://github.com/nazarli-shabnam/git-explain.git@v2.1.8"
|
|
|
70
78
|
|
|
71
79
|
Heuristics guess a sensible type and message from paths and statuses. **No account, no key, no network** for that path.
|
|
72
80
|
|
|
81
|
+
Suggested commits follow **[Conventional Commits](https://www.conventionalcommits.org/)**—`feat: …`, `fix: …`, optional `(scope)`, and so on—so changelogs and release tools can read them.
|
|
82
|
+
|
|
73
83
|
---
|
|
74
84
|
|
|
75
85
|
## Optional: Gemini
|
|
@@ -80,7 +90,7 @@ If you want sharper messages, set **`GEMINI_API_KEY`** (or `GOOGLE_API_KEY`) in
|
|
|
80
90
|
|--------|----------------|
|
|
81
91
|
| `git-explain --ai` | AI sees **paths and change type** only (no file contents). |
|
|
82
92
|
| `git-explain --ai --with-diff` | AI also sees the **diff**—better detail; only use if you’re OK sending that to the API. |
|
|
83
|
-
| `git-explain --suggest` | **Staged
|
|
93
|
+
| `git-explain --suggest` | **Staged only**; prints one plain `git commit -m "…"` line (easy to copy). Needs AI; don’t combine with other flags. |
|
|
84
94
|
|
|
85
95
|
Everything else (`--auto`, `--staged-only`, `--cwd`, model override, shell completion): **`git-explain --help`**.
|
|
86
96
|
|
|
@@ -1,54 +1,76 @@
|
|
|
1
1
|
from git_explain.gemini import (
|
|
2
|
-
COMMIT_LINE_CONVENTIONAL_RE,
|
|
3
2
|
COMMIT_LINE_RE,
|
|
3
|
+
_COMMIT_LINE_BRACKET_RE,
|
|
4
4
|
_fallback_type_and_message_with_context,
|
|
5
5
|
_is_generic_message,
|
|
6
|
+
_normalize_type,
|
|
6
7
|
truncate_commit_subject,
|
|
7
8
|
)
|
|
8
9
|
|
|
9
10
|
|
|
10
|
-
def
|
|
11
|
-
"""COMMIT_LINE_RE should match
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
11
|
+
def test_commit_line_re_matches_conventional_types() -> None:
|
|
12
|
+
"""COMMIT_LINE_RE should match conventional commits: type: subject."""
|
|
13
|
+
for line, expected_type in [
|
|
14
|
+
('git commit -m "feat: add new feature"', "FEAT"),
|
|
15
|
+
('git commit -m "fix: correct the bug"', "FIX"),
|
|
16
|
+
('git commit -m "docs: update readme"', "DOCS"),
|
|
17
|
+
('git commit -m "refactor: simplify logic"', "REFACTOR"),
|
|
18
|
+
('git commit -m "test: add unit tests"', "TEST"),
|
|
19
|
+
('git commit -m "chore: update deps"', "CHORE"),
|
|
20
|
+
('git commit -m "build: update dockerfile"', "BUILD"),
|
|
21
|
+
('git commit -m "ci: add github workflow"', "CI"),
|
|
22
|
+
('git commit -m "style: fix formatting"', "STYLE"),
|
|
23
|
+
('git commit -m "perf: optimize query"', "PERF"),
|
|
24
|
+
]:
|
|
25
|
+
m = COMMIT_LINE_RE.match(line)
|
|
26
|
+
assert m is not None, f"Expected match for {line}"
|
|
27
|
+
assert _normalize_type(m.group(1)) == expected_type
|
|
17
28
|
|
|
18
|
-
line_test = 'git commit -m "[TEST] Add unit test"'
|
|
19
|
-
m = COMMIT_LINE_RE.match(line_test)
|
|
20
|
-
assert m is None
|
|
21
29
|
|
|
30
|
+
def test_commit_line_re_matches_scope_and_breaking() -> None:
|
|
31
|
+
"""COMMIT_LINE_RE should parse scope and breaking change indicator."""
|
|
32
|
+
line = 'git commit -m "feat(cli): add new flag"'
|
|
33
|
+
m = COMMIT_LINE_RE.match(line)
|
|
34
|
+
assert m is not None
|
|
35
|
+
assert m.group(1).upper() == "FEAT"
|
|
36
|
+
assert m.group(2) == "cli"
|
|
37
|
+
assert m.group(3) == ""
|
|
38
|
+
assert "add new flag" in m.group(4)
|
|
22
39
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
line = 'git commit -m "tests: add unit tests"'
|
|
26
|
-
m = COMMIT_LINE_CONVENTIONAL_RE.match(line)
|
|
40
|
+
line = 'git commit -m "feat(api)!: drop legacy endpoint"'
|
|
41
|
+
m = COMMIT_LINE_RE.match(line)
|
|
27
42
|
assert m is not None
|
|
28
|
-
assert m.group(
|
|
43
|
+
assert m.group(2) == "api"
|
|
44
|
+
assert m.group(3) == "!"
|
|
29
45
|
|
|
30
|
-
|
|
31
|
-
m =
|
|
32
|
-
assert m is None
|
|
46
|
+
line = 'git commit -m "fix!: breaking bugfix"'
|
|
47
|
+
m = COMMIT_LINE_RE.match(line)
|
|
48
|
+
assert m is not None
|
|
49
|
+
assert m.group(2) is None
|
|
50
|
+
assert m.group(3) == "!"
|
|
33
51
|
|
|
34
52
|
|
|
35
|
-
def
|
|
53
|
+
def test_bracket_re_matches_legacy_format() -> None:
|
|
54
|
+
"""_COMMIT_LINE_BRACKET_RE should match [TYPE] format as fallback."""
|
|
36
55
|
for line in [
|
|
37
56
|
'git commit -m "[FEAT] Add feature"',
|
|
38
57
|
'git commit -m "[FIX] Fix bug"',
|
|
39
58
|
'git commit -m "[DOCS] Update readme"',
|
|
40
|
-
'git commit -m "[
|
|
41
|
-
'git commit -m "[CHORE] Add Docker
|
|
59
|
+
'git commit -m "[TESTS] Add tests"',
|
|
60
|
+
'git commit -m "[CHORE] Add Docker config"',
|
|
61
|
+
'git commit -m "[BUILD] Update dockerfile"',
|
|
62
|
+
'git commit -m "[CI] Add workflow"',
|
|
42
63
|
]:
|
|
43
|
-
m =
|
|
44
|
-
assert m is not None, f"Expected match for {line}"
|
|
64
|
+
m = _COMMIT_LINE_BRACKET_RE.match(line)
|
|
65
|
+
assert m is not None, f"Expected bracket match for {line}"
|
|
45
66
|
|
|
46
67
|
|
|
47
|
-
def
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
assert
|
|
51
|
-
assert
|
|
68
|
+
def test_normalize_type_converts_tests_to_test() -> None:
|
|
69
|
+
assert _normalize_type("TESTS") == "TEST"
|
|
70
|
+
assert _normalize_type("tests") == "TEST"
|
|
71
|
+
assert _normalize_type("TEST") == "TEST"
|
|
72
|
+
assert _normalize_type("feat") == "FEAT"
|
|
73
|
+
assert _normalize_type("unknown") == "CHORE"
|
|
52
74
|
|
|
53
75
|
|
|
54
76
|
def test_is_generic_message_flags_vague_add_changes() -> None:
|
|
@@ -143,7 +165,7 @@ def test_fallback_prefers_stems_when_folder_is_same() -> None:
|
|
|
143
165
|
assert "cli" in low or "gemini" in low or "git" in low
|
|
144
166
|
|
|
145
167
|
|
|
146
|
-
def
|
|
168
|
+
def test_fallback_many_folders_lists_topics_without_overflow() -> None:
|
|
147
169
|
folders = [f"ui/src/area{i}/File.tsx" for i in range(8)]
|
|
148
170
|
_ctype, msg = _fallback_type_and_message_with_context(
|
|
149
171
|
files=folders,
|
|
@@ -151,4 +173,5 @@ def test_fallback_many_folders_uses_related_areas_not_plus_more() -> None:
|
|
|
151
173
|
has_commits=True,
|
|
152
174
|
)
|
|
153
175
|
assert "(+" not in msg
|
|
154
|
-
assert "related areas" in msg.lower()
|
|
176
|
+
assert "related areas" not in msg.lower()
|
|
177
|
+
assert "area0" in msg.lower() or "area1" in msg.lower()
|
|
@@ -72,8 +72,8 @@ def test_test_only_paths_get_specific_test_message() -> None:
|
|
|
72
72
|
assert "project files" not in m
|
|
73
73
|
|
|
74
74
|
|
|
75
|
-
def
|
|
76
|
-
"""Infra paths should not collapse to '
|
|
75
|
+
def test_docker_nginx_env_paths_get_specific_build_message() -> None:
|
|
76
|
+
"""Infra paths should not collapse to 'add changes'."""
|
|
77
77
|
s = suggest_from_changes(
|
|
78
78
|
changes=[
|
|
79
79
|
("A", "api/app/.env.example"),
|
|
@@ -85,7 +85,7 @@ def test_docker_nginx_env_paths_get_specific_chore_message() -> None:
|
|
|
85
85
|
],
|
|
86
86
|
has_commits=True,
|
|
87
87
|
)
|
|
88
|
-
assert s.commit_type == "
|
|
88
|
+
assert s.commit_type == "BUILD"
|
|
89
89
|
m = s.commit_message.lower()
|
|
90
90
|
assert "docker" in m
|
|
91
91
|
assert "nginx" in m
|
|
@@ -44,7 +44,7 @@ def test_apply_commands_newlines_in_message_become_single_subject_line(
|
|
|
44
44
|
(repo / "a.txt").write_text("x\n", encoding="utf-8")
|
|
45
45
|
apply_commands(repo, ["a.txt"], "FIX", "first line\nsecond line")
|
|
46
46
|
subj = _git(repo, "log", "-1", "--format=%s").stdout.strip()
|
|
47
|
-
assert subj == "
|
|
47
|
+
assert subj == "fix: first line second line"
|
|
48
48
|
|
|
49
49
|
|
|
50
50
|
def test_apply_commands_deleted_file(tmp_path) -> None:
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "2.1.9"
|
|
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
|