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.
- {git_explain-1.1.2 → git_explain-1.1.4}/PKG-INFO +17 -3
- {git_explain-1.1.2 → git_explain-1.1.4}/README.md +16 -2
- {git_explain-1.1.2 → git_explain-1.1.4}/git_explain/cli.py +42 -35
- {git_explain-1.1.2 → git_explain-1.1.4}/git_explain/gemini.py +82 -8
- {git_explain-1.1.2 → git_explain-1.1.4}/git_explain/heuristics.py +32 -21
- git_explain-1.1.4/git_explain/path_topics.py +178 -0
- {git_explain-1.1.2 → git_explain-1.1.4}/git_explain.egg-info/PKG-INFO +17 -3
- {git_explain-1.1.2 → git_explain-1.1.4}/git_explain.egg-info/SOURCES.txt +1 -0
- {git_explain-1.1.2 → git_explain-1.1.4}/pyproject.toml +1 -1
- git_explain-1.1.4/tests/test_gemini.py +73 -0
- {git_explain-1.1.2 → git_explain-1.1.4}/tests/test_heuristics.py +37 -0
- git_explain-1.1.2/tests/test_gemini.py +0 -37
- {git_explain-1.1.2 → git_explain-1.1.4}/LICENSE +0 -0
- {git_explain-1.1.2 → git_explain-1.1.4}/git_explain/__init__.py +0 -0
- {git_explain-1.1.2 → git_explain-1.1.4}/git_explain/git.py +0 -0
- {git_explain-1.1.2 → git_explain-1.1.4}/git_explain/run.py +0 -0
- {git_explain-1.1.2 → git_explain-1.1.4}/git_explain.egg-info/dependency_links.txt +0 -0
- {git_explain-1.1.2 → git_explain-1.1.4}/git_explain.egg-info/entry_points.txt +0 -0
- {git_explain-1.1.2 → git_explain-1.1.4}/git_explain.egg-info/requires.txt +0 -0
- {git_explain-1.1.2 → git_explain-1.1.4}/git_explain.egg-info/top_level.txt +0 -0
- {git_explain-1.1.2 → git_explain-1.1.4}/setup.cfg +0 -0
- {git_explain-1.1.2 → git_explain-1.1.4}/tests/test_cli_utils.py +0 -0
- {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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
-
|
|
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(
|
|
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.
|
|
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
|
|
42
|
+
def _is_plain_config(path: str) -> bool:
|
|
36
43
|
p = path.lower()
|
|
37
44
|
base = os.path.basename(p)
|
|
38
|
-
|
|
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
|
-
|
|
52
|
-
|
|
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
|
|
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
|
-
|
|
101
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
|
@@ -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
|
|
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
|