git-explain 1.1.2__tar.gz → 1.1.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (23) hide show
  1. {git_explain-1.1.2 → git_explain-1.1.4}/PKG-INFO +17 -3
  2. {git_explain-1.1.2 → git_explain-1.1.4}/README.md +16 -2
  3. {git_explain-1.1.2 → git_explain-1.1.4}/git_explain/cli.py +42 -35
  4. {git_explain-1.1.2 → git_explain-1.1.4}/git_explain/gemini.py +82 -8
  5. {git_explain-1.1.2 → git_explain-1.1.4}/git_explain/heuristics.py +32 -21
  6. git_explain-1.1.4/git_explain/path_topics.py +178 -0
  7. {git_explain-1.1.2 → git_explain-1.1.4}/git_explain.egg-info/PKG-INFO +17 -3
  8. {git_explain-1.1.2 → git_explain-1.1.4}/git_explain.egg-info/SOURCES.txt +1 -0
  9. {git_explain-1.1.2 → git_explain-1.1.4}/pyproject.toml +1 -1
  10. git_explain-1.1.4/tests/test_gemini.py +73 -0
  11. {git_explain-1.1.2 → git_explain-1.1.4}/tests/test_heuristics.py +37 -0
  12. git_explain-1.1.2/tests/test_gemini.py +0 -37
  13. {git_explain-1.1.2 → git_explain-1.1.4}/LICENSE +0 -0
  14. {git_explain-1.1.2 → git_explain-1.1.4}/git_explain/__init__.py +0 -0
  15. {git_explain-1.1.2 → git_explain-1.1.4}/git_explain/git.py +0 -0
  16. {git_explain-1.1.2 → git_explain-1.1.4}/git_explain/run.py +0 -0
  17. {git_explain-1.1.2 → git_explain-1.1.4}/git_explain.egg-info/dependency_links.txt +0 -0
  18. {git_explain-1.1.2 → git_explain-1.1.4}/git_explain.egg-info/entry_points.txt +0 -0
  19. {git_explain-1.1.2 → git_explain-1.1.4}/git_explain.egg-info/requires.txt +0 -0
  20. {git_explain-1.1.2 → git_explain-1.1.4}/git_explain.egg-info/top_level.txt +0 -0
  21. {git_explain-1.1.2 → git_explain-1.1.4}/setup.cfg +0 -0
  22. {git_explain-1.1.2 → git_explain-1.1.4}/tests/test_cli_utils.py +0 -0
  23. {git_explain-1.1.2 → git_explain-1.1.4}/tests/test_run_apply.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: git-explain
3
- Version: 1.1.2
3
+ Version: 1.1.4
4
4
  Summary: CLI that suggests git add/commit from diffs using Gemini
5
5
  Author: git-explain contributors
6
6
  License-Expression: MIT
@@ -68,13 +68,27 @@ pip install -e .
68
68
 
69
69
  ## API key (for `--ai` only)
70
70
 
71
- Put your Gemini API key in a `.env` file where you run the CLI, or set it in your environment:
71
+ **Option 1 Environment variable** (recommended for production, CI, scripts):
72
+
73
+ ```powershell
74
+ # PowerShell
75
+ $env:GEMINI_API_KEY = "your_key_here"
76
+ ```
77
+
78
+ ```bash
79
+ # Bash / Zsh
80
+ export GEMINI_API_KEY=your_key_here
81
+ ```
82
+
83
+ **Option 2 — `.env` file** (convenient for local development):
84
+
85
+ Create a `.env` file where you run the CLI:
72
86
 
73
87
  ```
74
88
  GEMINI_API_KEY=your_key_here
75
89
  ```
76
90
 
