git-explain 2.1.8__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.
Files changed (29) hide show
  1. git_explain-2.2.0/PKG-INFO +111 -0
  2. git_explain-2.2.0/README.md +80 -0
  3. git_explain-2.2.0/git_explain/__init__.py +1 -0
  4. git_explain-2.2.0/git_explain/__main__.py +3 -0
  5. {git_explain-2.1.8 → git_explain-2.2.0}/git_explain/cli.py +56 -49
  6. {git_explain-2.1.8 → git_explain-2.2.0}/git_explain/commit_infer.py +1 -1
  7. {git_explain-2.1.8 → git_explain-2.2.0}/git_explain/gemini.py +101 -47
  8. {git_explain-2.1.8 → git_explain-2.2.0}/git_explain/heuristics.py +39 -21
  9. {git_explain-2.1.8 → git_explain-2.2.0}/git_explain/path_topics.py +80 -0
  10. {git_explain-2.1.8 → git_explain-2.2.0}/git_explain/run.py +46 -2
  11. git_explain-2.2.0/git_explain.egg-info/PKG-INFO +111 -0
  12. {git_explain-2.1.8 → git_explain-2.2.0}/git_explain.egg-info/SOURCES.txt +1 -0
  13. {git_explain-2.1.8 → git_explain-2.2.0}/pyproject.toml +5 -2
  14. {git_explain-2.1.8 → git_explain-2.2.0}/tests/test_cli_utils.py +10 -0
  15. {git_explain-2.1.8 → git_explain-2.2.0}/tests/test_gemini.py +54 -31
  16. {git_explain-2.1.8 → git_explain-2.2.0}/tests/test_heuristics.py +3 -3
  17. {git_explain-2.1.8 → git_explain-2.2.0}/tests/test_run_apply.py +20 -1
  18. git_explain-2.1.8/PKG-INFO +0 -168
  19. git_explain-2.1.8/README.md +0 -137
  20. git_explain-2.1.8/git_explain/__init__.py +0 -1
  21. git_explain-2.1.8/git_explain.egg-info/PKG-INFO +0 -168
  22. {git_explain-2.1.8 → git_explain-2.2.0}/LICENSE +0 -0
  23. {git_explain-2.1.8 → git_explain-2.2.0}/git_explain/git.py +0 -0
  24. {git_explain-2.1.8 → git_explain-2.2.0}/git_explain.egg-info/dependency_links.txt +0 -0
  25. {git_explain-2.1.8 → git_explain-2.2.0}/git_explain.egg-info/entry_points.txt +0 -0
  26. {git_explain-2.1.8 → git_explain-2.2.0}/git_explain.egg-info/requires.txt +0 -0
  27. {git_explain-2.1.8 → git_explain-2.2.0}/git_explain.egg-info/top_level.txt +0 -0
  28. {git_explain-2.1.8 → git_explain-2.2.0}/setup.cfg +0 -0
  29. {git_explain-2.1.8 → git_explain-2.2.0}/tests/test_commit_infer.py +0 -0
