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.
Files changed (28) hide show
  1. {git_explain-2.4.0 → git_explain-2.5.0}/PKG-INFO +3 -6
  2. {git_explain-2.4.0 → git_explain-2.5.0}/README.md +1 -4
  3. git_explain-2.5.0/git_explain/__init__.py +1 -0
  4. {git_explain-2.4.0 → git_explain-2.5.0}/git_explain/cli.py +14 -1
  5. {git_explain-2.4.0 → git_explain-2.5.0}/git_explain/gemini.py +9 -42
  6. {git_explain-2.4.0 → git_explain-2.5.0}/git_explain/heuristics.py +10 -43
  7. {git_explain-2.4.0 → git_explain-2.5.0}/git_explain/path_topics.py +40 -0
  8. {git_explain-2.4.0 → git_explain-2.5.0}/git_explain.egg-info/PKG-INFO +3 -6
  9. {git_explain-2.4.0 → git_explain-2.5.0}/git_explain.egg-info/SOURCES.txt +2 -0
  10. {git_explain-2.4.0 → git_explain-2.5.0}/git_explain.egg-info/requires.txt +1 -1
  11. {git_explain-2.4.0 → git_explain-2.5.0}/pyproject.toml +1 -1
  12. git_explain-2.5.0/tests/test_gemini_suggest_commands.py +127 -0
  13. git_explain-2.5.0/tests/test_git.py +209 -0
  14. git_explain-2.4.0/git_explain/__init__.py +0 -1
  15. {git_explain-2.4.0 → git_explain-2.5.0}/LICENSE +0 -0
  16. {git_explain-2.4.0 → git_explain-2.5.0}/git_explain/__main__.py +0 -0
  17. {git_explain-2.4.0 → git_explain-2.5.0}/git_explain/commit_infer.py +0 -0
  18. {git_explain-2.4.0 → git_explain-2.5.0}/git_explain/git.py +0 -0
  19. {git_explain-2.4.0 → git_explain-2.5.0}/git_explain/run.py +0 -0
  20. {git_explain-2.4.0 → git_explain-2.5.0}/git_explain.egg-info/dependency_links.txt +0 -0
  21. {git_explain-2.4.0 → git_explain-2.5.0}/git_explain.egg-info/entry_points.txt +0 -0
  22. {git_explain-2.4.0 → git_explain-2.5.0}/git_explain.egg-info/top_level.txt +0 -0
  23. {git_explain-2.4.0 → git_explain-2.5.0}/setup.cfg +0 -0
  24. {git_explain-2.4.0 → git_explain-2.5.0}/tests/test_cli_utils.py +0 -0
  25. {git_explain-2.4.0 → git_explain-2.5.0}/tests/test_commit_infer.py +0 -0
  26. {git_explain-2.4.0 → git_explain-2.5.0}/tests/test_gemini.py +0 -0
  27. {git_explain-2.4.0 → git_explain-2.5.0}/tests/test_heuristics.py +0 -0
  28. {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.4.0
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>=0.8.0; extra == "dev"
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
  [![PyPI](https://img.shields.io/pypi/v/git-explain.svg?label=pypi)](https://pypi.org/project/git-explain/)
39
39
  [![GitHub tag](https://img.shields.io/github/v/tag/nazarli-shabnam/git-explain?label=repo)](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
- [![Sponsored by GitAds](https://gitads.dev/v1/ad-serve?source=nazarli-shabnam/git-explain@github)](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
  [![PyPI](https://img.shields.io/pypi/v/git-explain.svg?label=pypi)](https://pypi.org/project/git-explain/)
6
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
- <!-- 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
- [![Sponsored by GitAds](https://gitads.dev/v1/ad-serve?source=nazarli-shabnam/git-explain@github)](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 Exception as e:
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 = _alnum_key(m_for.group(2))
295
- right = _alnum_key(m_for.group(3))
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
- code_topics = _code_topics(files)
383
- if code_topics:
384
- topics.append(", ".join(code_topics[:5]))
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 = _alnum_key(scope.replace("for", "", 1))
406
- msg_key = _alnum_key(msg)
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
- code_topics = _code_topics(paths)
179
- if code_topics:
180
- if len(code_topics) > 4:
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(code_topics[:4]))
159
+ topics.append(", ".join(file_code_topics[:4]))
193
160
  else:
194
- topics.append(", ".join(code_topics[:5]))
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 = _alnum_key(scope_suffix.replace("for", "", 1))
214
- msg_key = _alnum_key(message)
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.4.0
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>=0.8.0; extra == "dev"
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
  [![PyPI](https://img.shields.io/pypi/v/git-explain.svg?label=pypi)](https://pypi.org/project/git-explain/)
39
39
  [![GitHub tag](https://img.shields.io/github/v/tag/nazarli-shabnam/git-explain?label=repo)](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
- [![Sponsored by GitAds](https://gitads.dev/v1/ad-serve?source=nazarli-shabnam/git-explain@github)](https://gitads.dev/v1/ad-track?source=nazarli-shabnam/git-explain@github)
125
-
@@ -19,5 +19,7 @@ git_explain.egg-info/top_level.txt
19
19
  tests/test_cli_utils.py
20
20
  tests/test_commit_infer.py
21
21
  tests/test_gemini.py
22
+ tests/test_gemini_suggest_commands.py
23
+ tests/test_git.py
22
24
  tests/test_heuristics.py
23
25
  tests/test_run_apply.py
@@ -6,4 +6,4 @@ prompt_toolkit>=3.0.0
6
6
 
7
7
  [dev]
8
8
  pytest>=8.0.0
9
- ruff>=0.8.0
9
+ ruff==0.12.3
@@ -38,7 +38,7 @@ dependencies = [
38
38
  [project.optional-dependencies]
39
39
  dev = [
40
40
  "pytest>=8.0.0",
41
- "ruff>=0.8.0",
41
+ "ruff==0.12.3",
42
42
  ]
43
43
 
44
44
  [project.scripts]
@@ -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