77
- Or use `GOOGLE_API_KEY`. Optional: set `GEMINI_MODEL` to override the default (e.g. `GEMINI_MODEL=gemini-2.5-flash`). See [Troubleshooting](#troubleshooting) for 404/429.
91
+ You can also use `GOOGLE_API_KEY`. Optional: set `GEMINI_MODEL` to override the default (e.g. `GEMINI_MODEL=gemini-2.5-flash`). See [Troubleshooting](#troubleshooting) for 404/429.
78
92
 
79
93
  ---
80
94
 
@@ -38,13 +38,27 @@ pip install -e .
38
38
 
39
39
  ## API key (for `--ai` only)
40
40
 
41
- Put your Gemini API key in a `.env` file where you run the CLI, or set it in your environment:
41
+ **Option 1 Environment variable** (recommended for production, CI, scripts):
42
+
43
+ ```powershell
44
+ # PowerShell
45
+ $env:GEMINI_API_KEY = "your_key_here"
46
+ ```
47
+
48
+ ```bash
49
+ # Bash / Zsh
50
+ export GEMINI_API_KEY=your_key_here
51
+ ```
52
+
53
+ **Option 2 — `.env` file** (convenient for local development):
54
+
55
+ Create a `.env` file where you run the CLI:
42
56
 
43
57
  ```
44
58
  GEMINI_API_KEY=your_key_here
45
59
  ```
46
60
 
47
- Or use `GOOGLE_API_KEY`. Optional: set `GEMINI_MODEL` to override the default (e.g. `GEMINI_MODEL=gemini-2.5-flash`). See [Troubleshooting](#troubleshooting) for 404/429.
61
+ You can also use `GOOGLE_API_KEY`. Optional: set `GEMINI_MODEL` to override the default (e.g. `GEMINI_MODEL=gemini-2.5-flash`). See [Troubleshooting](#troubleshooting) for 404/429.
48
62
 
49
63
  ---
50
64
 
@@ -264,36 +264,11 @@ def run(
264
264
  return
265
265
 
266
266
  norm_paths = [c.path.replace("\\", "/") for c in changes]
267
- untracked_indices_by_root: dict[str, list[int]] = {}
268
- for idx, (ch, np) in enumerate(zip(changes, norm_paths)):
269
- if (
270
- "Untracked" in ch.sections
271
- and "Staged" not in ch.sections
272
- and "Unstaged" not in ch.sections
273
- and "/" in np
274
- ):
275
- root = np.split("/", 1)[0]
276
- untracked_indices_by_root.setdefault(root, []).append(idx)
277
- folder_groups = {
278
- root: idxs for root, idxs in untracked_indices_by_root.items() if len(idxs) > 1
279
- }
280
-
281
267
  display_items: list[tuple[str, list[int]]] = []
282
- seen_untracked_roots: set[str] = set()
283
268
  for idx, ch in enumerate(changes):
284
- np = norm_paths[idx]
285
- root = np.split("/", 1)[0] if "/" in np else None
286
- if root and root in folder_groups:
287
- if root in seen_untracked_roots:
288
- continue
289
- seen_untracked_roots.add(root)
290
- count = len(folder_groups[root])
291
- label = f"{root}/ (untracked folder; {count} files)"
292
- display_items.append((label, folder_groups[root]))
293
- else:
294
- sec = ",".join([s.lower() for s in ch.sections if s and s != "Meta"])
295
- label = f"[{ch.status}] ({sec}) {ch.path}"
296
- display_items.append((label, [idx]))
269
+ sec = ",".join([s.lower() for s in ch.sections if s and s != "Meta"])
270
+ label = f"[{ch.status}] ({sec}) {ch.path}"
271
+ display_items.append((label, [idx]))
297
272
 
298
273
  lines = []
299
274
  for idx, (label, _idxs) in enumerate(display_items, start=1):
@@ -347,8 +322,9 @@ def run(
347
322
 
348
323
  def suggest_for(
349
324
  change_items: list[tuple[str, str]], title: str
350
- ) -> tuple[list[str], str, str, str]:
351
- # Returns (paths, type, message, raw_text)
325
+ ) -> tuple[list[str], str, str, str, str | None]:
326
+ # Returns (paths, type, message, raw_text, ai_fallback_reason).
327
+ # ai_fallback_reason is set when --ai was used but heuristics were used instead.
352
328
  if ai:
353
329
  payload = _render_combined(has_commits, change_items, title=title)
354
330
  if with_diff:
@@ -360,7 +336,7 @@ def run(
360
336
  sug, raw = suggest_commands(payload, model=model, with_diff=with_diff)
361
337
  if sug is None:
362
338
  raise RuntimeError("Could not parse AI suggestion.")
363
- return sug.add_args, sug.commit_type, sug.commit_message, raw
339
+ return sug.add_args, sug.commit_type, sug.commit_message, raw, None
364
340
  except Exception as e:
365
341
  # Fall back to heuristics on quota / API errors
366
342
  h = suggest_from_changes(changes=change_items, has_commits=has_commits)
@@ -368,10 +344,11 @@ def run(
368
344
  h.add_args,
369
345
  h.commit_type,
370
346
  h.commit_message,
371
- f"AI unavailable: {e}",
347
+ "",
348
+ str(e),
372
349
  )
373
350
  h = suggest_from_changes(changes=change_items, has_commits=has_commits)
374
- return h.add_args, h.commit_type, h.commit_message, ""
351
+ return h.add_args, h.commit_type, h.commit_message, "", None
375
352
 
376
353
  selected_pairs = [(ch.status, ch.path) for ch in selected]
377
354
  unique_paths = {p for _, p in selected_pairs}
@@ -385,14 +362,44 @@ def run(
385
362
  mode = mode_input
386
363
 
387
364
  plan: list[tuple[str, list[str], str, str]] = []
365
+ ai_fallback_notes: list[tuple[str, str]] = []
388
366
  if mode == "split":
389
367
  groups = _group_changes(selected_pairs)
390
368
  for gname, items in groups.items():
391
- paths, ctype, cmsg, _raw = suggest_for(items, title=gname.capitalize())
369
+ paths, ctype, cmsg, _raw, fb = suggest_for(
370
+ items, title=gname.capitalize()
371
+ )
392
372
  plan.append((gname, paths, ctype, cmsg))
373
+ if fb:
374
+ ai_fallback_notes.append((gname, fb))
393
375
  else:
394
- paths, ctype, cmsg, _raw = suggest_for(selected_pairs, title="Selected")
376
+ paths, ctype, cmsg, _raw, fb = suggest_for(selected_pairs, title="Selected")
395
377
  plan.append(("one", paths, ctype, cmsg))
378
+ if fb:
379
+ ai_fallback_notes.append(("", fb))
380
+
381
+ if ai and ai_fallback_notes:
382
+ lines = [
383
+ "[bold]You used --ai, but Gemini was not used for the suggestion below.[/bold]",
384
+ "Commit message(s) come from [bold]local heuristics[/bold] instead.",
385
+ "",
386
+ ]
387
+ if mode == "split":
388
+ for gname, reason in ai_fallback_notes:
389
+ lines.append(f"[dim]{gname}:[/dim] {reason}")
390
+ else:
391
+ lines.append(ai_fallback_notes[0][1])
392
+ lines.append("")
393
+ lines.append(
394
+ "[dim]Check API key (GEMINI_API_KEY / GOOGLE_API_KEY), quota, model name, and network.[/dim]"
395
+ )
396
+ console.print(
397
+ Panel(
398
+ "\n".join(lines),
399
+ title="[yellow]Warning: AI unavailable[/yellow]",
400
+ border_style="yellow",
401
+ )
402
+ )
396
403
 
397
404
  def _render_plan(pl: list[tuple[str, list[str], str, str]]) -> str:
398
405
  rendered: list[str] = []
@@ -8,6 +8,14 @@ from dataclasses import dataclass
8
8
  from google import genai
9
9
  from google.genai import types
10
10
 
11
+ from git_explain.path_topics import (
12
+ area_scope_suffix,
13
+ basename_fallback_topic,
14
+ infra_deploy_topics,
15
+ is_test_path,
16
+ test_subject_hints,
17
+ )
18
+
11
19
  SYSTEM_PROMPT = """You are given a list of changed/added files under ## Staged, ## Unstaged, ## Untracked.
12
20
  Each file line is: <STATUS> <PATH> where STATUS is one of:
13
21
  - A = added/new file
@@ -20,13 +28,18 @@ Suggest one commit that includes ALL of these files.
20
28
 
21
29
  Rules:
22
30
  1. Line 1 must be: git add <path1> <path2> ... with EVERY PATH from the list (all sections). Do not omit any file. Do not truncate. Do not include status letters.
23
- 2. Line 2 must be: git commit -m "[TYPE] Message" with TYPE one of: FEAT, FIX, DOCS, REFACTOR, TEST.
31
+ 2. Line 2 must be: git commit -m "[TYPE] Message" with TYPE one of: FEAT, FIX, DOCS, REFACTOR, TEST, CHORE.
24
32
  3. The message must be a short, specific summary of what the change does based on the file names (e.g. "Add README and feature status doc", "Fix Gemini model and add file-list mode"). Never use only generic words like "update", "changes", or "refactor" by themselves—always add what was updated (e.g. "Update docs and CLI prompt").
25
- 4. Use imperative, no period at end. Maximum one short line.
33
+ 4. Infer concrete artifacts from paths when obvious: Dockerfiles, Docker Compose files, nginx configs, .env/.env.example templates, CI workflows—not vague summaries like "add changes" or "add files" with no subject. For test paths (e.g. tests/test_foo.py), name the area under test (e.g. "Expand tests for foo and bar")—not "update project files".
34
+ 5. Use imperative, no period at end. Maximum one short line.
26
35
 
27
36
  Example for files README.md, FEATURES.md, git_explain/gemini.py:
28
37
  git add README.md FEATURES.md git_explain/gemini.py
29
38
  git commit -m "[DOCS] Add README and FEATURES doc, tune Gemini prompt"
39
+
40
+ Example for Docker + nginx + env templates under api/ and apps/frontend/:
41
+ git add api/app/Dockerfile apps/frontend/nginx.conf
42
+ git commit -m "[CHORE] Add Docker and nginx config with env examples for api and frontend"
30
43
  """
31
44
 
32
45
  SYSTEM_PROMPT_WITH_DIFF = """You are given:
@@ -34,10 +47,11 @@ SYSTEM_PROMPT_WITH_DIFF = """You are given:
34
47
  2. The full diff (## Staged diff, ## Unstaged diff, ## Untracked) showing exact code changes.
35
48
 
36
49
  Use the diff to write a specific, detailed commit message. Do not use generic words like "update" or "changes"—describe what actually changed (e.g. "add opt-in --with-diff to send full diff to LLM for detailed messages", "tweak commit message edit flow to show suggestion before prompting to edit").
50
+ Name concrete pieces from paths when helpful (Docker, nginx, env templates, workflows)—avoid empty phrases like "add changes" that do not say what was added.
37
51
 
38
52
  Output format (conventional commits style):
39
53
  - Line 1: git add <path1> <path2> ... with EVERY path from the file list. Do not omit any.
40
- - Line 2: git commit -m "type: subject" where type is exactly one of: feat, fix, docs, refactor, test.
54
+ - Line 2: git commit -m "type: subject" where type is exactly one of: feat, fix, docs, refactor, test, chore.
41
55
  The subject must be a short, specific summary in imperative mood, no period at end (e.g. "feat: allow editing commit message before apply", "fix: parse conventional commit line from AI").
42
56
 
43
57
  Example:
@@ -47,16 +61,39 @@ git commit -m "feat: add opt-in --with-diff for detailed AI commit messages"
47
61
 
48
62
  ADD_LINE_RE = re.compile(r"git\s+add\s+(.+)", re.IGNORECASE)
49
63
  COMMIT_LINE_RE = re.compile(
50
- r'git\s+commit\s+-m\s+["\']\[(FEAT|FIX|DOCS|REFACTOR|TESTS)\]\s*(.+?)["\']',
64
+ r'git\s+commit\s+-m\s+["\']\[(FEAT|FIX|DOCS|REFACTOR|TESTS|CHORE)\]\s*(.+?)["\']',
51
65
  re.IGNORECASE,
52
66
  )
53
67
  # Conventional: "feat: subject" or "fix: subject" (use "tests" not "test")
54
68
  COMMIT_LINE_CONVENTIONAL_RE = re.compile(
55
- r'git\s+commit\s+-m\s+["\'](feat|fix|docs|refactor|tests)\s*:\s*(.+?)["\']',
69
+ r'git\s+commit\s+-m\s+["\'](feat|fix|docs|refactor|tests|chore)\s*:\s*(.+?)["\']',
56
70
  re.IGNORECASE,
57
71
  )
58
72
  DEFAULT_MODEL = "gemini-2.5-flash"
59
73
 
74
+ _VAGUE_VERB_NOUN = re.compile(
75
+ r"^(add|update|modify|make)\s+(changes?|updates?|stuff|things)\s*$",
76
+ re.IGNORECASE,
77
+ )
78
+
79
+ # After add/update/modify/make, these tails are too vague to keep as the final message.
80
+ _VAGUE_TAIL_AFTER_VERB = frozenset(
81
+ {
82
+ "project files",
83
+ "the project",
84
+ "the codebase",
85
+ "codebase",
86
+ "code",
87
+ "files",
88
+ "file",
89
+ "some files",
90
+ "various files",
91
+ "multiple files",
92
+ "dependencies",
93
+ "deps",
94
+ }
95
+ )
96
+
60
97
  _GENERIC_MESSAGES = {
61
98
  "update",
62
99
  "updates",
@@ -80,6 +117,25 @@ def _is_generic_message(message: str) -> bool:
80
117
  return True
81
118
  if msg in _GENERIC_MESSAGES:
82
119
  return True
120
+ if _VAGUE_VERB_NOUN.match(msg):
121
+ return True
122
+ parts = msg.split()
123
+ if len(parts) == 2 and parts[0] in (
124
+ "add",
125
+ "update",
126
+ "modify",
127
+ "make",
128
+ ) and parts[1] in ("changes", "change", "updates", "update", "files", "file"):
129
+ return True
130
+ if len(parts) >= 2 and parts[0] in (
131
+ "add",
132
+ "update",
133
+ "modify",
134
+ "make",
135
+ ):
136
+ tail = " ".join(parts[1:]).strip()
137
+ if tail in _VAGUE_TAIL_AFTER_VERB:
138
+ return True
83
139
  # "update X" is okay, but bare "update" or "update stuff" isn't
84
140
  if re.fullmatch(
85
141
  r"(update|updates|change|changes|refactor|refactoring|misc)(\s+.+)?", msg
@@ -127,8 +183,12 @@ def _fallback_type_and_message_with_context(
127
183
 
128
184
  verb = "Add" if (added_any or has_commits is False) else "Update"
129
185
 
186
+ all_test_paths = bool(files) and all(is_test_path(f) for f in files)
187
+
130
188
  if docs_only:
131
189
  commit_type = "DOCS"
190
+ elif all_test_paths:
191
+ commit_type = "TEST"
132
192
  elif verb == "Add":
133
193
  commit_type = "FEAT"
134
194
  else:
@@ -139,6 +199,17 @@ def _fallback_type_and_message_with_context(
139
199
  topics.append("README")
140
200
  if any(f.endswith("features.md") for f in lower):
141
201
  topics.append("FEATURES doc")
202
+ topics.extend(infra_deploy_topics(files))
203
+ test_files = [f for f in files if is_test_path(f)]
204
+ if test_files:
205
+ all_tests_only = len(test_files) == len(files)
206
+ hints = test_subject_hints(files)
207
+ if all_tests_only and hints:
208
+ head = " and ".join(hints[:3])
209
+ tail = f" (+{len(hints) - 3} more)" if len(hints) > 3 else ""
210
+ topics.append(f"tests for {head}{tail}")
211
+ else:
212
+ topics.append("tests")
142
213
  if touches_docs and not docs_only:
143
214
  topics.append("docs")
144
215
  if any(f.startswith("git_explain/") for f in lower) or any(
@@ -154,13 +225,14 @@ def _fallback_type_and_message_with_context(
154
225
  if touches_packaging:
155
226
  topics.append("packaging config")
156
227
 
157
- if not topics:
158
- topics = ["project files"]
159
-
160
228
  # Dedupe while keeping order
161
229
  seen: set[str] = set()
162
230
  topics = [t for t in topics if not (t in seen or seen.add(t))]
163
231
 
232
+ if not topics:
233
+ fb = basename_fallback_topic(files)
234
+ topics = [fb] if fb else ["project files"]
235
+
164
236
  if len(topics) == 1:
165
237
  msg = f"{verb} {topics[0]}"
166
238
  elif len(topics) == 2:
@@ -168,6 +240,8 @@ def _fallback_type_and_message_with_context(
168
240
  else:
169
241
  msg = f"{verb} {topics[0]}, {topics[1]}, and {topics[2]}"
170
242
 
243
+ msg += area_scope_suffix(files)
244
+
171
245
  if verb == "Add" and (has_commits is False):
172
246
  # Make initial commits a little clearer but still "Add …"
173
247
  msg = msg.replace("Add ", "Add initial ", 1) if msg.startswith("Add ") else msg
@@ -5,10 +5,17 @@ from __future__ import annotations
5
5
  import os
6
6
 
7
7
  from git_explain.gemini import Suggestion
8
+ from git_explain.path_topics import (
9
+ area_scope_suffix,
10
+ basename_fallback_topic,
11
+ infra_deploy_topics,
12
+ is_infra_deploy_path,
13
+ is_test_path,
14
+ test_subject_hints,
15
+ )
8
16
 
9
17
 
10
18
  DOC_EXTS = {".md", ".rst", ".txt"}
11
- TEST_HINTS = ("test", "tests", "pytest", "unittest")
12
19
  CONFIG_FILES = {
13
20
  "pyproject.toml",
14
21
  "requirements.txt",
@@ -32,25 +39,15 @@ def _is_doc(path: str) -> bool:
32
39
  }
33
40
 
34
41
 
35
- def _is_test(path: str) -> bool:
42
+ def _is_plain_config(path: str) -> bool:
36
43
  p = path.lower()
37
44
  base = os.path.basename(p)
38
- if p.startswith("tests/") or "/tests/" in p:
39
- return True
40
- if (
41
- base.startswith("test_")
42
- or base.endswith("_test.py")
43
- or base.endswith(".spec.ts")
44
- or base.endswith(".spec.tsx")
45
- ):
46
- return True
47
- return any(h in p for h in TEST_HINTS)
45
+ return base in CONFIG_FILES or os.path.splitext(p)[1] in CONFIG_EXTS
48
46
 
49
47
 
50
48
  def _is_config(path: str) -> bool:
51
- p = path.lower()
52
- base = os.path.basename(p)
53
- return base in CONFIG_FILES or os.path.splitext(p)[1] in CONFIG_EXTS
49
+ """Packaging/config files plus Docker, Compose, nginx, env templates."""
50
+ return _is_plain_config(path) or is_infra_deploy_path(path)
54
51
 
55
52
 
56
53
  def suggest_from_changes(
@@ -63,7 +60,7 @@ def suggest_from_changes(
63
60
  added_any = any(s.upper() == "A" for s, _ in changes) or has_commits is False
64
61
 
65
62
  docs = [p for p in paths if _is_doc(p)]
66
- tests = [p for p in paths if _is_test(p)]
63
+ tests = [p for p in paths if is_test_path(p)]
67
64
  configs = [p for p in paths if _is_config(p)]
68
65
  has_tests = bool(tests)
69
66
  has_configs = bool(configs)
@@ -96,20 +93,29 @@ def suggest_from_changes(
96
93
  topics.append("README")
97
94
  if any(os.path.basename(p).lower() == "features.md" for p in paths):
98
95
  topics.append("FEATURES doc")
96
+ topics.extend(infra_deploy_topics(paths))
99
97
  if tests:
100
- topics.append("tests")
101
- if configs:
98
+ all_tests_only = bool(paths) and len(tests) == len(paths)
99
+ hints = test_subject_hints(paths)
100
+ if all_tests_only and hints:
101
+ head = " and ".join(hints[:3])
102
+ tail = f" (+{len(hints) - 3} more)" if len(hints) > 3 else ""
103
+ topics.append(f"tests for {head}{tail}")
104
+ else:
105
+ topics.append("tests")
106
+ if any(_is_plain_config(p) for p in paths):
102
107
  topics.append("config")
103
108
  if any("git_explain/" in p.replace("\\", "/").lower() for p in paths):
104
109
  topics.append("git-explain CLI")
105
110
 
106
- if not topics:
107
- topics = ["changes"]
108
-
109
111
  # Dedupe while preserving order
110
112
  seen: set[str] = set()
111
113
  topics = [t for t in topics if not (t in seen or seen.add(t))]
112
114
 
115
+ if not topics:
116
+ fb = basename_fallback_topic(paths)
117
+ topics = [fb] if fb else ["project files"]
118
+
113
119
  if len(topics) == 1:
114
120
  message = f"{verb} {topics[0]}"
115
121
  elif len(topics) == 2:
@@ -117,7 +123,12 @@ def suggest_from_changes(
117
123
  else:
118
124
  message = f"{verb} {topics[0]}, {topics[1]}, and {topics[2]}"
119
125
 
126
+ message += area_scope_suffix(paths)
127
+
120
128
  if added_any and has_commits is False and message.startswith("Add "):
121
129
  message = message.replace("Add ", "Add initial ", 1)
122
130
 
131
+ if len(message) > 72:
132
+ message = message[:72].rstrip()
133
+
123
134
  return Suggestion(add_args=paths, commit_type=commit_type, commit_message=message)
@@ -0,0 +1,178 @@
1
+ """Derive concrete commit-message topics from paths (Docker, nginx, env templates, areas)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+
7
+
8
+ def _norm(p: str) -> str:
9
+ return p.replace("\\", "/").strip()
10
+
11
+
12
+ _TEST_HINTS = ("pytest", "unittest", "tests/", "/tests/")
13
+
14
+
15
+ def is_test_path(path: str) -> bool:
16
+ """True if path looks like a test file (mirrors heuristics rules; paths normalized)."""
17
+ p = _norm(path).lower()
18
+ base = os.path.basename(p)
19
+ if p.startswith("tests/") or "/tests/" in p:
20
+ return True
21
+ if (
22
+ base.startswith("test_")
23
+ or base.endswith("_test.py")
24
+ or base.endswith(".spec.ts")
25
+ or base.endswith(".spec.tsx")
26
+ ):
27
+ return True
28
+ return any(h in p for h in _TEST_HINTS)
29
+
30
+
31
+ def test_subject_hints(paths: list[str]) -> list[str]:
32
+ """Short labels from test filenames, e.g. test_gemini.py -> gemini (deduped, stable order)."""
33
+ hints: list[str] = []
34
+ seen: set[str] = set()
35
+ for raw in paths:
36
+ if not is_test_path(raw):
37
+ continue
38
+ base = os.path.basename(_norm(raw))
39
+ stem, _ext = os.path.splitext(base)
40
+ s = stem
41
+ low = s.lower()
42
+ if low.startswith("test_"):
43
+ core = s[5:]
44
+ elif low.endswith("_test"):
45
+ core = s[: -len("_test")]
46
+ else:
47
+ core = s
48
+ core = core.strip().replace("_", " ").strip()
49
+ if not core:
50
+ continue
51
+ key = core.lower()
52
+ if key not in seen:
53
+ seen.add(key)
54
+ hints.append(core)
55
+ return hints
56
+
57
+
58
+ def is_infra_deploy_path(path: str) -> bool:
59
+ """True if path looks like Docker, Compose, nginx, or env-template deploy config."""
60
+ p = _norm(path).lower()
61
+ base = os.path.basename(p)
62
+ if base == "dockerfile" or base.endswith(".dockerfile"):
63
+ return True
64
+ if base == ".dockerignore":
65
+ return True
66
+ if base.startswith("docker-compose") and base.endswith((".yml", ".yaml")):
67
+ return True
68
+ if base in ("compose.yaml", "compose.yml"):
69
+ return True
70
+ if "nginx" in base and base.endswith(".conf"):
71
+ return True
72
+ if base.endswith((".env.example", ".env.sample")):
73
+ return True
74
+ if base in ("compose.env.example", ".env.example"):
75
+ return True
76
+ return False
77
+
78
+
79
+ def infra_deploy_topics(paths: list[str]) -> list[str]:
80
+ """Ordered, deduplicated topic phrases (no leading verb)."""
81
+ has_dockerfile = False
82
+ has_dockerignore = False
83
+ has_compose = False
84
+ has_nginx = False
85
+ has_env_example = False
86
+
87
+ for raw in paths:
88
+ p = _norm(raw).lower()
89
+ base = os.path.basename(p)
90
+ if base == "dockerfile" or base.endswith(".dockerfile"):
91
+ has_dockerfile = True
92
+ if base == ".dockerignore":
93
+ has_dockerignore = True
94
+ if base.startswith("docker-compose") and base.endswith((".yml", ".yaml")):
95
+ has_compose = True
96
+ if base in ("compose.yaml", "compose.yml"):
97
+ has_compose = True
98
+ if "nginx" in base and base.endswith(".conf"):
99
+ has_nginx = True
100
+ if base.endswith((".env.example", ".env.sample")):
101
+ has_env_example = True
102
+ if base in ("compose.env.example", ".env.example"):
103
+ has_env_example = True
104
+
105
+ topics: list[str] = []
106
+ seen: set[str] = set()
107
+
108
+ def add(s: str) -> None:
109
+ if s not in seen:
110
+ seen.add(s)
111
+ topics.append(s)
112
+
113
+ if has_dockerfile or has_dockerignore:
114
+ add("Docker")
115
+ if has_compose:
116
+ add("Docker Compose")
117
+ if has_nginx:
118
+ add("nginx")
119
+ if has_env_example:
120
+ add("env examples")
121
+
122
+ return topics
123
+
124
+
125
+ def area_scope_suffix(paths: list[str]) -> str:
126
+ """Return ' for api and frontend' style suffix, or ''."""
127
+ labels: list[str] = []
128
+ seen: set[str] = set()
129
+
130
+ for raw in paths:
131
+ p = _norm(raw)
132
+ if not p or p == ".":
133
+ continue
134
+ parts = [x for x in p.split("/") if x]
135
+ if len(parts) < 2:
136
+ continue
137
+ first, second = parts[0], parts[1]
138
+ if first.lower() in {"apps", "packages", "services"}:
139
+ label = second
140
+ else:
141
+ label = first
142
+ low = label.lower()
143
+ if low not in seen:
144
+ seen.add(low)
145
+ labels.append(label)
146
+
147
+ if not labels:
148
+ return ""
149
+
150
+ def fmt(lbl: str) -> str:
151
+ return "API" if lbl.lower() == "api" else lbl
152
+
153
+ labels = [fmt(x) for x in labels]
154
+ if len(labels) == 1:
155
+ return f" for {labels[0]}"
156
+ if len(labels) == 2:
157
+ return f" for {labels[0]} and {labels[1]}"
158
+ return f" for {labels[0]}, {labels[1]}, and {labels[2]}"
159
+
160
+
161
+ def basename_fallback_topic(paths: list[str], max_names: int = 4) -> str | None:
162
+ """Short description from basenames when no other topic matched."""
163
+ bases: list[str] = []
164
+ seen: set[str] = set()
165
+ for raw in paths:
166
+ b = os.path.basename(_norm(raw))
167
+ if not b:
168
+ continue
169
+ key = b.lower()
170
+ if key not in seen:
171
+ seen.add(key)
172
+ bases.append(b)
173
+ if not bases:
174
+ return None
175
+ if len(bases) <= max_names:
176
+ return ", ".join(bases)
177
+ head = ", ".join(bases[:max_names])
178
+ return f"{head} (+{len(bases) - max_names} more)"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: git-explain
3
- Version: 1.1.2
3
+ Version: 1.1.4
4
4
  Summary: CLI that suggests git add/commit from diffs using Gemini
5
5
  Author: git-explain contributors
6
6
  License-Expression: MIT
@@ -68,13 +68,27 @@ pip install -e .
68
68
 
69
69
  ## API key (for `--ai` only)
70
70
 
71
- Put your Gemini API key in a `.env` file where you run the CLI, or set it in your environment:
71
+ **Option 1 Environment variable** (recommended for production, CI, scripts):
72
+
73
+ ```powershell
74
+ # PowerShell
75
+ $env:GEMINI_API_KEY = "your_key_here"
76
+ ```
77
+
78
+ ```bash
79
+ # Bash / Zsh
80
+ export GEMINI_API_KEY=your_key_here
81
+ ```
82
+
83
+ **Option 2 — `.env` file** (convenient for local development):
84
+
85
+ Create a `.env` file where you run the CLI:
72
86
 
73
87
  ```
74
88
  GEMINI_API_KEY=your_key_here
75
89
  ```
76
90
 
77
- Or use `GOOGLE_API_KEY`. Optional: set `GEMINI_MODEL` to override the default (e.g. `GEMINI_MODEL=gemini-2.5-flash`). See [Troubleshooting](#troubleshooting) for 404/429.
91
+ You can also use `GOOGLE_API_KEY`. Optional: set `GEMINI_MODEL` to override the default (e.g. `GEMINI_MODEL=gemini-2.5-flash`). See [Troubleshooting](#troubleshooting) for 404/429.
78
92
 
79
93
  ---
80
94
 
@@ -6,6 +6,7 @@ git_explain/cli.py
6
6
  git_explain/gemini.py
7
7
  git_explain/git.py
8
8
  git_explain/heuristics.py
9
+ git_explain/path_topics.py
9
10
  git_explain/run.py
10
11
  git_explain.egg-info/PKG-INFO
11
12
  git_explain.egg-info/SOURCES.txt
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "git-explain"
7
- version = "v1.1.2"
7
+ version = "v1.1.4"
8
8
  description = "CLI that suggests git add/commit from diffs using Gemini"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -0,0 +1,73 @@
1
+ from git_explain.gemini import (
2
+ COMMIT_LINE_CONVENTIONAL_RE,
3
+ COMMIT_LINE_RE,
4
+ _fallback_type_and_message_with_context,
5
+ _is_generic_message,
6
+ )
7
+
8
+
9
+ def test_commit_line_re_matches_tests_not_test() -> None:
10
+ """COMMIT_LINE_RE should match [TESTS] but not [TEST]."""
11
+ line_tests = 'git commit -m "[TESTS] Add unit tests"'
12
+ m = COMMIT_LINE_RE.match(line_tests)
13
+ assert m is not None
14
+ assert m.group(1).upper() == "TESTS"
15
+ assert "Add unit tests" in m.group(2)
16
+
17
+ line_test = 'git commit -m "[TEST] Add unit test"'
18
+ m = COMMIT_LINE_RE.match(line_test)
19
+ assert m is None
20
+
21
+
22
+ def test_commit_line_conventional_re_matches_tests() -> None:
23
+ """COMMIT_LINE_CONVENTIONAL_RE should match 'tests:' not 'test:'."""
24
+ line = 'git commit -m "tests: add unit tests"'
25
+ m = COMMIT_LINE_CONVENTIONAL_RE.match(line)
26
+ assert m is not None
27
+ assert m.group(1).lower() == "tests"
28
+
29
+ line_test = 'git commit -m "test: add unit test"'
30
+ m = COMMIT_LINE_CONVENTIONAL_RE.match(line_test)
31
+ assert m is None
32
+
33
+
34
+ def test_commit_line_re_matches_other_types() -> None:
35
+ for line in [
36
+ 'git commit -m "[FEAT] Add feature"',
37
+ 'git commit -m "[FIX] Fix bug"',
38
+ 'git commit -m "[DOCS] Update readme"',
39
+ 'git commit -m "[REFACTOR] Simplify logic"',
40
+ 'git commit -m "[CHORE] Add Docker and nginx config"',
41
+ ]:
42
+ m = COMMIT_LINE_RE.match(line)
43
+ assert m is not None, f"Expected match for {line}"
44
+
45
+
46
+ def test_commit_line_conventional_matches_chore() -> None:
47
+ line = 'git commit -m "chore: add docker compose"'
48
+ m = COMMIT_LINE_CONVENTIONAL_RE.match(line)
49
+ assert m is not None
50
+ assert m.group(1).lower() == "chore"
51
+
52
+
53
+ def test_is_generic_message_flags_vague_add_changes() -> None:
54
+ assert _is_generic_message("Add changes") is True
55
+ assert _is_generic_message("Update changes") is True
56
+ assert _is_generic_message("Add Docker and nginx for api") is False
57
+
58
+
59
+ def test_is_generic_message_flags_update_project_files() -> None:
60
+ assert _is_generic_message("Update project files") is True
61
+ assert _is_generic_message("Add project files") is True
62
+ assert _is_generic_message("Update tests for gemini and heuristics") is False
63
+
64
+
65
+ def test_fallback_uses_test_hints_for_test_files() -> None:
66
+ ctype, msg = _fallback_type_and_message_with_context(
67
+ files=["tests/test_gemini.py", "tests/test_heuristics.py"],
68
+ added_any=False,
69
+ has_commits=True,
70
+ )
71
+ assert ctype == "TEST"
72
+ assert "gemini" in msg.lower()
73
+ assert "heuristics" in msg.lower()
@@ -39,3 +39,40 @@ def test_config_only_is_chore_not_test() -> None:
39
39
  has_commits=True,
40
40
  )
41
41
  assert s.commit_type == "CHORE"
42
+
43
+
44
+ def test_test_only_paths_get_specific_test_message() -> None:
45
+ s = suggest_from_changes(
46
+ changes=[
47
+ ("M", "tests/test_gemini.py"),
48
+ ("M", "tests/test_heuristics.py"),
49
+ ],
50
+ has_commits=True,
51
+ )
52
+ assert s.commit_type == "TEST"
53
+ m = s.commit_message.lower()
54
+ assert "gemini" in m
55
+ assert "heuristics" in m
56
+ assert "project files" not in m
57
+
58
+
59
+ def test_docker_nginx_env_paths_get_specific_chore_message() -> None:
60
+ """Infra paths should not collapse to 'Add changes'."""
61
+ s = suggest_from_changes(
62
+ changes=[
63
+ ("A", "api/app/.env.example"),
64
+ ("A", "api/app/Dockerfile"),
65
+ ("A", "apps/frontend/.dockerignore"),
66
+ ("A", "apps/frontend/Dockerfile"),
67
+ ("A", "apps/frontend/nginx.conf"),
68
+ ("A", "compose.env.example"),
69
+ ],
70
+ has_commits=True,
71
+ )
72
+ assert s.commit_type == "CHORE"
73
+ m = s.commit_message.lower()
74
+ assert "docker" in m
75
+ assert "nginx" in m
76
+ assert "env" in m
77
+ assert "changes" not in m
78
+ assert "api" in m and "frontend" in m
@@ -1,37 +0,0 @@
1
- from git_explain.gemini import COMMIT_LINE_CONVENTIONAL_RE, COMMIT_LINE_RE
2
-
3
-
4
- def test_commit_line_re_matches_tests_not_test() -> None:
5
- """COMMIT_LINE_RE should match [TESTS] but not [TEST]."""
6
- line_tests = 'git commit -m "[TESTS] Add unit tests"'
7
- m = COMMIT_LINE_RE.match(line_tests)
8
- assert m is not None
9
- assert m.group(1).upper() == "TESTS"
10
- assert "Add unit tests" in m.group(2)
11
-
12
- line_test = 'git commit -m "[TEST] Add unit test"'
13
- m = COMMIT_LINE_RE.match(line_test)
14
- assert m is None
15
-
16
-
17
- def test_commit_line_conventional_re_matches_tests() -> None:
18
- """COMMIT_LINE_CONVENTIONAL_RE should match 'tests:' not 'test:'."""
19
- line = 'git commit -m "tests: add unit tests"'
20
- m = COMMIT_LINE_CONVENTIONAL_RE.match(line)
21
- assert m is not None
22
- assert m.group(1).lower() == "tests"
23
-
24
- line_test = 'git commit -m "test: add unit test"'
25
- m = COMMIT_LINE_CONVENTIONAL_RE.match(line_test)
26
- assert m is None
27
-
28
-
29
- def test_commit_line_re_matches_other_types() -> None:
30
- for line in [
31
- 'git commit -m "[FEAT] Add feature"',
32
- 'git commit -m "[FIX] Fix bug"',
33
- 'git commit -m "[DOCS] Update readme"',
34
- 'git commit -m "[REFACTOR] Simplify logic"',
35
- ]:
36
- m = COMMIT_LINE_RE.match(line)
37
- assert m is not None, f"Expected match for {line}"
File without changes
File without changes