@@ -0,0 +1,111 @@
1
+ Metadata-Version: 2.4
2
+ Name: git-explain
3
+ Version: 2.2.0
4
+ Summary: CLI that suggests git add/commit from diffs using Gemini
5
+ Author: nazarli-shabnam
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/nazarli-shabnam/git-explain
8
+ Project-URL: Source, https://github.com/nazarli-shabnam/git-explain
9
+ Keywords: git,cli,commit,ai,gemini
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3 :: Only
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Topic :: Software Development :: Version Control :: Git
19
+ Requires-Python: >=3.10
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Requires-Dist: google-genai>=1.50.0
23
+ Requires-Dist: typer>=0.12.0
24
+ Requires-Dist: rich>=13.0.0
25
+ Requires-Dist: python-dotenv>=1.0.0
26
+ Requires-Dist: prompt_toolkit>=3.0.0
27
+ Provides-Extra: dev
28
+ Requires-Dist: pytest>=8.0.0; extra == "dev"
29
+ Requires-Dist: ruff>=0.8.0; extra == "dev"
30
+ Dynamic: license-file
31
+
32
+ # git-explain
33
+
34
+ **Commit message block?** Run this in your repo after you change files. It suggests `git add` and `git commit` lines you can copy—or apply in one step if you want. **Nothing leaves your machine** unless you turn on AI.
35
+
36
+ [![PyPI](https://img.shields.io/pypi/v/git-explain.svg?label=pypi)](https://pypi.org/project/git-explain/)
37
+ [![GitHub tag](https://img.shields.io/github/v/tag/nazarli-shabnam/git-explain?label=repo)](https://github.com/nazarli-shabnam/git-explain/tags)
38
+
39
+
40
+ ---
41
+
42
+ ## Install (Python 3.10+)
43
+
44
+ ```bash
45
+ pip install git-explain
46
+ ```
47
+
48
+ **From source** (this repo):
49
+
50
+ ```bash
51
+ pip install -e .
52
+ ```
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
+
62
+ Optional: install a specific tag from GitHub instead of PyPI:
63
+
64
+ ```bash
65
+ pip install "git+https://github.com/nazarli-shabnam/git-explain.git@v2.1.8"
66
+ ```
67
+
68
+ ---
69
+
70
+ ## Try it
71
+
72
+ 1. In any git repo, change or add a file (not ignored).
73
+ 2. Run:
74
+ ```bash
75
+ git-explain
76
+ ```
77
+ 3. Choose what to include (`all` is fine), read the suggestion, answer **`n`** if you only want to copy commands yourself—nothing bad happens.
78
+
79
+ Heuristics guess a sensible type and message from paths and statuses. **No account, no key, no network** for that path.
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
+
83
+ ---
84
+
85
+ ## Optional: Gemini
86
+
87
+ If you want sharper messages, set **`GEMINI_API_KEY`** (or `GOOGLE_API_KEY`) in the environment or a **`.env`** file in the folder where you run the tool.
88
+
89
+ | Command | In plain terms |
90
+ |--------|----------------|
91
+ | `git-explain --ai` | AI sees **paths and change type** only (no file contents). |
92
+ | `git-explain --ai --with-diff` | AI also sees the **diff**—better detail; only use if you’re OK sending that to the API. |
93
+ | `git-explain --suggest` | **Staged only**; prints one plain `git commit -m "…"` line (easy to copy). Needs AI; don’t combine with other flags. |
94
+
95
+ Everything else (`--auto`, `--staged-only`, `--cwd`, model override, shell completion): **`git-explain --help`**.
96
+
97
+ ---
98
+
99
+ ## If Gemini complains
100
+
101
+ - **429 / quota** — wait a bit, or try the default model; see Google’s [rate limits](https://ai.google.dev/gemini-api/docs/rate-limits).
102
+ - **404 / model not found** — set something current, e.g. **`GEMINI_MODEL=gemini-2.5-flash`**, and check their [model list](https://ai.google.dev/api/models).
103
+
104
+ ---
105
+
106
+ ## Developers
107
+
108
+ ```bash
109
+ pip install -e ".[dev]"
110
+ pytest -q
111
+ ```
@@ -0,0 +1,80 @@
1
+ # git-explain
2
+
3
+ **Commit message block?** Run this in your repo after you change files. It suggests `git add` and `git commit` lines you can copy—or apply in one step if you want. **Nothing leaves your machine** unless you turn on AI.
4
+
5
+ [![PyPI](https://img.shields.io/pypi/v/git-explain.svg?label=pypi)](https://pypi.org/project/git-explain/)
6
+ [![GitHub tag](https://img.shields.io/github/v/tag/nazarli-shabnam/git-explain?label=repo)](https://github.com/nazarli-shabnam/git-explain/tags)
7
+
8
+
9
+ ---
10
+
11
+ ## Install (Python 3.10+)
12
+
13
+ ```bash
14
+ pip install git-explain
15
+ ```
16
+
17
+ **From source** (this repo):
18
+
19
+ ```bash
20
+ pip install -e .
21
+ ```
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
+
31
+ Optional: install a specific tag from GitHub instead of PyPI:
32
+
33
+ ```bash
34
+ pip install "git+https://github.com/nazarli-shabnam/git-explain.git@v2.1.8"
35
+ ```
36
+
37
+ ---
38
+
39
+ ## Try it
40
+
41
+ 1. In any git repo, change or add a file (not ignored).
42
+ 2. Run:
43
+ ```bash
44
+ git-explain
45
+ ```
46
+ 3. Choose what to include (`all` is fine), read the suggestion, answer **`n`** if you only want to copy commands yourself—nothing bad happens.
47
+
48
+ Heuristics guess a sensible type and message from paths and statuses. **No account, no key, no network** for that path.
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
+
52
+ ---
53
+
54
+ ## Optional: Gemini
55
+
56
+ If you want sharper messages, set **`GEMINI_API_KEY`** (or `GOOGLE_API_KEY`) in the environment or a **`.env`** file in the folder where you run the tool.
57
+
58
+ | Command | In plain terms |
59
+ |--------|----------------|
60
+ | `git-explain --ai` | AI sees **paths and change type** only (no file contents). |
61
+ | `git-explain --ai --with-diff` | AI also sees the **diff**—better detail; only use if you’re OK sending that to the API. |
62
+ | `git-explain --suggest` | **Staged only**; prints one plain `git commit -m "…"` line (easy to copy). Needs AI; don’t combine with other flags. |
63
+
64
+ Everything else (`--auto`, `--staged-only`, `--cwd`, model override, shell completion): **`git-explain --help`**.
65
+
66
+ ---
67
+
68
+ ## If Gemini complains
69
+
70
+ - **429 / quota** — wait a bit, or try the default model; see Google’s [rate limits](https://ai.google.dev/gemini-api/docs/rate-limits).
71
+ - **404 / model not found** — set something current, e.g. **`GEMINI_MODEL=gemini-2.5-flash`**, and check their [model list](https://ai.google.dev/api/models).
72
+
73
+ ---
74
+
75
+ ## Developers
76
+
77
+ ```bash
78
+ pip install -e ".[dev]"
79
+ pytest -q
80
+ ```
@@ -0,0 +1 @@
1
+ __version__ = "2.2.0"
@@ -0,0 +1,3 @@
1
+ from git_explain.cli import app
2
+
3
+ app()
@@ -1,7 +1,8 @@
1
1
  """CLI for git-explain: suggest and optionally apply commit message from diffs."""
2
2
 
3
+ import re
3
4
  import subprocess
4
- from dataclasses import dataclass
5
+ from dataclasses import dataclass, replace
5
6
  from pathlib import Path
6
7
  from typing import Iterable
7
8
 
@@ -11,14 +12,18 @@ from rich.console import Console
11
12
  from rich.panel import Panel
12
13
  from rich.text import Text
13
14
 
14
- from git_explain.gemini import suggest_commands
15
+ from git_explain.gemini import Suggestion, suggest_commands
15
16
  from git_explain.heuristics import suggest_from_changes
16
17
  from git_explain.git import (
17
18
  get_combined_diff,
18
19
  get_diff_for_paths,
19
20
  get_staged_diff_for_paths,
20
21
  )
21
- from git_explain.run import apply_commands
22
+ from git_explain.run import (
23
+ apply_commands,
24
+ format_commit_message,
25
+ normalize_commit_subject_for_dash_m,
26
+ )
22
27
 
23
28
  load_dotenv()
24
29
  app = typer.Typer()
@@ -55,9 +60,8 @@ def _parse_combined(combined: str) -> tuple[bool | None, list[Change]]:
55
60
  if v in ("true", "false"):
56
61
  has_commits = v == "true"
57
62
  continue
58
- m = __import__("re").match(
59
- r"^([AMDRC])\s+(.+)$", line, __import__("re").IGNORECASE
60
- )
63
+ # A/M/D/R/C: add/modify/delete/rename/copy; T: type change; U: unmerged (git name-status)
64
+ m = re.match(r"^([AMDRCUT])\s+(.+)$", line, re.IGNORECASE)
61
65
  if not m:
62
66
  continue
63
67
  status = m.group(1).upper()
@@ -343,13 +347,11 @@ def run(
343
347
  )
344
348
  raise typer.Exit(1)
345
349
 
346
- console.print(
347
- Panel(
348
- f'git commit -m "[{sug.commit_type}] {sug.commit_message}"',
349
- title="Suggested commit command",
350
- border_style="green",
351
- )
350
+ cmsg = normalize_commit_subject_for_dash_m(sug.commit_message)
351
+ full = format_commit_message(
352
+ sug.commit_type, cmsg, scope=sug.scope, breaking=sug.breaking
352
353
  )
354
+ print(f'git commit -m "{full}"')
353
355
  return
354
356
 
355
357
  if staged_only:
@@ -421,9 +423,8 @@ def run(
421
423
 
422
424
  def suggest_for(
423
425
  change_items: list[tuple[str, str]], title: str
424
- ) -> tuple[list[str], str, str, str, str | None]:
425
- # Returns (paths, type, message, raw_text, ai_fallback_reason).
426
- # 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)."""
427
428
  paths_for_infer = [p for _, p in change_items]
428
429
  infer_diff: str | None = None
429
430
  if paths_for_infer:
@@ -443,7 +444,7 @@ def run(
443
444
  if diff_text:
444
445
  payload = payload + "\n\n## Diff\n" + diff_text
445
446
  try:
446
- sug, raw = suggest_commands(
447
+ sug, _raw = suggest_commands(
447
448
  payload,
448
449
  model=model,
449
450
  with_diff=with_diff,
@@ -451,27 +452,20 @@ def run(
451
452
  )
452
453
  if sug is None:
453
454
  raise RuntimeError("Could not parse AI suggestion.")
454
- return sug.add_args, sug.commit_type, sug.commit_message, raw, None
455
+ return sug, None
455
456
  except Exception as e:
456
- # Fall back to heuristics on quota / API errors
457
457
  h = suggest_from_changes(
458
458
  changes=change_items,
459
459
  has_commits=has_commits,
460
460
  diff_text=infer_diff,
461
461
  )
462
- return (
463
- h.add_args,
464
- h.commit_type,
465
- h.commit_message,
466
- "",
467
- str(e),
468
- )
462
+ return h, str(e)
469
463
  h = suggest_from_changes(
470
464
  changes=change_items,
471
465
  has_commits=has_commits,
472
466
  diff_text=infer_diff,
473
467
  )
474
- return h.add_args, h.commit_type, h.commit_message, "", None
468
+ return h, None
475
469
 
476
470
  selected_pairs = [(ch.status, ch.path) for ch in selected]
477
471
  unique_paths = {p for _, p in selected_pairs}
@@ -491,18 +485,18 @@ def run(
491
485
  if mode_input in ("one", "split"):
492
486
  mode = mode_input
493
487
 
494
- plan: list[tuple[str, list[str], str, str]] = []
488
+ plan: list[tuple[str, Suggestion]] = []
495
489
  ai_fallback_notes: list[tuple[str, str]] = []
496
490
  if mode == "split":
497
491
  groups = _group_changes(selected_pairs)
498
492
  for gname, items in groups.items():
499
- paths, ctype, cmsg, _raw, fb = suggest_for(items, title=gname.capitalize())
500
- plan.append((gname, paths, ctype, cmsg))
493
+ sug, fb = suggest_for(items, title=gname.capitalize())
494
+ plan.append((gname, sug))
501
495
  if fb:
502
496
  ai_fallback_notes.append((gname, fb))
503
497
  else:
504
- paths, ctype, cmsg, _raw, fb = suggest_for(selected_pairs, title="Selected")
505
- plan.append(("one", paths, ctype, cmsg))
498
+ sug, fb = suggest_for(selected_pairs, title="Selected")
499
+ plan.append(("one", sug))
506
500
  if fb:
507
501
  ai_fallback_notes.append(("", fb))
508
502
 
@@ -529,11 +523,15 @@ def run(
529
523
  )
530
524
  )
531
525
 
532
- def _render_plan(pl: list[tuple[str, list[str], str, str]]) -> str:
526
+ def _render_plan(pl: list[tuple[str, Suggestion]]) -> str:
533
527
  rendered: list[str] = []
534
- for name, paths, ctype, cmsg in pl:
535
- add_line = "git add -A -- " + " ".join(_ps_quote(p) for p in paths)
536
- commit_line = f'git commit -m "[{ctype}] {cmsg}"'
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}"'
537
535
  rendered.append(f"### {name}\n{add_line}\n{commit_line}")
538
536
  return "\n\n".join(rendered)
539
537
 
@@ -555,29 +553,35 @@ def run(
555
553
  .lower()
556
554
  )
557
555
  if edit_choice in ("y", "yes"):
558
- updated: list[tuple[str, list[str], str, str]] = []
559
- for name, paths, ctype, cmsg in plan:
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
+ )
560
564
  console.print(
561
- f"[dim]{name}:[/dim] current message: [bold][{ctype}] {cmsg}[/bold]"
565
+ f"[dim]{name}:[/dim] current message: [bold]{current}[/bold]"
562
566
  )
563
567
  try:
564
568
  from prompt_toolkit import prompt as pt_prompt
565
569
 
566
570
  new_msg = (
567
571
  pt_prompt(
568
- "New commit message (subject only, no [TYPE] prefix): ",
569
- default=cmsg,
572
+ "New commit message (subject only, type/scope added automatically): ",
573
+ default=sug.commit_message,
570
574
  ).strip()
571
- or cmsg
575
+ or sug.commit_message
572
576
  )
573
577
  except Exception:
574
578
  new_msg = (
575
579
  typer.prompt(
576
- "New commit message (subject only, no [TYPE] prefix)",
577
- default=cmsg,
580
+ "New commit message (subject only, type/scope added automatically)",
581
+ default=sug.commit_message,
578
582
  ).strip()
579
- ) or cmsg
580
- updated.append((name, paths, ctype, new_msg))
583
+ ) or sug.commit_message
584
+ updated.append((name, replace(sug, commit_message=new_msg)))
581
585
  plan = updated
582
586
  console.print(
583
587
  Panel(
@@ -599,13 +603,16 @@ def run(
599
603
  do_apply = choice == "auto" or choice in ("y", "yes")
600
604
 
601
605
  if do_apply:
602
- for name, paths, ctype, cmsg in plan:
606
+ for name, sug in plan:
603
607
  try:
604
608
  apply_commands(
605
609
  repo_root,
606
- [] if staged_only else paths,
607
- ctype,
608
- cmsg,
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,
609
616
  staged_only=staged_only,
610
617
  )
611
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)