git-explain 2.4.0__tar.gz → 2.5.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.4.0 → git_explain-2.5.0}/PKG-INFO +3 -6
- {git_explain-2.4.0 → git_explain-2.5.0}/README.md +1 -4
- git_explain-2.5.0/git_explain/__init__.py +1 -0
- {git_explain-2.4.0 → git_explain-2.5.0}/git_explain/cli.py +14 -1
- {git_explain-2.4.0 → git_explain-2.5.0}/git_explain/gemini.py +9 -42
- {git_explain-2.4.0 → git_explain-2.5.0}/git_explain/heuristics.py +10 -43
- {git_explain-2.4.0 → git_explain-2.5.0}/git_explain/path_topics.py +40 -0
- {git_explain-2.4.0 → git_explain-2.5.0}/git_explain.egg-info/PKG-INFO +3 -6
- {git_explain-2.4.0 → git_explain-2.5.0}/git_explain.egg-info/SOURCES.txt +2 -0
- {git_explain-2.4.0 → git_explain-2.5.0}/git_explain.egg-info/requires.txt +1 -1
- {git_explain-2.4.0 → git_explain-2.5.0}/pyproject.toml +1 -1
- git_explain-2.5.0/tests/test_gemini_suggest_commands.py +127 -0
- git_explain-2.5.0/tests/test_git.py +209 -0
- git_explain-2.4.0/git_explain/__init__.py +0 -1
- {git_explain-2.4.0 → git_explain-2.5.0}/LICENSE +0 -0
- {git_explain-2.4.0 → git_explain-2.5.0}/git_explain/__main__.py +0 -0
- {git_explain-2.4.0 → git_explain-2.5.0}/git_explain/commit_infer.py +0 -0
- {git_explain-2.4.0 → git_explain-2.5.0}/git_explain/git.py +0 -0
- {git_explain-2.4.0 → git_explain-2.5.0}/git_explain/run.py +0 -0
- {git_explain-2.4.0 → git_explain-2.5.0}/git_explain.egg-info/dependency_links.txt +0 -0
- {git_explain-2.4.0 → git_explain-2.5.0}/git_explain.egg-info/entry_points.txt +0 -0
- {git_explain-2.4.0 → git_explain-2.5.0}/git_explain.egg-info/top_level.txt +0 -0
- {git_explain-2.4.0 → git_explain-2.5.0}/setup.cfg +0 -0
- {git_explain-2.4.0 → git_explain-2.5.0}/tests/test_cli_utils.py +0 -0
- {git_explain-2.4.0 → git_explain-2.5.0}/tests/test_commit_infer.py +0 -0
- {git_explain-2.4.0 → git_explain-2.5.0}/tests/test_gemini.py +0 -0
- {git_explain-2.4.0 → git_explain-2.5.0}/tests/test_heuristics.py +0 -0
- {git_explain-2.4.0 → git_explain-2.5.0}/tests/test_run_apply.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: git-explain
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.5.0
|
|
4
4
|
Summary: CLI that suggests git add/commit from diffs using Gemini
|
|
5
5
|
Author: nazarli-shabnam
|
|
6
6
|
Author-email: shabnamnezerli@gmail.com
|
|
@@ -28,7 +28,7 @@ Requires-Dist: python-dotenv>=1.0.0
|
|
|
28
28
|
Requires-Dist: prompt_toolkit>=3.0.0
|
|
29
29
|
Provides-Extra: dev
|
|
30
30
|
Requires-Dist: pytest>=8.0.0; extra == "dev"
|
|
31
|
-
Requires-Dist: ruff
|
|
31
|
+
Requires-Dist: ruff==0.12.3; extra == "dev"
|
|
32
32
|
Dynamic: license-file
|
|
33
33
|
|
|
34
34
|
# git-explain
|
|
@@ -37,7 +37,6 @@ Suggests **conventional** `git add` / `git commit` messages from your changes. U
|
|
|
37
37
|
|
|
38
38
|
[](https://pypi.org/project/git-explain/)
|
|
39
39
|
[](https://github.com/nazarli-shabnam/git-explain/tags)
|
|
40
|
-
<!-- GitAds-Verify: 29ITVVWNRUVU524NJ5ZRR6DSZKIHP3EX -->
|
|
41
40
|
|
|
42
41
|
---
|
|
43
42
|
|
|
@@ -83,6 +82,7 @@ If `AI_API_KEY` is empty, **`GEMINI_API_KEY`** is still read (same key, older na
|
|
|
83
82
|
| `--auto` | Apply suggested commands without a confirmation prompt. |
|
|
84
83
|
| `--staged-only` | Work with staged changes only (no `git add` from the tool). |
|
|
85
84
|
| `--cwd` | Use another directory as the git repo root. |
|
|
85
|
+
| `--model` | Override the AI model for this run (defaults to `AI_MODEL` from the repo `.env`). |
|
|
86
86
|
| `--with-diff` | Send the full diff to the AI (more context). |
|
|
87
87
|
| `--suggest` | Print one suggested `git commit -m "…"` line (staged, AI only). |
|
|
88
88
|
|
|
@@ -120,6 +120,3 @@ python -m git_explain
|
|
|
120
120
|
|
|
121
121
|
Contributors: `pip install -e ".[dev]"` then `pytest -q`, `ruff check .`, `ruff format --check .`.
|
|
122
122
|
|
|
123
|
-
## GitAds Sponsored
|
|
124
|
-
[](https://gitads.dev/v1/ad-track?source=nazarli-shabnam/git-explain@github)
|
|
125
|
-
|
|
@@ -4,7 +4,6 @@ Suggests **conventional** `git add` / `git commit` messages from your changes. U
|
|
|
4
4
|
|
|
5
5
|
[](https://pypi.org/project/git-explain/)
|
|
6
6
|
[](https://github.com/nazarli-shabnam/git-explain/tags)
|
|
7
|
-
<!-- GitAds-Verify: 29ITVVWNRUVU524NJ5ZRR6DSZKIHP3EX -->
|
|
8
7
|
|
|
9
8
|
---
|
|
10
9
|
|
|
@@ -50,6 +49,7 @@ If `AI_API_KEY` is empty, **`GEMINI_API_KEY`** is still read (same key, older na
|
|
|
50
49
|
| `--auto` | Apply suggested commands without a confirmation prompt. |
|
|
51
50
|
| `--staged-only` | Work with staged changes only (no `git add` from the tool). |
|
|
52
51
|
| `--cwd` | Use another directory as the git repo root. |
|
|
52
|
+
| `--model` | Override the AI model for this run (defaults to `AI_MODEL` from the repo `.env`). |
|
|
53
53
|
| `--with-diff` | Send the full diff to the AI (more context). |
|
|
54
54
|
| `--suggest` | Print one suggested `git commit -m "…"` line (staged, AI only). |
|
|
55
55
|
|
|
@@ -87,6 +87,3 @@ python -m git_explain
|
|
|
87
87
|
|
|
88
88
|
Contributors: `pip install -e ".[dev]"` then `pytest -q`, `ruff check .`, `ruff format --check .`.
|
|
89
89
|
|
|
90
|
-
## GitAds Sponsored
|
|
91
|
-
[](https://gitads.dev/v1/ad-track?source=nazarli-shabnam/git-explain@github)
|
|
92
|
-
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "2.5.0"
|
|
@@ -8,8 +8,11 @@ from pathlib import Path
|
|
|
8
8
|
from collections.abc import Callable
|
|
9
9
|
from typing import Iterable
|
|
10
10
|
|
|
11
|
+
import httpx
|
|
12
|
+
import requests
|
|
11
13
|
import typer
|
|
12
14
|
from dotenv import dotenv_values
|
|
15
|
+
from google.genai import errors as genai_errors
|
|
13
16
|
from rich.console import Console
|
|
14
17
|
from rich.panel import Panel
|
|
15
18
|
from rich.text import Text
|
|
@@ -40,6 +43,16 @@ _AI_ENV_KEYS = (
|
|
|
40
43
|
# Terminal hyperlinks (OSC 8) for first-run setup — Ctrl+click in supported terminals.
|
|
41
44
|
_GOOGLE_AI_API_KEY_URL = "https://aistudio.google.com/apikey"
|
|
42
45
|
|
|
46
|
+
# Expected failure modes from an AI call: missing key / unparseable response
|
|
47
|
+
# (RuntimeError), Gemini API errors, and network-level failures. Anything else
|
|
48
|
+
# is a real bug and should not be silently treated as "AI unavailable".
|
|
49
|
+
_AI_CALL_ERRORS = (
|
|
50
|
+
RuntimeError,
|
|
51
|
+
genai_errors.APIError,
|
|
52
|
+
httpx.HTTPError,
|
|
53
|
+
requests.exceptions.RequestException,
|
|
54
|
+
)
|
|
55
|
+
|
|
43
56
|
|
|
44
57
|
def _gemini_fallback_notifier(
|
|
45
58
|
group_label: str | None = None,
|
|
@@ -578,7 +591,7 @@ def run(
|
|
|
578
591
|
if sug is None:
|
|
579
592
|
raise RuntimeError("Could not parse AI suggestion.")
|
|
580
593
|
return sug, None
|
|
581
|
-
except
|
|
594
|
+
except _AI_CALL_ERRORS as e:
|
|
582
595
|
h = suggest_from_changes(
|
|
583
596
|
changes=change_items,
|
|
584
597
|
has_commits=has_commits,
|
|
@@ -11,8 +11,10 @@ from google.genai import types
|
|
|
11
11
|
|
|
12
12
|
from git_explain.commit_infer import refine_type_and_message_from_diff
|
|
13
13
|
from git_explain.path_topics import (
|
|
14
|
+
alnum_key,
|
|
14
15
|
area_scope_suffix,
|
|
15
16
|
basename_fallback_topic,
|
|
17
|
+
code_topics,
|
|
16
18
|
infer_scope,
|
|
17
19
|
infra_deploy_topics,
|
|
18
20
|
is_test_path,
|
|
@@ -184,7 +186,6 @@ _GENERIC_MESSAGES = {
|
|
|
184
186
|
"misc",
|
|
185
187
|
}
|
|
186
188
|
|
|
187
|
-
CODE_EXTS = {".py", ".js", ".ts", ".tsx", ".go", ".rs", ".java", ".rb", ".php", ".cs"}
|
|
188
189
|
_WEAK_TOPIC_WORDS = {
|
|
189
190
|
"project",
|
|
190
191
|
"projects",
|
|
@@ -210,40 +211,6 @@ _WEAK_TOPIC_WORDS = {
|
|
|
210
211
|
}
|
|
211
212
|
|
|
212
213
|
|
|
213
|
-
def _code_topics(files: list[str]) -> list[str]:
|
|
214
|
-
labeled: list[tuple[str, str]] = [] # (folder_label, stem)
|
|
215
|
-
for p in files:
|
|
216
|
-
p2 = p.replace("\\", "/")
|
|
217
|
-
base = os.path.basename(p2)
|
|
218
|
-
ext = os.path.splitext(base)[1].lower()
|
|
219
|
-
if ext not in CODE_EXTS:
|
|
220
|
-
continue
|
|
221
|
-
stem = os.path.splitext(base)[0].replace("_", " ")
|
|
222
|
-
parts = [x for x in p2.split("/") if x]
|
|
223
|
-
folder = parts[-2] if len(parts) >= 2 else stem
|
|
224
|
-
labeled.append((folder.replace("_", " "), stem))
|
|
225
|
-
|
|
226
|
-
if not labeled:
|
|
227
|
-
return []
|
|
228
|
-
|
|
229
|
-
folder_set = {f.lower() for f, _ in labeled}
|
|
230
|
-
prefer_stems = len(folder_set) == 1 and len(labeled) >= 2
|
|
231
|
-
|
|
232
|
-
topics: list[str] = []
|
|
233
|
-
seen: set[str] = set()
|
|
234
|
-
for folder, stem in labeled:
|
|
235
|
-
label = stem if prefer_stems else folder
|
|
236
|
-
key = label.lower()
|
|
237
|
-
if key not in seen:
|
|
238
|
-
seen.add(key)
|
|
239
|
-
topics.append(label)
|
|
240
|
-
return topics
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
def _alnum_key(s: str) -> str:
|
|
244
|
-
return re.sub(r"[^a-z0-9]+", "", (s or "").lower())
|
|
245
|
-
|
|
246
|
-
|
|
247
214
|
def _is_generic_message(message: str) -> bool:
|
|
248
215
|
msg = (message or "").strip().lower()
|
|
249
216
|
if not msg:
|
|
@@ -291,8 +258,8 @@ def _is_generic_message(message: str) -> bool:
|
|
|
291
258
|
return True
|
|
292
259
|
m_for = re.match(r"^(add|update|modify|make)\s+(.+?)\s+for\s+(.+)$", msg)
|
|
293
260
|
if m_for:
|
|
294
|
-
left =
|
|
295
|
-
right =
|
|
261
|
+
left = alnum_key(m_for.group(2))
|
|
262
|
+
right = alnum_key(m_for.group(3))
|
|
296
263
|
if left and right and (left == right or left in right or right in left):
|
|
297
264
|
return True
|
|
298
265
|
# "update X" is okay, but bare "update" or "update stuff" isn't
|
|
@@ -379,9 +346,9 @@ def _fallback_type_and_message_with_context(
|
|
|
379
346
|
topics.append("tests")
|
|
380
347
|
if touches_docs and not docs_only:
|
|
381
348
|
topics.append("docs")
|
|
382
|
-
|
|
383
|
-
if
|
|
384
|
-
topics.append(", ".join(
|
|
349
|
+
file_code_topics = code_topics(files)
|
|
350
|
+
if file_code_topics:
|
|
351
|
+
topics.append(", ".join(file_code_topics[:5]))
|
|
385
352
|
if touches_packaging:
|
|
386
353
|
topics.append("packaging config")
|
|
387
354
|
|
|
@@ -402,8 +369,8 @@ def _fallback_type_and_message_with_context(
|
|
|
402
369
|
|
|
403
370
|
scope = area_scope_suffix(files)
|
|
404
371
|
if scope:
|
|
405
|
-
scope_key =
|
|
406
|
-
msg_key =
|
|
372
|
+
scope_key = alnum_key(scope.replace("for", "", 1))
|
|
373
|
+
msg_key = alnum_key(msg)
|
|
407
374
|
if scope_key and scope_key not in msg_key:
|
|
408
375
|
msg += scope
|
|
409
376
|
|
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import os
|
|
6
|
-
import re
|
|
7
6
|
|
|
8
7
|
from git_explain.commit_infer import refine_type_and_message_from_diff
|
|
9
8
|
from git_explain.gemini import (
|
|
@@ -12,8 +11,11 @@ from git_explain.gemini import (
|
|
|
12
11
|
truncate_commit_subject,
|
|
13
12
|
)
|
|
14
13
|
from git_explain.path_topics import (
|
|
14
|
+
CODE_EXTS,
|
|
15
|
+
alnum_key,
|
|
15
16
|
area_scope_suffix,
|
|
16
17
|
basename_fallback_topic,
|
|
18
|
+
code_topics,
|
|
17
19
|
infer_scope,
|
|
18
20
|
infra_deploy_topics,
|
|
19
21
|
is_build_path,
|
|
@@ -36,7 +38,6 @@ CONFIG_FILES = {
|
|
|
36
38
|
"license.md",
|
|
37
39
|
}
|
|
38
40
|
CONFIG_EXTS = {".toml", ".yml", ".yaml", ".json", ".ini", ".cfg", ".lock"}
|
|
39
|
-
CODE_EXTS = {".py", ".js", ".ts", ".tsx", ".go", ".rs", ".java", ".rb", ".php", ".cs"}
|
|
40
41
|
|
|
41
42
|
|
|
42
43
|
def _is_doc(path: str) -> bool:
|
|
@@ -60,40 +61,6 @@ def _is_config(path: str) -> bool:
|
|
|
60
61
|
return _is_plain_config(path) or is_infra_deploy_path(path)
|
|
61
62
|
|
|
62
63
|
|
|
63
|
-
def _code_topics(paths: list[str]) -> list[str]:
|
|
64
|
-
labeled: list[tuple[str, str]] = [] # (folder_label, stem)
|
|
65
|
-
for p in paths:
|
|
66
|
-
p2 = p.replace("\\", "/")
|
|
67
|
-
base = os.path.basename(p2)
|
|
68
|
-
ext = os.path.splitext(base)[1].lower()
|
|
69
|
-
if ext not in CODE_EXTS:
|
|
70
|
-
continue
|
|
71
|
-
stem = os.path.splitext(base)[0].replace("_", " ")
|
|
72
|
-
parts = [x for x in p2.split("/") if x]
|
|
73
|
-
folder = parts[-2] if len(parts) >= 2 else stem
|
|
74
|
-
labeled.append((folder.replace("_", " "), stem))
|
|
75
|
-
|
|
76
|
-
if not labeled:
|
|
77
|
-
return []
|
|
78
|
-
|
|
79
|
-
folder_set = {f.lower() for f, _ in labeled}
|
|
80
|
-
prefer_stems = len(folder_set) == 1 and len(labeled) >= 2
|
|
81
|
-
|
|
82
|
-
topics: list[str] = []
|
|
83
|
-
seen: set[str] = set()
|
|
84
|
-
for folder, stem in labeled:
|
|
85
|
-
label = stem if prefer_stems else folder
|
|
86
|
-
key = label.lower()
|
|
87
|
-
if key not in seen:
|
|
88
|
-
seen.add(key)
|
|
89
|
-
topics.append(label)
|
|
90
|
-
return topics
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
def _alnum_key(s: str) -> str:
|
|
94
|
-
return re.sub(r"[^a-z0-9]+", "", (s or "").lower())
|
|
95
|
-
|
|
96
|
-
|
|
97
64
|
def _path_first_segment(path: str) -> str:
|
|
98
65
|
parts = [x for x in path.replace("\\", "/").strip("/").split("/") if x]
|
|
99
66
|
return parts[0].lower() if parts else "root"
|
|
@@ -175,9 +142,9 @@ def suggest_from_changes(
|
|
|
175
142
|
topics.append("tests")
|
|
176
143
|
if any(_is_plain_config(p) for p in paths):
|
|
177
144
|
topics.append("config")
|
|
178
|
-
|
|
179
|
-
if
|
|
180
|
-
if len(
|
|
145
|
+
file_code_topics = code_topics(paths)
|
|
146
|
+
if file_code_topics:
|
|
147
|
+
if len(file_code_topics) > 4:
|
|
181
148
|
roots = {_path_first_segment(p) for p in paths}
|
|
182
149
|
if len(roots) == 1:
|
|
183
150
|
root = next(iter(roots))
|
|
@@ -189,9 +156,9 @@ def suggest_from_changes(
|
|
|
189
156
|
a, b = sorted(roots)
|
|
190
157
|
topics.append(f"{len(paths)} files across {a} and {b}")
|
|
191
158
|
else:
|
|
192
|
-
topics.append(", ".join(
|
|
159
|
+
topics.append(", ".join(file_code_topics[:4]))
|
|
193
160
|
else:
|
|
194
|
-
topics.append(", ".join(
|
|
161
|
+
topics.append(", ".join(file_code_topics[:5]))
|
|
195
162
|
|
|
196
163
|
# Dedupe while preserving order
|
|
197
164
|
seen: set[str] = set()
|
|
@@ -210,8 +177,8 @@ def suggest_from_changes(
|
|
|
210
177
|
|
|
211
178
|
scope_suffix = area_scope_suffix(paths)
|
|
212
179
|
if scope_suffix:
|
|
213
|
-
scope_key =
|
|
214
|
-
msg_key =
|
|
180
|
+
scope_key = alnum_key(scope_suffix.replace("for", "", 1))
|
|
181
|
+
msg_key = alnum_key(message)
|
|
215
182
|
if scope_key and scope_key not in msg_key:
|
|
216
183
|
message += scope_suffix
|
|
217
184
|
|
|
@@ -3,12 +3,52 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import os
|
|
6
|
+
import re
|
|
6
7
|
|
|
7
8
|
|
|
8
9
|
def _norm(p: str) -> str:
|
|
9
10
|
return p.replace("\\", "/").strip()
|
|
10
11
|
|
|
11
12
|
|
|
13
|
+
CODE_EXTS = {".py", ".js", ".ts", ".tsx", ".go", ".rs", ".java", ".rb", ".php", ".cs"}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def alnum_key(s: str) -> str:
|
|
17
|
+
"""Lowercase alnum-only key, for loose substring comparisons of free text."""
|
|
18
|
+
return re.sub(r"[^a-z0-9]+", "", (s or "").lower())
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def code_topics(paths: list[str]) -> list[str]:
|
|
22
|
+
"""Ordered, deduplicated topic labels for code files: filename stems or parent folders."""
|
|
23
|
+
labeled: list[tuple[str, str]] = [] # (folder_label, stem)
|
|
24
|
+
for p in paths:
|
|
25
|
+
p2 = p.replace("\\", "/")
|
|
26
|
+
base = os.path.basename(p2)
|
|
27
|
+
ext = os.path.splitext(base)[1].lower()
|
|
28
|
+
if ext not in CODE_EXTS:
|
|
29
|
+
continue
|
|
30
|
+
stem = os.path.splitext(base)[0].replace("_", " ")
|
|
31
|
+
parts = [x for x in p2.split("/") if x]
|
|
32
|
+
folder = parts[-2] if len(parts) >= 2 else stem
|
|
33
|
+
labeled.append((folder.replace("_", " "), stem))
|
|
34
|
+
|
|
35
|
+
if not labeled:
|
|
36
|
+
return []
|
|
37
|
+
|
|
38
|
+
folder_set = {f.lower() for f, _ in labeled}
|
|
39
|
+
prefer_stems = len(folder_set) == 1 and len(labeled) >= 2
|
|
40
|
+
|
|
41
|
+
topics: list[str] = []
|
|
42
|
+
seen: set[str] = set()
|
|
43
|
+
for folder, stem in labeled:
|
|
44
|
+
label = stem if prefer_stems else folder
|
|
45
|
+
key = label.lower()
|
|
46
|
+
if key not in seen:
|
|
47
|
+
seen.add(key)
|
|
48
|
+
topics.append(label)
|
|
49
|
+
return topics
|
|
50
|
+
|
|
51
|
+
|
|
12
52
|
_TEST_HINTS = ("pytest", "unittest", "tests/", "/tests/")
|
|
13
53
|
|
|
14
54
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: git-explain
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.5.0
|
|
4
4
|
Summary: CLI that suggests git add/commit from diffs using Gemini
|
|
5
5
|
Author: nazarli-shabnam
|
|
6
6
|
Author-email: shabnamnezerli@gmail.com
|
|
@@ -28,7 +28,7 @@ Requires-Dist: python-dotenv>=1.0.0
|
|
|
28
28
|
Requires-Dist: prompt_toolkit>=3.0.0
|
|
29
29
|
Provides-Extra: dev
|
|
30
30
|
Requires-Dist: pytest>=8.0.0; extra == "dev"
|
|
31
|
-
Requires-Dist: ruff
|
|
31
|
+
Requires-Dist: ruff==0.12.3; extra == "dev"
|
|
32
32
|
Dynamic: license-file
|
|
33
33
|
|
|
34
34
|
# git-explain
|
|
@@ -37,7 +37,6 @@ Suggests **conventional** `git add` / `git commit` messages from your changes. U
|
|
|
37
37
|
|
|
38
38
|
[](https://pypi.org/project/git-explain/)
|
|
39
39
|
[](https://github.com/nazarli-shabnam/git-explain/tags)
|
|
40
|
-
<!-- GitAds-Verify: 29ITVVWNRUVU524NJ5ZRR6DSZKIHP3EX -->
|
|
41
40
|
|
|
42
41
|
---
|
|
43
42
|
|
|
@@ -83,6 +82,7 @@ If `AI_API_KEY` is empty, **`GEMINI_API_KEY`** is still read (same key, older na
|
|
|
83
82
|
| `--auto` | Apply suggested commands without a confirmation prompt. |
|
|
84
83
|
| `--staged-only` | Work with staged changes only (no `git add` from the tool). |
|
|
85
84
|
| `--cwd` | Use another directory as the git repo root. |
|
|
85
|
+
| `--model` | Override the AI model for this run (defaults to `AI_MODEL` from the repo `.env`). |
|
|
86
86
|
| `--with-diff` | Send the full diff to the AI (more context). |
|
|
87
87
|
| `--suggest` | Print one suggested `git commit -m "…"` line (staged, AI only). |
|
|
88
88
|
|
|
@@ -120,6 +120,3 @@ python -m git_explain
|
|
|
120
120
|
|
|
121
121
|
Contributors: `pip install -e ".[dev]"` then `pytest -q`, `ruff check .`, `ruff format --check .`.
|
|
122
122
|
|
|
123
|
-
## GitAds Sponsored
|
|
124
|
-
[](https://gitads.dev/v1/ad-track?source=nazarli-shabnam/git-explain@github)
|
|
125
|
-
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from google.genai import errors as genai_errors
|
|
3
|
+
|
|
4
|
+
from git_explain import gemini as gemini_module
|
|
5
|
+
from git_explain.gemini import suggest_commands
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class _FakeModels:
|
|
9
|
+
def __init__(self, fn):
|
|
10
|
+
self._fn = fn
|
|
11
|
+
|
|
12
|
+
def generate_content(self, *, model, contents, config):
|
|
13
|
+
return self._fn(model=model, contents=contents, config=config)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class _FakeClient:
|
|
17
|
+
def __init__(self, fn):
|
|
18
|
+
self.models = _FakeModels(fn)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class _FakeResponse:
|
|
22
|
+
def __init__(self, text: str) -> None:
|
|
23
|
+
self.text = text
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _patch_client(monkeypatch, fn) -> None:
|
|
27
|
+
monkeypatch.setattr(gemini_module.genai, "Client", lambda api_key: _FakeClient(fn))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
_DIFF = "## Meta\nhas_commits: true\n\n## Staged\nM a.txt"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_suggest_commands_successful_parse(monkeypatch) -> None:
|
|
34
|
+
monkeypatch.setenv("AI_API_KEY", "test-key")
|
|
35
|
+
monkeypatch.delenv("AI_MODEL_FALLBACKS", raising=False)
|
|
36
|
+
|
|
37
|
+
def fake(*, model, contents, config):
|
|
38
|
+
return _FakeResponse('git add a.txt\ngit commit -m "fix: correct the bug"')
|
|
39
|
+
|
|
40
|
+
_patch_client(monkeypatch, fake)
|
|
41
|
+
|
|
42
|
+
sug, raw = suggest_commands(_DIFF, model="gemini-2.5-flash")
|
|
43
|
+
|
|
44
|
+
assert sug is not None
|
|
45
|
+
assert sug.commit_type == "FIX"
|
|
46
|
+
assert sug.commit_message == "correct the bug"
|
|
47
|
+
assert sug.add_args == ["a.txt"]
|
|
48
|
+
assert "correct the bug" in raw
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_suggest_commands_retries_fallback_on_rate_limit(monkeypatch) -> None:
|
|
52
|
+
monkeypatch.setenv("AI_API_KEY", "test-key")
|
|
53
|
+
monkeypatch.setenv("AI_MODEL_FALLBACKS", "gemini-2.5-flash-lite")
|
|
54
|
+
|
|
55
|
+
calls: list[str] = []
|
|
56
|
+
|
|
57
|
+
def fake(*, model, contents, config):
|
|
58
|
+
calls.append(model)
|
|
59
|
+
if model == "gemini-2.5-flash":
|
|
60
|
+
raise genai_errors.ClientError(429, {"error": {"message": "rate"}}, None)
|
|
61
|
+
return _FakeResponse('git add a.txt\ngit commit -m "fix: correct the bug"')
|
|
62
|
+
|
|
63
|
+
_patch_client(monkeypatch, fake)
|
|
64
|
+
|
|
65
|
+
notified: list[str] = []
|
|
66
|
+
sug, _raw = suggest_commands(
|
|
67
|
+
_DIFF,
|
|
68
|
+
model="gemini-2.5-flash",
|
|
69
|
+
fallback_notifier=notified.append,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
assert calls == ["gemini-2.5-flash", "gemini-2.5-flash-lite"]
|
|
73
|
+
assert notified == ["gemini-2.5-flash-lite"]
|
|
74
|
+
assert sug is not None
|
|
75
|
+
assert sug.commit_type == "FIX"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_suggest_commands_non_retryable_error_propagates_without_fallback(
|
|
79
|
+
monkeypatch,
|
|
80
|
+
) -> None:
|
|
81
|
+
monkeypatch.setenv("AI_API_KEY", "test-key")
|
|
82
|
+
monkeypatch.setenv("AI_MODEL_FALLBACKS", "gemini-2.5-flash-lite")
|
|
83
|
+
|
|
84
|
+
calls: list[str] = []
|
|
85
|
+
|
|
86
|
+
def fake(*, model, contents, config):
|
|
87
|
+
calls.append(model)
|
|
88
|
+
raise genai_errors.ClientError(400, {"error": {"message": "bad request"}}, None)
|
|
89
|
+
|
|
90
|
+
_patch_client(monkeypatch, fake)
|
|
91
|
+
|
|
92
|
+
with pytest.raises(genai_errors.ClientError):
|
|
93
|
+
suggest_commands(_DIFF, model="gemini-2.5-flash")
|
|
94
|
+
|
|
95
|
+
assert calls == ["gemini-2.5-flash"]
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_suggest_commands_generic_ai_message_falls_back_to_heuristic_message(
|
|
99
|
+
monkeypatch,
|
|
100
|
+
) -> None:
|
|
101
|
+
monkeypatch.setenv("AI_API_KEY", "test-key")
|
|
102
|
+
monkeypatch.delenv("AI_MODEL_FALLBACKS", raising=False)
|
|
103
|
+
|
|
104
|
+
def fake(*, model, contents, config):
|
|
105
|
+
return _FakeResponse('git add a.txt\ngit commit -m "chore: update"')
|
|
106
|
+
|
|
107
|
+
_patch_client(monkeypatch, fake)
|
|
108
|
+
|
|
109
|
+
sug, _raw = suggest_commands(_DIFF, model="gemini-2.5-flash")
|
|
110
|
+
|
|
111
|
+
assert sug is not None
|
|
112
|
+
assert sug.commit_message != "update"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def test_suggest_commands_missing_api_key_raises(monkeypatch) -> None:
|
|
116
|
+
monkeypatch.delenv("AI_API_KEY", raising=False)
|
|
117
|
+
monkeypatch.delenv("GEMINI_API_KEY", raising=False)
|
|
118
|
+
|
|
119
|
+
with pytest.raises(RuntimeError):
|
|
120
|
+
suggest_commands(_DIFF, model="gemini-2.5-flash")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_suggest_commands_empty_diff_returns_none(monkeypatch) -> None:
|
|
124
|
+
monkeypatch.setenv("AI_API_KEY", "test-key")
|
|
125
|
+
sug, raw = suggest_commands(" ", model="gemini-2.5-flash")
|
|
126
|
+
assert sug is None
|
|
127
|
+
assert raw == ""
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from git_explain.git import (
|
|
6
|
+
ensure_git_repo,
|
|
7
|
+
get_combined_diff,
|
|
8
|
+
get_diff_for_paths,
|
|
9
|
+
get_repo_root,
|
|
10
|
+
get_staged_changes,
|
|
11
|
+
get_staged_diff_for_paths,
|
|
12
|
+
get_untracked_changes,
|
|
13
|
+
get_unstaged_changes,
|
|
14
|
+
repo_has_commits,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _git(cwd, *args: str) -> subprocess.CompletedProcess[str]:
|
|
19
|
+
return subprocess.run(
|
|
20
|
+
["git", *args],
|
|
21
|
+
cwd=cwd,
|
|
22
|
+
check=True,
|
|
23
|
+
capture_output=True,
|
|
24
|
+
text=True,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _init_repo(repo) -> None:
|
|
29
|
+
repo.mkdir()
|
|
30
|
+
_git(repo, "init")
|
|
31
|
+
_git(repo, "config", "user.email", "test@example.com")
|
|
32
|
+
_git(repo, "config", "user.name", "Test User")
|
|
33
|
+
# Emit UTF-8 paths as-is instead of octal-escaped/quoted (git's default
|
|
34
|
+
# for non-ASCII paths), matching the UTF-8 assumption in git_explain.git.
|
|
35
|
+
_git(repo, "config", "core.quotepath", "false")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_get_repo_root_raises_outside_a_repo(tmp_path) -> None:
|
|
39
|
+
outside = tmp_path / "not_a_repo"
|
|
40
|
+
outside.mkdir()
|
|
41
|
+
with pytest.raises(RuntimeError):
|
|
42
|
+
get_repo_root(outside)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_ensure_git_repo_raises_outside_a_repo(tmp_path) -> None:
|
|
46
|
+
outside = tmp_path / "not_a_repo"
|
|
47
|
+
outside.mkdir()
|
|
48
|
+
with pytest.raises(RuntimeError):
|
|
49
|
+
ensure_git_repo(outside)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_get_repo_root_and_ensure_git_repo_return_root(tmp_path) -> None:
|
|
53
|
+
repo = tmp_path / "repo"
|
|
54
|
+
_init_repo(repo)
|
|
55
|
+
assert get_repo_root(repo).resolve() == repo.resolve()
|
|
56
|
+
assert ensure_git_repo(repo).resolve() == repo.resolve()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_repo_has_commits_before_and_after_first_commit(tmp_path) -> None:
|
|
60
|
+
repo = tmp_path / "repo"
|
|
61
|
+
_init_repo(repo)
|
|
62
|
+
assert repo_has_commits(repo) is False
|
|
63
|
+
|
|
64
|
+
(repo / "a.txt").write_text("hello\n", encoding="utf-8")
|
|
65
|
+
_git(repo, "add", "a.txt")
|
|
66
|
+
_git(repo, "commit", "-m", "init")
|
|
67
|
+
assert repo_has_commits(repo) is True
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_get_staged_changes(tmp_path) -> None:
|
|
71
|
+
repo = tmp_path / "repo"
|
|
72
|
+
_init_repo(repo)
|
|
73
|
+
(repo / "a.txt").write_text("hello\n", encoding="utf-8")
|
|
74
|
+
_git(repo, "add", "a.txt")
|
|
75
|
+
assert get_staged_changes(repo) == [("A", "a.txt")]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_get_unstaged_changes(tmp_path) -> None:
|
|
79
|
+
repo = tmp_path / "repo"
|
|
80
|
+
_init_repo(repo)
|
|
81
|
+
(repo / "a.txt").write_text("hello\n", encoding="utf-8")
|
|
82
|
+
_git(repo, "add", "a.txt")
|
|
83
|
+
_git(repo, "commit", "-m", "init")
|
|
84
|
+
|
|
85
|
+
(repo / "a.txt").write_text("hello world\n", encoding="utf-8")
|
|
86
|
+
assert get_unstaged_changes(repo) == [("M", "a.txt")]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_get_untracked_changes(tmp_path) -> None:
|
|
90
|
+
repo = tmp_path / "repo"
|
|
91
|
+
_init_repo(repo)
|
|
92
|
+
(repo / "new.txt").write_text("new\n", encoding="utf-8")
|
|
93
|
+
assert get_untracked_changes(repo) == [("A", "new.txt")]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_rename_is_normalized_to_single_r_status(tmp_path) -> None:
|
|
97
|
+
repo = tmp_path / "repo"
|
|
98
|
+
_init_repo(repo)
|
|
99
|
+
(repo / "old.txt").write_text("same content\n", encoding="utf-8")
|
|
100
|
+
_git(repo, "add", "old.txt")
|
|
101
|
+
_git(repo, "commit", "-m", "init")
|
|
102
|
+
|
|
103
|
+
_git(repo, "mv", "old.txt", "new.txt")
|
|
104
|
+
staged = get_staged_changes(repo)
|
|
105
|
+
assert staged == [("R", "new.txt")]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_copy_without_detection_flags_shows_as_two_adds(tmp_path) -> None:
|
|
109
|
+
# get_staged_changes() calls `git diff --name-status` without -C, so git
|
|
110
|
+
# never detects copies (unlike renames, copy detection needs an explicit
|
|
111
|
+
# flag) — both the original and the copy show up as separate adds.
|
|
112
|
+
repo = tmp_path / "repo"
|
|
113
|
+
_init_repo(repo)
|
|
114
|
+
(repo / "old.txt").write_text("x\n" * 20, encoding="utf-8")
|
|
115
|
+
_git(repo, "add", "old.txt")
|
|
116
|
+
_git(repo, "commit", "-m", "init")
|
|
117
|
+
|
|
118
|
+
(repo / "copy.txt").write_text((repo / "old.txt").read_text(encoding="utf-8"))
|
|
119
|
+
_git(repo, "add", "copy.txt")
|
|
120
|
+
assert get_staged_changes(repo) == [("A", "copy.txt")]
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_non_ascii_path_round_trips(tmp_path) -> None:
|
|
124
|
+
repo = tmp_path / "repo"
|
|
125
|
+
_init_repo(repo)
|
|
126
|
+
fname = "café.txt"
|
|
127
|
+
(repo / fname).write_text("hello\n", encoding="utf-8")
|
|
128
|
+
assert get_untracked_changes(repo) == [("A", fname)]
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def test_get_combined_diff_sections(tmp_path) -> None:
|
|
132
|
+
repo = tmp_path / "repo"
|
|
133
|
+
_init_repo(repo)
|
|
134
|
+
(repo / "tracked.txt").write_text("hello\n", encoding="utf-8")
|
|
135
|
+
_git(repo, "add", "tracked.txt")
|
|
136
|
+
_git(repo, "commit", "-m", "init")
|
|
137
|
+
|
|
138
|
+
(repo / "tracked.txt").write_text("hello world\n", encoding="utf-8")
|
|
139
|
+
(repo / "staged.txt").write_text("staged\n", encoding="utf-8")
|
|
140
|
+
_git(repo, "add", "staged.txt")
|
|
141
|
+
(repo / "untracked.txt").write_text("new\n", encoding="utf-8")
|
|
142
|
+
|
|
143
|
+
combined, root = get_combined_diff(repo)
|
|
144
|
+
assert root.resolve() == repo.resolve()
|
|
145
|
+
assert "## Meta\nhas_commits: true" in combined
|
|
146
|
+
assert "## Staged\nA staged.txt" in combined
|
|
147
|
+
assert "## Unstaged\nM tracked.txt" in combined
|
|
148
|
+
assert "## Untracked\nA untracked.txt" in combined
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def test_get_combined_diff_empty_repo_has_no_commits(tmp_path) -> None:
|
|
152
|
+
repo = tmp_path / "repo"
|
|
153
|
+
_init_repo(repo)
|
|
154
|
+
combined, _root = get_combined_diff(repo)
|
|
155
|
+
assert "has_commits: false" in combined
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def test_get_diff_for_paths_includes_untracked_file_content(tmp_path) -> None:
|
|
159
|
+
repo = tmp_path / "repo"
|
|
160
|
+
_init_repo(repo)
|
|
161
|
+
(repo / "new.txt").write_text("brand new content\n", encoding="utf-8")
|
|
162
|
+
|
|
163
|
+
diff = get_diff_for_paths(["new.txt"], repo)
|
|
164
|
+
assert "Untracked (new file): new.txt" in diff
|
|
165
|
+
assert "brand new content" in diff
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def test_get_diff_for_paths_includes_staged_and_unstaged_sections(tmp_path) -> None:
|
|
169
|
+
repo = tmp_path / "repo"
|
|
170
|
+
_init_repo(repo)
|
|
171
|
+
(repo / "a.txt").write_text("line1\n", encoding="utf-8")
|
|
172
|
+
_git(repo, "add", "a.txt")
|
|
173
|
+
_git(repo, "commit", "-m", "init")
|
|
174
|
+
|
|
175
|
+
(repo / "a.txt").write_text("line1\nline2\n", encoding="utf-8")
|
|
176
|
+
_git(repo, "add", "a.txt")
|
|
177
|
+
(repo / "a.txt").write_text("line1\nline2\nline3\n", encoding="utf-8")
|
|
178
|
+
|
|
179
|
+
diff = get_diff_for_paths(["a.txt"], repo)
|
|
180
|
+
assert "## Staged diff" in diff
|
|
181
|
+
assert "## Unstaged diff" in diff
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def test_get_diff_for_paths_empty_paths_returns_empty_string(tmp_path) -> None:
|
|
185
|
+
repo = tmp_path / "repo"
|
|
186
|
+
_init_repo(repo)
|
|
187
|
+
assert get_diff_for_paths([], repo) == ""
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def test_get_staged_diff_for_paths(tmp_path) -> None:
|
|
191
|
+
repo = tmp_path / "repo"
|
|
192
|
+
_init_repo(repo)
|
|
193
|
+
(repo / "a.txt").write_text("line1\n", encoding="utf-8")
|
|
194
|
+
_git(repo, "add", "a.txt")
|
|
195
|
+
_git(repo, "commit", "-m", "init")
|
|
196
|
+
|
|
197
|
+
(repo / "a.txt").write_text("line1\nline2\n", encoding="utf-8")
|
|
198
|
+
_git(repo, "add", "a.txt")
|
|
199
|
+
(repo / "a.txt").write_text("line1\nline2\nline3\n", encoding="utf-8")
|
|
200
|
+
|
|
201
|
+
staged_diff = get_staged_diff_for_paths(["a.txt"], repo)
|
|
202
|
+
assert "+line2" in staged_diff
|
|
203
|
+
assert "line3" not in staged_diff
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def test_get_staged_diff_for_paths_empty_paths_returns_empty_string(tmp_path) -> None:
|
|
207
|
+
repo = tmp_path / "repo"
|
|
208
|
+
_init_repo(repo)
|
|
209
|
+
assert get_staged_diff_for_paths([], repo) == ""
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "2.4.0"
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|