git-explain 1.1.4__tar.gz → 2.1.5__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.4 → git_explain-2.1.5}/PKG-INFO +7 -4
- {git_explain-1.1.4 → git_explain-2.1.5}/README.md +5 -3
- git_explain-2.1.5/git_explain/__init__.py +1 -0
- {git_explain-1.1.4 → git_explain-2.1.5}/git_explain/cli.py +148 -14
- git_explain-2.1.5/git_explain/commit_infer.py +66 -0
- {git_explain-1.1.4 → git_explain-2.1.5}/git_explain/gemini.py +134 -21
- {git_explain-1.1.4 → git_explain-2.1.5}/git_explain/git.py +16 -0
- {git_explain-1.1.4 → git_explain-2.1.5}/git_explain/heuristics.py +61 -4
- {git_explain-1.1.4 → git_explain-2.1.5}/git_explain/run.py +11 -0
- {git_explain-1.1.4 → git_explain-2.1.5}/git_explain.egg-info/PKG-INFO +7 -4
- {git_explain-1.1.4 → git_explain-2.1.5}/git_explain.egg-info/SOURCES.txt +2 -0
- {git_explain-1.1.4 → git_explain-2.1.5}/git_explain.egg-info/requires.txt +1 -0
- {git_explain-1.1.4 → git_explain-2.1.5}/pyproject.toml +2 -1
- {git_explain-1.1.4 → git_explain-2.1.5}/tests/test_cli_utils.py +29 -2
- git_explain-2.1.5/tests/test_commit_infer.py +72 -0
- {git_explain-1.1.4 → git_explain-2.1.5}/tests/test_gemini.py +57 -0
- {git_explain-1.1.4 → git_explain-2.1.5}/tests/test_heuristics.py +17 -1
- git_explain-1.1.4/git_explain/__init__.py +0 -1
- {git_explain-1.1.4 → git_explain-2.1.5}/LICENSE +0 -0
- {git_explain-1.1.4 → git_explain-2.1.5}/git_explain/path_topics.py +0 -0
- {git_explain-1.1.4 → git_explain-2.1.5}/git_explain.egg-info/dependency_links.txt +0 -0
- {git_explain-1.1.4 → git_explain-2.1.5}/git_explain.egg-info/entry_points.txt +0 -0
- {git_explain-1.1.4 → git_explain-2.1.5}/git_explain.egg-info/top_level.txt +0 -0
- {git_explain-1.1.4 → git_explain-2.1.5}/setup.cfg +0 -0
- {git_explain-1.1.4 → git_explain-2.1.5}/tests/test_run_apply.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: git-explain
|
|
3
|
-
Version:
|
|
3
|
+
Version: 2.1.5
|
|
4
4
|
Summary: CLI that suggests git add/commit from diffs using Gemini
|
|
5
5
|
Author: git-explain contributors
|
|
6
6
|
License-Expression: MIT
|
|
@@ -26,6 +26,7 @@ Requires-Dist: python-dotenv>=1.0.0
|
|
|
26
26
|
Requires-Dist: prompt_toolkit>=3.0.0
|
|
27
27
|
Provides-Extra: dev
|
|
28
28
|
Requires-Dist: pytest>=8.0.0; extra == "dev"
|
|
29
|
+
Requires-Dist: ruff>=0.8.0; extra == "dev"
|
|
29
30
|
Dynamic: license-file
|
|
30
31
|
|
|
31
32
|
# git-explain
|
|
@@ -104,13 +105,14 @@ You’ll see a list of changed files, choose which to include, then get suggeste
|
|
|
104
105
|
|
|
105
106
|
---
|
|
106
107
|
|
|
107
|
-
## Modes: `git-explain` vs `git-explain --ai` vs `git-explain --with-diff --ai`
|
|
108
|
+
## Modes: `git-explain` vs `git-explain --ai` vs `git-explain --with-diff --ai` vs `git-explain --suggest`
|
|
108
109
|
|
|
109
110
|
| Command | What it does |
|
|
110
111
|
|---------|--------------|
|
|
111
112
|
| `git-explain` | **Heuristics only.** No API call. Suggests commit type and message from file names and status (e.g. docs, tests, config, code). Fast and private. |
|
|
112
113
|
| `git-explain --ai` | **AI (paths only).** Sends only file paths and statuses (A/M/D) to Gemini. No file contents. Good for smarter messages without sharing code. |
|
|
113
114
|
| `git-explain --with-diff --ai` | **AI (full diff).** Sends file list **plus** the full diff (staged, unstaged, untracked content) to Gemini. Produces detailed, specific messages (e.g. `feat: add opt-in --with-diff for detailed AI commit messages`). Opt-in; use when you want maximum accuracy and are okay sending diff content to the API. |
|
|
115
|
+
| `git-explain --suggest` | **AI staged-only suggestion mode.** Requires staged changes; sends staged file list + staged diff to Gemini and prints only `git commit -m ...`. It never applies changes and cannot be combined with other flags. |
|
|
114
116
|
|
|
115
117
|
**Summary:** Use plain `git-explain` for speed and privacy. Use `--ai` for better suggestions without sharing code. Use `--with-diff --ai` when you want the most accurate, context-aware messages and accept sending diff content to Gemini.
|
|
116
118
|
|
|
@@ -125,8 +127,9 @@ You’ll see a list of changed files, choose which to include, then get suggeste
|
|
|
125
127
|
| `--ai` | Use Gemini for commit type/message (file paths only). |
|
|
126
128
|
| `--with-diff` | With `--ai`: send full diff to the model for detailed messages. |
|
|
127
129
|
| `--model NAME` | Override Gemini model (e.g. `--model gemini-2.0-flash`). |
|
|
128
|
-
| `--staged-only` | Commit only what’s already staged (no `git add`). |
|
|
130
|
+
| `--staged-only` | Commit only what’s already staged (no `git add`). Always one commit for the whole index—split-by-group mode is disabled, because Git would commit the entire index on the first step and later steps would have nothing left staged. |
|
|
129
131
|
| `--cwd PATH` | Run as if current directory is `PATH`. |
|
|
132
|
+
| `--suggest` | Dedicated staged-only AI suggestion mode; prints only commit command and exits. Cannot be combined with other flags. |
|
|
130
133
|
| `--install-completion [SHELL]` | Install shell completion (`bash`, `zsh`). |
|
|
131
134
|
| `--show-completion [SHELL]` | Print completion script for `SHELL`. |
|
|
132
135
|
|
|
@@ -134,7 +137,7 @@ You’ll see a list of changed files, choose which to include, then get suggeste
|
|
|
134
137
|
|
|
135
138
|
## Workflow
|
|
136
139
|
|
|
137
|
-
1. **Changed files** — Shows staged, unstaged, and untracked files. Untracked
|
|
140
|
+
1. **Changed files** — Shows staged, unstaged, and untracked files. Untracked directories are expanded so you still see per-file paths.
|
|
138
141
|
2. **Select files** — Enter numbers (e.g. `1,2,5-7`), `all`, or a path (e.g. `main.py`, `src/utils/`).
|
|
139
142
|
3. **Commit mode** — If you selected 2+ files: choose `one` (single commit) or `split` (separate commits by docs/tests/config/code).
|
|
140
143
|
4. **Suggested commands** — Panel with `git add` and `git commit` lines.
|
|
@@ -74,13 +74,14 @@ You’ll see a list of changed files, choose which to include, then get suggeste
|
|
|
74
74
|
|
|
75
75
|
---
|
|
76
76
|
|
|
77
|
-
## Modes: `git-explain` vs `git-explain --ai` vs `git-explain --with-diff --ai`
|
|
77
|
+
## Modes: `git-explain` vs `git-explain --ai` vs `git-explain --with-diff --ai` vs `git-explain --suggest`
|
|
78
78
|
|
|
79
79
|
| Command | What it does |
|
|
80
80
|
|---------|--------------|
|
|
81
81
|
| `git-explain` | **Heuristics only.** No API call. Suggests commit type and message from file names and status (e.g. docs, tests, config, code). Fast and private. |
|
|
82
82
|
| `git-explain --ai` | **AI (paths only).** Sends only file paths and statuses (A/M/D) to Gemini. No file contents. Good for smarter messages without sharing code. |
|
|
83
83
|
| `git-explain --with-diff --ai` | **AI (full diff).** Sends file list **plus** the full diff (staged, unstaged, untracked content) to Gemini. Produces detailed, specific messages (e.g. `feat: add opt-in --with-diff for detailed AI commit messages`). Opt-in; use when you want maximum accuracy and are okay sending diff content to the API. |
|
|
84
|
+
| `git-explain --suggest` | **AI staged-only suggestion mode.** Requires staged changes; sends staged file list + staged diff to Gemini and prints only `git commit -m ...`. It never applies changes and cannot be combined with other flags. |
|
|
84
85
|
|
|
85
86
|
**Summary:** Use plain `git-explain` for speed and privacy. Use `--ai` for better suggestions without sharing code. Use `--with-diff --ai` when you want the most accurate, context-aware messages and accept sending diff content to Gemini.
|
|
86
87
|
|
|
@@ -95,8 +96,9 @@ You’ll see a list of changed files, choose which to include, then get suggeste
|
|
|
95
96
|
| `--ai` | Use Gemini for commit type/message (file paths only). |
|
|
96
97
|
| `--with-diff` | With `--ai`: send full diff to the model for detailed messages. |
|
|
97
98
|
| `--model NAME` | Override Gemini model (e.g. `--model gemini-2.0-flash`). |
|
|
98
|
-
| `--staged-only` | Commit only what’s already staged (no `git add`). |
|
|
99
|
+
| `--staged-only` | Commit only what’s already staged (no `git add`). Always one commit for the whole index—split-by-group mode is disabled, because Git would commit the entire index on the first step and later steps would have nothing left staged. |
|
|
99
100
|
| `--cwd PATH` | Run as if current directory is `PATH`. |
|
|
101
|
+
| `--suggest` | Dedicated staged-only AI suggestion mode; prints only commit command and exits. Cannot be combined with other flags. |
|
|
100
102
|
| `--install-completion [SHELL]` | Install shell completion (`bash`, `zsh`). |
|
|
101
103
|
| `--show-completion [SHELL]` | Print completion script for `SHELL`. |
|
|
102
104
|
|
|
@@ -104,7 +106,7 @@ You’ll see a list of changed files, choose which to include, then get suggeste
|
|
|
104
106
|
|
|
105
107
|
## Workflow
|
|
106
108
|
|
|
107
|
-
1. **Changed files** — Shows staged, unstaged, and untracked files. Untracked
|
|
109
|
+
1. **Changed files** — Shows staged, unstaged, and untracked files. Untracked directories are expanded so you still see per-file paths.
|
|
108
110
|
2. **Select files** — Enter numbers (e.g. `1,2,5-7`), `all`, or a path (e.g. `main.py`, `src/utils/`).
|
|
109
111
|
3. **Commit mode** — If you selected 2+ files: choose `one` (single commit) or `split` (separate commits by docs/tests/config/code).
|
|
110
112
|
4. **Suggested commands** — Panel with `git add` and `git commit` lines.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "2.1.4"
|
|
@@ -13,13 +13,19 @@ from rich.text import Text
|
|
|
13
13
|
|
|
14
14
|
from git_explain.gemini import suggest_commands
|
|
15
15
|
from git_explain.heuristics import suggest_from_changes
|
|
16
|
-
from git_explain.git import
|
|
16
|
+
from git_explain.git import (
|
|
17
|
+
get_combined_diff,
|
|
18
|
+
get_diff_for_paths,
|
|
19
|
+
get_staged_diff_for_paths,
|
|
20
|
+
)
|
|
17
21
|
from git_explain.run import apply_commands
|
|
18
22
|
|
|
19
23
|
load_dotenv()
|
|
20
24
|
app = typer.Typer()
|
|
21
25
|
console = Console()
|
|
22
26
|
|
|
27
|
+
_DIFF_INFER_MAX_CHARS = 50_000
|
|
28
|
+
|
|
23
29
|
|
|
24
30
|
@dataclass(frozen=True)
|
|
25
31
|
class Change:
|
|
@@ -158,6 +164,12 @@ def _group_changes(changes: list[tuple[str, str]]) -> dict[str, list[tuple[str,
|
|
|
158
164
|
".gitignore",
|
|
159
165
|
} or p2.endswith((".toml", ".yml", ".yaml", ".json", ".ini", ".cfg", ".lock"))
|
|
160
166
|
|
|
167
|
+
def is_code(p: str) -> bool:
|
|
168
|
+
p2 = p.lower()
|
|
169
|
+
return p2.endswith(
|
|
170
|
+
(".py", ".js", ".ts", ".tsx", ".go", ".rs", ".java", ".rb", ".php", ".cs")
|
|
171
|
+
)
|
|
172
|
+
|
|
161
173
|
groups: dict[str, list[tuple[str, str]]] = {
|
|
162
174
|
"docs": [],
|
|
163
175
|
"tests": [],
|
|
@@ -172,13 +184,42 @@ def _group_changes(changes: list[tuple[str, str]]) -> dict[str, list[tuple[str,
|
|
|
172
184
|
groups["tests"].append((st, p))
|
|
173
185
|
elif is_config(p):
|
|
174
186
|
groups["config"].append((st, p))
|
|
175
|
-
elif p
|
|
187
|
+
elif is_code(p):
|
|
176
188
|
groups["code"].append((st, p))
|
|
177
189
|
else:
|
|
178
190
|
groups["other"].append((st, p))
|
|
179
191
|
return {k: v for k, v in groups.items() if v}
|
|
180
192
|
|
|
181
193
|
|
|
194
|
+
def _validate_suggest_flags(
|
|
195
|
+
*,
|
|
196
|
+
suggest: bool,
|
|
197
|
+
auto: bool,
|
|
198
|
+
ai: bool,
|
|
199
|
+
staged_only: bool,
|
|
200
|
+
model: str | None,
|
|
201
|
+
with_diff: bool,
|
|
202
|
+
) -> None:
|
|
203
|
+
if not suggest:
|
|
204
|
+
return
|
|
205
|
+
bad: list[str] = []
|
|
206
|
+
if auto:
|
|
207
|
+
bad.append("--auto")
|
|
208
|
+
if ai:
|
|
209
|
+
bad.append("--ai")
|
|
210
|
+
if staged_only:
|
|
211
|
+
bad.append("--staged-only")
|
|
212
|
+
if with_diff:
|
|
213
|
+
bad.append("--with-diff")
|
|
214
|
+
if model is not None:
|
|
215
|
+
bad.append("--model")
|
|
216
|
+
if bad:
|
|
217
|
+
raise typer.BadParameter(
|
|
218
|
+
"--suggest is a dedicated mode and cannot be combined with: "
|
|
219
|
+
+ ", ".join(bad)
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
|
|
182
223
|
@app.callback(invoke_without_command=True)
|
|
183
224
|
def main(
|
|
184
225
|
ctx: typer.Context,
|
|
@@ -209,9 +250,22 @@ def main(
|
|
|
209
250
|
"--with-diff",
|
|
210
251
|
help="With --ai: send full diff to the model for detailed, specific commit messages (opt-in).",
|
|
211
252
|
),
|
|
253
|
+
suggest: bool = typer.Option(
|
|
254
|
+
False,
|
|
255
|
+
"--suggest",
|
|
256
|
+
help="AI suggestion-only mode: use staged files + staged diff and print only commit command.",
|
|
257
|
+
),
|
|
212
258
|
) -> None:
|
|
213
259
|
if ctx.invoked_subcommand is not None:
|
|
214
260
|
return
|
|
261
|
+
_validate_suggest_flags(
|
|
262
|
+
suggest=suggest,
|
|
263
|
+
auto=auto,
|
|
264
|
+
ai=ai,
|
|
265
|
+
staged_only=staged_only,
|
|
266
|
+
model=model,
|
|
267
|
+
with_diff=with_diff,
|
|
268
|
+
)
|
|
215
269
|
run(
|
|
216
270
|
cwd=Path(cwd) if cwd else None,
|
|
217
271
|
auto=auto,
|
|
@@ -219,6 +273,7 @@ def main(
|
|
|
219
273
|
staged_only=staged_only,
|
|
220
274
|
model=model,
|
|
221
275
|
with_diff=with_diff,
|
|
276
|
+
suggest=suggest,
|
|
222
277
|
)
|
|
223
278
|
|
|
224
279
|
|
|
@@ -229,6 +284,7 @@ def run(
|
|
|
229
284
|
staged_only: bool = False,
|
|
230
285
|
model: str | None = None,
|
|
231
286
|
with_diff: bool = False,
|
|
287
|
+
suggest: bool = False,
|
|
232
288
|
) -> None:
|
|
233
289
|
console.print(Text("git-explain", style="bold"))
|
|
234
290
|
if with_diff and not ai:
|
|
@@ -253,6 +309,49 @@ def run(
|
|
|
253
309
|
has_commits, changes = _parse_combined(combined)
|
|
254
310
|
console.print(Panel(combined, title="Changed files", border_style="dim"))
|
|
255
311
|
|
|
312
|
+
if suggest:
|
|
313
|
+
staged_changes = [c for c in changes if "Staged" in c.sections]
|
|
314
|
+
if not staged_changes:
|
|
315
|
+
console.print(
|
|
316
|
+
"[yellow]Warning:[/yellow] --suggest requires staged changes. "
|
|
317
|
+
"Stage files first (git add ...), then run --suggest again."
|
|
318
|
+
)
|
|
319
|
+
raise typer.Exit(1)
|
|
320
|
+
selected_pairs = [(ch.status, ch.path) for ch in staged_changes]
|
|
321
|
+
payload = _render_combined(has_commits, selected_pairs, title="Staged")
|
|
322
|
+
paths = [p for _, p in selected_pairs]
|
|
323
|
+
staged_diff = get_staged_diff_for_paths(paths, cwd=repo_root)
|
|
324
|
+
if staged_diff:
|
|
325
|
+
payload = payload + "\n\n## Diff\n" + staged_diff
|
|
326
|
+
infer_diff = (
|
|
327
|
+
staged_diff[:_DIFF_INFER_MAX_CHARS]
|
|
328
|
+
if len(staged_diff) > _DIFF_INFER_MAX_CHARS
|
|
329
|
+
else staged_diff
|
|
330
|
+
)
|
|
331
|
+
try:
|
|
332
|
+
sug, _raw = suggest_commands(
|
|
333
|
+
payload,
|
|
334
|
+
model=None,
|
|
335
|
+
with_diff=True,
|
|
336
|
+
unified_diff_for_infer=infer_diff,
|
|
337
|
+
)
|
|
338
|
+
if sug is None:
|
|
339
|
+
raise RuntimeError("Could not parse AI suggestion.")
|
|
340
|
+
except Exception as e:
|
|
341
|
+
console.print(
|
|
342
|
+
f"[red]Error:[/red] --suggest requires AI and failed to get a suggestion: {e}"
|
|
343
|
+
)
|
|
344
|
+
raise typer.Exit(1)
|
|
345
|
+
|
|
346
|
+
console.print(
|
|
347
|
+
Panel(
|
|
348
|
+
f'git commit -m "[{sug.commit_type}] {sug.commit_message}"',
|
|
349
|
+
title="Suggested commit command",
|
|
350
|
+
border_style="green",
|
|
351
|
+
)
|
|
352
|
+
)
|
|
353
|
+
return
|
|
354
|
+
|
|
256
355
|
if staged_only:
|
|
257
356
|
changes = [c for c in changes if "Staged" in c.sections]
|
|
258
357
|
console.print(
|
|
@@ -325,6 +424,17 @@ def run(
|
|
|
325
424
|
) -> tuple[list[str], str, str, str, str | None]:
|
|
326
425
|
# Returns (paths, type, message, raw_text, ai_fallback_reason).
|
|
327
426
|
# ai_fallback_reason is set when --ai was used but heuristics were used instead.
|
|
427
|
+
paths_for_infer = [p for _, p in change_items]
|
|
428
|
+
infer_diff: str | None = None
|
|
429
|
+
if paths_for_infer:
|
|
430
|
+
raw_d = get_diff_for_paths(paths_for_infer, cwd=repo_root)
|
|
431
|
+
if raw_d.strip():
|
|
432
|
+
infer_diff = (
|
|
433
|
+
raw_d[:_DIFF_INFER_MAX_CHARS]
|
|
434
|
+
if len(raw_d) > _DIFF_INFER_MAX_CHARS
|
|
435
|
+
else raw_d
|
|
436
|
+
)
|
|
437
|
+
|
|
328
438
|
if ai:
|
|
329
439
|
payload = _render_combined(has_commits, change_items, title=title)
|
|
330
440
|
if with_diff:
|
|
@@ -333,13 +443,22 @@ def run(
|
|
|
333
443
|
if diff_text:
|
|
334
444
|
payload = payload + "\n\n## Diff\n" + diff_text
|
|
335
445
|
try:
|
|
336
|
-
sug, raw = suggest_commands(
|
|
446
|
+
sug, raw = suggest_commands(
|
|
447
|
+
payload,
|
|
448
|
+
model=model,
|
|
449
|
+
with_diff=with_diff,
|
|
450
|
+
unified_diff_for_infer=infer_diff,
|
|
451
|
+
)
|
|
337
452
|
if sug is None:
|
|
338
453
|
raise RuntimeError("Could not parse AI suggestion.")
|
|
339
454
|
return sug.add_args, sug.commit_type, sug.commit_message, raw, None
|
|
340
455
|
except Exception as e:
|
|
341
456
|
# Fall back to heuristics on quota / API errors
|
|
342
|
-
h = suggest_from_changes(
|
|
457
|
+
h = suggest_from_changes(
|
|
458
|
+
changes=change_items,
|
|
459
|
+
has_commits=has_commits,
|
|
460
|
+
diff_text=infer_diff,
|
|
461
|
+
)
|
|
343
462
|
return (
|
|
344
463
|
h.add_args,
|
|
345
464
|
h.commit_type,
|
|
@@ -347,7 +466,11 @@ def run(
|
|
|
347
466
|
"",
|
|
348
467
|
str(e),
|
|
349
468
|
)
|
|
350
|
-
h = suggest_from_changes(
|
|
469
|
+
h = suggest_from_changes(
|
|
470
|
+
changes=change_items,
|
|
471
|
+
has_commits=has_commits,
|
|
472
|
+
diff_text=infer_diff,
|
|
473
|
+
)
|
|
351
474
|
return h.add_args, h.commit_type, h.commit_message, "", None
|
|
352
475
|
|
|
353
476
|
selected_pairs = [(ch.status, ch.path) for ch in selected]
|
|
@@ -355,20 +478,25 @@ def run(
|
|
|
355
478
|
|
|
356
479
|
mode = "one"
|
|
357
480
|
if len(unique_paths) > 1:
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
481
|
+
if staged_only:
|
|
482
|
+
console.print(
|
|
483
|
+
"[dim]Note:[/dim] split commits are not available with --staged-only: "
|
|
484
|
+
"each commit would need its own staging, but this mode skips git add. "
|
|
485
|
+
"Using a single commit for everything currently staged."
|
|
486
|
+
)
|
|
487
|
+
else:
|
|
488
|
+
mode_input = (
|
|
489
|
+
typer.prompt("Commit mode: one or split", default="one").strip().lower()
|
|
490
|
+
)
|
|
491
|
+
if mode_input in ("one", "split"):
|
|
492
|
+
mode = mode_input
|
|
363
493
|
|
|
364
494
|
plan: list[tuple[str, list[str], str, str]] = []
|
|
365
495
|
ai_fallback_notes: list[tuple[str, str]] = []
|
|
366
496
|
if mode == "split":
|
|
367
497
|
groups = _group_changes(selected_pairs)
|
|
368
498
|
for gname, items in groups.items():
|
|
369
|
-
paths, ctype, cmsg, _raw, fb = suggest_for(
|
|
370
|
-
items, title=gname.capitalize()
|
|
371
|
-
)
|
|
499
|
+
paths, ctype, cmsg, _raw, fb = suggest_for(items, title=gname.capitalize())
|
|
372
500
|
plan.append((gname, paths, ctype, cmsg))
|
|
373
501
|
if fb:
|
|
374
502
|
ai_fallback_notes.append((gname, fb))
|
|
@@ -473,7 +601,13 @@ def run(
|
|
|
473
601
|
if do_apply:
|
|
474
602
|
for name, paths, ctype, cmsg in plan:
|
|
475
603
|
try:
|
|
476
|
-
apply_commands(
|
|
604
|
+
apply_commands(
|
|
605
|
+
repo_root,
|
|
606
|
+
[] if staged_only else paths,
|
|
607
|
+
ctype,
|
|
608
|
+
cmsg,
|
|
609
|
+
staged_only=staged_only,
|
|
610
|
+
)
|
|
477
611
|
console.print(f"[green]Commit created ({name}).[/green]")
|
|
478
612
|
except subprocess.CalledProcessError as e:
|
|
479
613
|
console.print("[red]git command failed.[/red]")
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Infer FIX-style commits from unified diff text (behavior fixes vs refactors)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def infer_fix_subject_from_diff(diff_text: str | None) -> str | None:
|
|
7
|
+
"""Return a short subject fragment after 'Fix …', or None if no strong signal.
|
|
8
|
+
|
|
9
|
+
Uses high-precision phrases so we do not flip real refactors to FIX.
|
|
10
|
+
"""
|
|
11
|
+
if not diff_text or len(diff_text.strip()) < 12:
|
|
12
|
+
return None
|
|
13
|
+
low = diff_text.lower()
|
|
14
|
+
|
|
15
|
+
if "split commits are not available" in low and (
|
|
16
|
+
"staged-only" in low or "staged_only" in low
|
|
17
|
+
):
|
|
18
|
+
return "staged-only mode with multi-file split commits"
|
|
19
|
+
|
|
20
|
+
if (
|
|
21
|
+
"nothing is currently staged" in low
|
|
22
|
+
and "--staged-only" in low
|
|
23
|
+
and "git add" in low
|
|
24
|
+
):
|
|
25
|
+
return "clearer error when index is empty under --staged-only"
|
|
26
|
+
|
|
27
|
+
infer_signals = (
|
|
28
|
+
"refine_type_and_message_from_diff",
|
|
29
|
+
"infer_fix_subject_from_diff",
|
|
30
|
+
"unified_diff_for_infer",
|
|
31
|
+
"commit_infer.py",
|
|
32
|
+
)
|
|
33
|
+
if sum(1 for s in infer_signals if s in low) >= 2:
|
|
34
|
+
return "commit message classification using unified diffs"
|
|
35
|
+
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def refine_type_and_message_from_diff(
|
|
40
|
+
commit_type: str,
|
|
41
|
+
commit_message: str,
|
|
42
|
+
diff_text: str | None,
|
|
43
|
+
) -> tuple[str, str]:
|
|
44
|
+
"""When diff shows a behavior fix, prefer FIX and a concrete subject.
|
|
45
|
+
|
|
46
|
+
Does not override DOCS, TEST(S), or CHORE. May override REFACTOR or FEAT
|
|
47
|
+
when the diff matches known bugfix patterns.
|
|
48
|
+
"""
|
|
49
|
+
ct = (commit_type or "").upper()
|
|
50
|
+
if ct in ("DOCS", "TEST", "TESTS", "CHORE"):
|
|
51
|
+
return commit_type, commit_message
|
|
52
|
+
|
|
53
|
+
subject = infer_fix_subject_from_diff(diff_text)
|
|
54
|
+
if not subject:
|
|
55
|
+
return commit_type, commit_message
|
|
56
|
+
|
|
57
|
+
if ct == "FIX":
|
|
58
|
+
msg = (commit_message or "").strip()
|
|
59
|
+
if len(msg) < 8 or msg.lower() in {"fix", "fixes", "bugfix", "bug fix"}:
|
|
60
|
+
return "FIX", f"Fix {subject}"
|
|
61
|
+
return commit_type, commit_message
|
|
62
|
+
|
|
63
|
+
if ct in ("REFACTOR", "FEAT"):
|
|
64
|
+
return "FIX", f"Fix {subject}"
|
|
65
|
+
|
|
66
|
+
return commit_type, commit_message
|
|
@@ -8,6 +8,7 @@ from dataclasses import dataclass
|
|
|
8
8
|
from google import genai
|
|
9
9
|
from google.genai import types
|
|
10
10
|
|
|
11
|
+
from git_explain.commit_infer import refine_type_and_message_from_diff
|
|
11
12
|
from git_explain.path_topics import (
|
|
12
13
|
area_scope_suffix,
|
|
13
14
|
basename_fallback_topic,
|
|
@@ -31,7 +32,8 @@ Rules:
|
|
|
31
32
|
2. Line 2 must be: git commit -m "[TYPE] Message" with TYPE one of: FEAT, FIX, DOCS, REFACTOR, TEST, CHORE.
|
|
32
33
|
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").
|
|
33
34
|
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
|
|
35
|
+
5. Use [FIX] (or "fix:" with --with-diff) when the change corrects broken behavior, wrong CLI flow, or misleading errors—not [REFACTOR] for those cases.
|
|
36
|
+
6. Use imperative, no period at end. Maximum one short line.
|
|
35
37
|
|
|
36
38
|
Example for files README.md, FEATURES.md, git_explain/gemini.py:
|
|
37
39
|
git add README.md FEATURES.md git_explain/gemini.py
|
|
@@ -48,6 +50,7 @@ SYSTEM_PROMPT_WITH_DIFF = """You are given:
|
|
|
48
50
|
|
|
49
51
|
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
52
|
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.
|
|
53
|
+
Prefer **fix:** when the diff corrects incorrect behavior or user-visible bugs; use **refactor:** only for internal restructuring without behavior change.
|
|
51
54
|
|
|
52
55
|
Output format (conventional commits style):
|
|
53
56
|
- Line 1: git add <path1> <path2> ... with EVERY path from the file list. Do not omit any.
|
|
@@ -110,6 +113,65 @@ _GENERIC_MESSAGES = {
|
|
|
110
113
|
"misc",
|
|
111
114
|
}
|
|
112
115
|
|
|
116
|
+
CODE_EXTS = {".py", ".js", ".ts", ".tsx", ".go", ".rs", ".java", ".rb", ".php", ".cs"}
|
|
117
|
+
_WEAK_TOPIC_WORDS = {
|
|
118
|
+
"project",
|
|
119
|
+
"projects",
|
|
120
|
+
"repo",
|
|
121
|
+
"repository",
|
|
122
|
+
"codebase",
|
|
123
|
+
"code",
|
|
124
|
+
"app",
|
|
125
|
+
"apps",
|
|
126
|
+
"service",
|
|
127
|
+
"services",
|
|
128
|
+
"package",
|
|
129
|
+
"packages",
|
|
130
|
+
"module",
|
|
131
|
+
"modules",
|
|
132
|
+
"library",
|
|
133
|
+
"libraries",
|
|
134
|
+
"cli",
|
|
135
|
+
"tool",
|
|
136
|
+
"tools",
|
|
137
|
+
"git",
|
|
138
|
+
"explain",
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _code_topics(files: list[str]) -> list[str]:
|
|
143
|
+
labeled: list[tuple[str, str]] = [] # (folder_label, stem)
|
|
144
|
+
for p in files:
|
|
145
|
+
p2 = p.replace("\\", "/")
|
|
146
|
+
base = os.path.basename(p2)
|
|
147
|
+
ext = os.path.splitext(base)[1].lower()
|
|
148
|
+
if ext not in CODE_EXTS:
|
|
149
|
+
continue
|
|
150
|
+
stem = os.path.splitext(base)[0].replace("_", " ")
|
|
151
|
+
parts = [x for x in p2.split("/") if x]
|
|
152
|
+
folder = parts[-2] if len(parts) >= 2 else stem
|
|
153
|
+
labeled.append((folder.replace("_", " "), stem))
|
|
154
|
+
|
|
155
|
+
if not labeled:
|
|
156
|
+
return []
|
|
157
|
+
|
|
158
|
+
folder_set = {f.lower() for f, _ in labeled}
|
|
159
|
+
prefer_stems = len(folder_set) == 1 and len(labeled) >= 2
|
|
160
|
+
|
|
161
|
+
topics: list[str] = []
|
|
162
|
+
seen: set[str] = set()
|
|
163
|
+
for folder, stem in labeled:
|
|
164
|
+
label = stem if prefer_stems else folder
|
|
165
|
+
key = label.lower()
|
|
166
|
+
if key not in seen:
|
|
167
|
+
seen.add(key)
|
|
168
|
+
topics.append(label)
|
|
169
|
+
return topics
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _alnum_key(s: str) -> str:
|
|
173
|
+
return re.sub(r"[^a-z0-9]+", "", (s or "").lower())
|
|
174
|
+
|
|
113
175
|
|
|
114
176
|
def _is_generic_message(message: str) -> bool:
|
|
115
177
|
msg = (message or "").strip().lower()
|
|
@@ -120,12 +182,17 @@ def _is_generic_message(message: str) -> bool:
|
|
|
120
182
|
if _VAGUE_VERB_NOUN.match(msg):
|
|
121
183
|
return True
|
|
122
184
|
parts = msg.split()
|
|
123
|
-
if
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
185
|
+
if (
|
|
186
|
+
len(parts) == 2
|
|
187
|
+
and parts[0]
|
|
188
|
+
in (
|
|
189
|
+
"add",
|
|
190
|
+
"update",
|
|
191
|
+
"modify",
|
|
192
|
+
"make",
|
|
193
|
+
)
|
|
194
|
+
and parts[1] in ("changes", "change", "updates", "update", "files", "file")
|
|
195
|
+
):
|
|
129
196
|
return True
|
|
130
197
|
if len(parts) >= 2 and parts[0] in (
|
|
131
198
|
"add",
|
|
@@ -136,6 +203,27 @@ def _is_generic_message(message: str) -> bool:
|
|
|
136
203
|
tail = " ".join(parts[1:]).strip()
|
|
137
204
|
if tail in _VAGUE_TAIL_AFTER_VERB:
|
|
138
205
|
return True
|
|
206
|
+
if len(parts) >= 2 and parts[0] in ("add", "update", "modify", "make"):
|
|
207
|
+
tail_words = re.findall(r"[a-z0-9]+", " ".join(parts[1:]))
|
|
208
|
+
if (
|
|
209
|
+
tail_words
|
|
210
|
+
and len(tail_words) <= 2
|
|
211
|
+
and all(w in _WEAK_TOPIC_WORDS for w in tail_words)
|
|
212
|
+
):
|
|
213
|
+
return True
|
|
214
|
+
# Catch category-heavy but still vague summaries such as:
|
|
215
|
+
# "Update README, docs, and CLI for project"
|
|
216
|
+
if "readme" in msg and ("docs" in msg or "documentation" in msg):
|
|
217
|
+
if " for " in msg and any(k in msg for k in ("cli", "project", "codebase")):
|
|
218
|
+
return True
|
|
219
|
+
if msg.endswith(" for project") or msg.endswith(" for codebase"):
|
|
220
|
+
return True
|
|
221
|
+
m_for = re.match(r"^(add|update|modify|make)\s+(.+?)\s+for\s+(.+)$", msg)
|
|
222
|
+
if m_for:
|
|
223
|
+
left = _alnum_key(m_for.group(2))
|
|
224
|
+
right = _alnum_key(m_for.group(3))
|
|
225
|
+
if left and right and (left == right or left in right or right in left):
|
|
226
|
+
return True
|
|
139
227
|
# "update X" is okay, but bare "update" or "update stuff" isn't
|
|
140
228
|
if re.fullmatch(
|
|
141
229
|
r"(update|updates|change|changes|refactor|refactoring|misc)(\s+.+)?", msg
|
|
@@ -181,7 +269,9 @@ def _fallback_type_and_message_with_context(
|
|
|
181
269
|
touches_docs = any(is_doc(f) for f in lower)
|
|
182
270
|
touches_packaging = any(is_packaging(f) for f in lower)
|
|
183
271
|
|
|
184
|
-
|
|
272
|
+
# In fallback we don't have per-file status detail here, so use "Add" only
|
|
273
|
+
# for initial commit. Otherwise prefer "Update" to avoid overclaiming.
|
|
274
|
+
verb = "Add" if (has_commits is False) else "Update"
|
|
185
275
|
|
|
186
276
|
all_test_paths = bool(files) and all(is_test_path(f) for f in files)
|
|
187
277
|
|
|
@@ -212,16 +302,12 @@ def _fallback_type_and_message_with_context(
|
|
|
212
302
|
topics.append("tests")
|
|
213
303
|
if touches_docs and not docs_only:
|
|
214
304
|
topics.append("docs")
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
topics.append(
|
|
221
|
-
if any("git_explain/git.py" in f for f in lower):
|
|
222
|
-
topics.append("change detection")
|
|
223
|
-
if any("git_explain/cli.py" in f for f in lower):
|
|
224
|
-
topics.append("CLI output")
|
|
305
|
+
code_topics = _code_topics(files)
|
|
306
|
+
if code_topics:
|
|
307
|
+
label = ", ".join(code_topics[:4])
|
|
308
|
+
if len(code_topics) > 4:
|
|
309
|
+
label += f" (+{len(code_topics) - 4} more)"
|
|
310
|
+
topics.append(label)
|
|
225
311
|
if touches_packaging:
|
|
226
312
|
topics.append("packaging config")
|
|
227
313
|
|
|
@@ -240,12 +326,22 @@ def _fallback_type_and_message_with_context(
|
|
|
240
326
|
else:
|
|
241
327
|
msg = f"{verb} {topics[0]}, {topics[1]}, and {topics[2]}"
|
|
242
328
|
|
|
243
|
-
|
|
329
|
+
scope = area_scope_suffix(files)
|
|
330
|
+
if scope:
|
|
331
|
+
scope_key = _alnum_key(scope.replace("for", "", 1))
|
|
332
|
+
msg_key = _alnum_key(msg)
|
|
333
|
+
if scope_key and scope_key not in msg_key:
|
|
334
|
+
msg += scope
|
|
244
335
|
|
|
245
336
|
if verb == "Add" and (has_commits is False):
|
|
246
337
|
# Make initial commits a little clearer but still "Add …"
|
|
247
338
|
msg = msg.replace("Add ", "Add initial ", 1) if msg.startswith("Add ") else msg
|
|
248
339
|
|
|
340
|
+
if _is_generic_message(msg):
|
|
341
|
+
fb = basename_fallback_topic(files)
|
|
342
|
+
if fb:
|
|
343
|
+
msg = f"{verb} {fb}"
|
|
344
|
+
|
|
249
345
|
msg = msg.strip().rstrip(".")
|
|
250
346
|
if len(msg) > 72:
|
|
251
347
|
msg = msg[:72].rstrip()
|
|
@@ -295,9 +391,18 @@ def _get_client() -> genai.Client:
|
|
|
295
391
|
|
|
296
392
|
|
|
297
393
|
def suggest_commands(
|
|
298
|
-
diff: str,
|
|
394
|
+
diff: str,
|
|
395
|
+
model: str | None = None,
|
|
396
|
+
with_diff: bool = False,
|
|
397
|
+
*,
|
|
398
|
+
unified_diff_for_infer: str | None = None,
|
|
299
399
|
) -> tuple[Suggestion | None, str]:
|
|
300
|
-
"""Call Gemini with the file list (and optionally full diff); return (suggestion, raw_response). suggestion is None if unparseable.
|
|
400
|
+
"""Call Gemini with the file list (and optionally full diff); return (suggestion, raw_response). suggestion is None if unparseable.
|
|
401
|
+
|
|
402
|
+
``unified_diff_for_infer`` optional text (staged+unstaged unified diff) used to
|
|
403
|
+
refine REFACTOR/FEAT into FIX when the diff matches behavior-fix patterns
|
|
404
|
+
(e.g. ``--staged-only``), including when ``with_diff`` is False.
|
|
405
|
+
"""
|
|
301
406
|
if not diff or not diff.strip():
|
|
302
407
|
return None, ""
|
|
303
408
|
model = model or os.environ.get("GEMINI_MODEL") or DEFAULT_MODEL
|
|
@@ -393,6 +498,14 @@ def suggest_commands(
|
|
|
393
498
|
commit_type, commit_message = _fallback_type_and_message_with_context(
|
|
394
499
|
files=add_args, added_any=added_any, has_commits=has_commits
|
|
395
500
|
)
|
|
501
|
+
|
|
502
|
+
infer_body = unified_diff_for_infer
|
|
503
|
+
if not (infer_body and infer_body.strip()) and with_diff and "\n## Diff" in diff:
|
|
504
|
+
infer_body = diff.split("\n## Diff", 1)[1]
|
|
505
|
+
commit_type, commit_message = refine_type_and_message_from_diff(
|
|
506
|
+
commit_type, commit_message, infer_body
|
|
507
|
+
)
|
|
508
|
+
|
|
396
509
|
return Suggestion(
|
|
397
510
|
add_args=add_args, commit_type=commit_type, commit_message=commit_message
|
|
398
511
|
), raw
|
|
@@ -168,3 +168,19 @@ def get_diff_for_paths(paths: list[str], cwd: str | Path | None = None) -> str:
|
|
|
168
168
|
parts.append(f"## Untracked (new file): {p}\n<binary or unreadable>")
|
|
169
169
|
|
|
170
170
|
return "\n\n".join(parts)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def get_staged_diff_for_paths(paths: list[str], cwd: str | Path | None = None) -> str:
|
|
174
|
+
"""Return staged-only unified diff for the given paths."""
|
|
175
|
+
if not paths:
|
|
176
|
+
return ""
|
|
177
|
+
root = get_repo_root(cwd)
|
|
178
|
+
result = subprocess.run(
|
|
179
|
+
["git", "diff", "--cached", "--"] + paths,
|
|
180
|
+
capture_output=True,
|
|
181
|
+
text=True,
|
|
182
|
+
cwd=root,
|
|
183
|
+
)
|
|
184
|
+
if result.returncode == 0 and result.stdout.strip():
|
|
185
|
+
return result.stdout.strip()
|
|
186
|
+
return ""
|
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import os
|
|
6
|
+
import re
|
|
6
7
|
|
|
8
|
+
from git_explain.commit_infer import refine_type_and_message_from_diff
|
|
7
9
|
from git_explain.gemini import Suggestion
|
|
8
10
|
from git_explain.path_topics import (
|
|
9
11
|
area_scope_suffix,
|
|
@@ -27,6 +29,7 @@ CONFIG_FILES = {
|
|
|
27
29
|
"license.md",
|
|
28
30
|
}
|
|
29
31
|
CONFIG_EXTS = {".toml", ".yml", ".yaml", ".json", ".ini", ".cfg", ".lock"}
|
|
32
|
+
CODE_EXTS = {".py", ".js", ".ts", ".tsx", ".go", ".rs", ".java", ".rb", ".php", ".cs"}
|
|
30
33
|
|
|
31
34
|
|
|
32
35
|
def _is_doc(path: str) -> bool:
|
|
@@ -50,14 +53,50 @@ def _is_config(path: str) -> bool:
|
|
|
50
53
|
return _is_plain_config(path) or is_infra_deploy_path(path)
|
|
51
54
|
|
|
52
55
|
|
|
56
|
+
def _code_topics(paths: list[str]) -> list[str]:
|
|
57
|
+
labeled: list[tuple[str, str]] = [] # (folder_label, stem)
|
|
58
|
+
for p in paths:
|
|
59
|
+
p2 = p.replace("\\", "/")
|
|
60
|
+
base = os.path.basename(p2)
|
|
61
|
+
ext = os.path.splitext(base)[1].lower()
|
|
62
|
+
if ext not in CODE_EXTS:
|
|
63
|
+
continue
|
|
64
|
+
stem = os.path.splitext(base)[0].replace("_", " ")
|
|
65
|
+
parts = [x for x in p2.split("/") if x]
|
|
66
|
+
folder = parts[-2] if len(parts) >= 2 else stem
|
|
67
|
+
labeled.append((folder.replace("_", " "), stem))
|
|
68
|
+
|
|
69
|
+
if not labeled:
|
|
70
|
+
return []
|
|
71
|
+
|
|
72
|
+
folder_set = {f.lower() for f, _ in labeled}
|
|
73
|
+
prefer_stems = len(folder_set) == 1 and len(labeled) >= 2
|
|
74
|
+
|
|
75
|
+
topics: list[str] = []
|
|
76
|
+
seen: set[str] = set()
|
|
77
|
+
for folder, stem in labeled:
|
|
78
|
+
label = stem if prefer_stems else folder
|
|
79
|
+
key = label.lower()
|
|
80
|
+
if key not in seen:
|
|
81
|
+
seen.add(key)
|
|
82
|
+
topics.append(label)
|
|
83
|
+
return topics
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _alnum_key(s: str) -> str:
|
|
87
|
+
return re.sub(r"[^a-z0-9]+", "", (s or "").lower())
|
|
88
|
+
|
|
89
|
+
|
|
53
90
|
def suggest_from_changes(
|
|
54
91
|
*,
|
|
55
92
|
changes: list[tuple[str, str]],
|
|
56
93
|
has_commits: bool | None,
|
|
94
|
+
diff_text: str | None = None,
|
|
57
95
|
) -> Suggestion:
|
|
58
96
|
"""Create a Suggestion from [(status, path)] without calling AI."""
|
|
59
97
|
paths = [p for _, p in changes]
|
|
60
98
|
added_any = any(s.upper() == "A" for s, _ in changes) or has_commits is False
|
|
99
|
+
modified_any = any(s.upper() == "M" for s, _ in changes)
|
|
61
100
|
|
|
62
101
|
docs = [p for p in paths if _is_doc(p)]
|
|
63
102
|
tests = [p for p in paths if is_test_path(p)]
|
|
@@ -72,7 +111,12 @@ def suggest_from_changes(
|
|
|
72
111
|
tc = len([p for p in non_docs if p in tests or p in configs])
|
|
73
112
|
mostly_tests_or_config = tc / max(1, len(non_docs)) >= 0.6
|
|
74
113
|
|
|
75
|
-
|
|
114
|
+
if has_commits is False:
|
|
115
|
+
verb = "Add"
|
|
116
|
+
elif added_any and not modified_any:
|
|
117
|
+
verb = "Add"
|
|
118
|
+
else:
|
|
119
|
+
verb = "Update"
|
|
76
120
|
|
|
77
121
|
if docs_only:
|
|
78
122
|
commit_type = "DOCS"
|
|
@@ -105,8 +149,12 @@ def suggest_from_changes(
|
|
|
105
149
|
topics.append("tests")
|
|
106
150
|
if any(_is_plain_config(p) for p in paths):
|
|
107
151
|
topics.append("config")
|
|
108
|
-
|
|
109
|
-
|
|
152
|
+
code_topics = _code_topics(paths)
|
|
153
|
+
if code_topics:
|
|
154
|
+
label = ", ".join(code_topics[:4])
|
|
155
|
+
if len(code_topics) > 4:
|
|
156
|
+
label += f" (+{len(code_topics) - 4} more)"
|
|
157
|
+
topics.append(label)
|
|
110
158
|
|
|
111
159
|
# Dedupe while preserving order
|
|
112
160
|
seen: set[str] = set()
|
|
@@ -123,7 +171,12 @@ def suggest_from_changes(
|
|
|
123
171
|
else:
|
|
124
172
|
message = f"{verb} {topics[0]}, {topics[1]}, and {topics[2]}"
|
|
125
173
|
|
|
126
|
-
|
|
174
|
+
scope = area_scope_suffix(paths)
|
|
175
|
+
if scope:
|
|
176
|
+
scope_key = _alnum_key(scope.replace("for", "", 1))
|
|
177
|
+
msg_key = _alnum_key(message)
|
|
178
|
+
if scope_key and scope_key not in msg_key:
|
|
179
|
+
message += scope
|
|
127
180
|
|
|
128
181
|
if added_any and has_commits is False and message.startswith("Add "):
|
|
129
182
|
message = message.replace("Add ", "Add initial ", 1)
|
|
@@ -131,4 +184,8 @@ def suggest_from_changes(
|
|
|
131
184
|
if len(message) > 72:
|
|
132
185
|
message = message[:72].rstrip()
|
|
133
186
|
|
|
187
|
+
commit_type, message = refine_type_and_message_from_diff(
|
|
188
|
+
commit_type, message, diff_text
|
|
189
|
+
)
|
|
190
|
+
|
|
134
191
|
return Suggestion(add_args=paths, commit_type=commit_type, commit_message=message)
|
|
@@ -27,11 +27,17 @@ def apply_commands(
|
|
|
27
27
|
add_args: list[str],
|
|
28
28
|
commit_type: str,
|
|
29
29
|
commit_message: str,
|
|
30
|
+
*,
|
|
31
|
+
staged_only: bool = False,
|
|
30
32
|
) -> None:
|
|
31
33
|
"""Stage selected paths and commit. Raises on failure.
|
|
32
34
|
|
|
33
35
|
Uses `git add -A -- <paths...>` to properly handle deletes/renames.
|
|
34
36
|
Verifies that something is staged before attempting the commit.
|
|
37
|
+
|
|
38
|
+
When ``staged_only`` is True, ``git add`` is skipped (``add_args`` should be
|
|
39
|
+
empty); the current index is committed as-is. Split multi-commit plans are
|
|
40
|
+
not supported in that mode because each ``git commit`` empties the index.
|
|
35
41
|
"""
|
|
36
42
|
root = Path(repo_root)
|
|
37
43
|
if add_args:
|
|
@@ -43,6 +49,11 @@ def apply_commands(
|
|
|
43
49
|
text=True,
|
|
44
50
|
)
|
|
45
51
|
if not _has_staged_changes(root):
|
|
52
|
+
if staged_only:
|
|
53
|
+
raise RuntimeError(
|
|
54
|
+
"Nothing is currently staged. With --staged-only, git-explain does "
|
|
55
|
+
"not run git add; stage your changes first, then try again."
|
|
56
|
+
)
|
|
46
57
|
raise RuntimeError("Nothing staged after git add; aborting commit.")
|
|
47
58
|
full_message = f"[{commit_type}] {commit_message}"
|
|
48
59
|
subprocess.run(
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: git-explain
|
|
3
|
-
Version:
|
|
3
|
+
Version: 2.1.5
|
|
4
4
|
Summary: CLI that suggests git add/commit from diffs using Gemini
|
|
5
5
|
Author: git-explain contributors
|
|
6
6
|
License-Expression: MIT
|
|
@@ -26,6 +26,7 @@ Requires-Dist: python-dotenv>=1.0.0
|
|
|
26
26
|
Requires-Dist: prompt_toolkit>=3.0.0
|
|
27
27
|
Provides-Extra: dev
|
|
28
28
|
Requires-Dist: pytest>=8.0.0; extra == "dev"
|
|
29
|
+
Requires-Dist: ruff>=0.8.0; extra == "dev"
|
|
29
30
|
Dynamic: license-file
|
|
30
31
|
|
|
31
32
|
# git-explain
|
|
@@ -104,13 +105,14 @@ You’ll see a list of changed files, choose which to include, then get suggeste
|
|
|
104
105
|
|
|
105
106
|
---
|
|
106
107
|
|
|
107
|
-
## Modes: `git-explain` vs `git-explain --ai` vs `git-explain --with-diff --ai`
|
|
108
|
+
## Modes: `git-explain` vs `git-explain --ai` vs `git-explain --with-diff --ai` vs `git-explain --suggest`
|
|
108
109
|
|
|
109
110
|
| Command | What it does |
|
|
110
111
|
|---------|--------------|
|
|
111
112
|
| `git-explain` | **Heuristics only.** No API call. Suggests commit type and message from file names and status (e.g. docs, tests, config, code). Fast and private. |
|
|
112
113
|
| `git-explain --ai` | **AI (paths only).** Sends only file paths and statuses (A/M/D) to Gemini. No file contents. Good for smarter messages without sharing code. |
|
|
113
114
|
| `git-explain --with-diff --ai` | **AI (full diff).** Sends file list **plus** the full diff (staged, unstaged, untracked content) to Gemini. Produces detailed, specific messages (e.g. `feat: add opt-in --with-diff for detailed AI commit messages`). Opt-in; use when you want maximum accuracy and are okay sending diff content to the API. |
|
|
115
|
+
| `git-explain --suggest` | **AI staged-only suggestion mode.** Requires staged changes; sends staged file list + staged diff to Gemini and prints only `git commit -m ...`. It never applies changes and cannot be combined with other flags. |
|
|
114
116
|
|
|
115
117
|
**Summary:** Use plain `git-explain` for speed and privacy. Use `--ai` for better suggestions without sharing code. Use `--with-diff --ai` when you want the most accurate, context-aware messages and accept sending diff content to Gemini.
|
|
116
118
|
|
|
@@ -125,8 +127,9 @@ You’ll see a list of changed files, choose which to include, then get suggeste
|
|
|
125
127
|
| `--ai` | Use Gemini for commit type/message (file paths only). |
|
|
126
128
|
| `--with-diff` | With `--ai`: send full diff to the model for detailed messages. |
|
|
127
129
|
| `--model NAME` | Override Gemini model (e.g. `--model gemini-2.0-flash`). |
|
|
128
|
-
| `--staged-only` | Commit only what’s already staged (no `git add`). |
|
|
130
|
+
| `--staged-only` | Commit only what’s already staged (no `git add`). Always one commit for the whole index—split-by-group mode is disabled, because Git would commit the entire index on the first step and later steps would have nothing left staged. |
|
|
129
131
|
| `--cwd PATH` | Run as if current directory is `PATH`. |
|
|
132
|
+
| `--suggest` | Dedicated staged-only AI suggestion mode; prints only commit command and exits. Cannot be combined with other flags. |
|
|
130
133
|
| `--install-completion [SHELL]` | Install shell completion (`bash`, `zsh`). |
|
|
131
134
|
| `--show-completion [SHELL]` | Print completion script for `SHELL`. |
|
|
132
135
|
|
|
@@ -134,7 +137,7 @@ You’ll see a list of changed files, choose which to include, then get suggeste
|
|
|
134
137
|
|
|
135
138
|
## Workflow
|
|
136
139
|
|
|
137
|
-
1. **Changed files** — Shows staged, unstaged, and untracked files. Untracked
|
|
140
|
+
1. **Changed files** — Shows staged, unstaged, and untracked files. Untracked directories are expanded so you still see per-file paths.
|
|
138
141
|
2. **Select files** — Enter numbers (e.g. `1,2,5-7`), `all`, or a path (e.g. `main.py`, `src/utils/`).
|
|
139
142
|
3. **Commit mode** — If you selected 2+ files: choose `one` (single commit) or `split` (separate commits by docs/tests/config/code).
|
|
140
143
|
4. **Suggested commands** — Panel with `git add` and `git commit` lines.
|
|
@@ -3,6 +3,7 @@ README.md
|
|
|
3
3
|
pyproject.toml
|
|
4
4
|
git_explain/__init__.py
|
|
5
5
|
git_explain/cli.py
|
|
6
|
+
git_explain/commit_infer.py
|
|
6
7
|
git_explain/gemini.py
|
|
7
8
|
git_explain/git.py
|
|
8
9
|
git_explain/heuristics.py
|
|
@@ -15,6 +16,7 @@ git_explain.egg-info/entry_points.txt
|
|
|
15
16
|
git_explain.egg-info/requires.txt
|
|
16
17
|
git_explain.egg-info/top_level.txt
|
|
17
18
|
tests/test_cli_utils.py
|
|
19
|
+
tests/test_commit_infer.py
|
|
18
20
|
tests/test_gemini.py
|
|
19
21
|
tests/test_heuristics.py
|
|
20
22
|
tests/test_run_apply.py
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "git-explain"
|
|
7
|
-
version = "
|
|
7
|
+
version = "v2.1.5"
|
|
8
8
|
description = "CLI that suggests git add/commit from diffs using Gemini"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -36,6 +36,7 @@ dependencies = [
|
|
|
36
36
|
[project.optional-dependencies]
|
|
37
37
|
dev = [
|
|
38
38
|
"pytest>=8.0.0",
|
|
39
|
+
"ruff>=0.8.0",
|
|
39
40
|
]
|
|
40
41
|
|
|
41
42
|
[project.scripts]
|
|
@@ -1,8 +1,11 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
1
3
|
from git_explain.cli import (
|
|
2
4
|
_group_changes,
|
|
3
5
|
_parse_combined,
|
|
4
6
|
_parse_selection,
|
|
5
7
|
_ps_quote,
|
|
8
|
+
_validate_suggest_flags,
|
|
6
9
|
)
|
|
7
10
|
|
|
8
11
|
|
|
@@ -114,6 +117,30 @@ def test_group_changes_config_patterns() -> None:
|
|
|
114
117
|
|
|
115
118
|
|
|
116
119
|
def test_group_changes_code_bucket() -> None:
|
|
117
|
-
changes = [("M", "
|
|
120
|
+
changes = [("M", "src/app.ts")]
|
|
118
121
|
groups = _group_changes(changes)
|
|
119
|
-
assert groups["code"] == [("M", "
|
|
122
|
+
assert groups["code"] == [("M", "src/app.ts")]
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def test_validate_suggest_flags_allows_suggest_alone() -> None:
|
|
126
|
+
_validate_suggest_flags(
|
|
127
|
+
suggest=True,
|
|
128
|
+
auto=False,
|
|
129
|
+
ai=False,
|
|
130
|
+
staged_only=False,
|
|
131
|
+
model=None,
|
|
132
|
+
with_diff=False,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def test_validate_suggest_flags_rejects_combined_flags() -> None:
|
|
137
|
+
with pytest.raises(Exception) as ex:
|
|
138
|
+
_validate_suggest_flags(
|
|
139
|
+
suggest=True,
|
|
140
|
+
auto=True,
|
|
141
|
+
ai=True,
|
|
142
|
+
staged_only=False,
|
|
143
|
+
model="gemini-2.5-flash",
|
|
144
|
+
with_diff=False,
|
|
145
|
+
)
|
|
146
|
+
assert "--suggest is a dedicated mode" in str(ex.value)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
from git_explain.commit_infer import (
|
|
2
|
+
infer_fix_subject_from_diff,
|
|
3
|
+
refine_type_and_message_from_diff,
|
|
4
|
+
)
|
|
5
|
+
from git_explain.heuristics import suggest_from_changes
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_infer_fix_subject_staged_only_split() -> None:
|
|
9
|
+
diff = """
|
|
10
|
+
+ if staged_only:
|
|
11
|
+
+ console.print(
|
|
12
|
+
+ "split commits are not available with --staged-only"
|
|
13
|
+
+ )
|
|
14
|
+
"""
|
|
15
|
+
assert infer_fix_subject_from_diff(diff) is not None
|
|
16
|
+
assert "staged-only" in (infer_fix_subject_from_diff(diff) or "").lower()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_infer_fix_subject_commit_classification_helpers_in_diff() -> None:
|
|
20
|
+
diff = """
|
|
21
|
+
diff --git a/git_explain/commit_infer.py b/git_explain/commit_infer.py
|
|
22
|
+
+def refine_type_and_message_from_diff
|
|
23
|
+
+def infer_fix_subject_from_diff
|
|
24
|
+
"""
|
|
25
|
+
subj = infer_fix_subject_from_diff(diff)
|
|
26
|
+
assert subj is not None
|
|
27
|
+
assert "diff" in subj.lower() or "classification" in subj.lower()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_infer_fix_subject_empty_index_message() -> None:
|
|
31
|
+
diff = """
|
|
32
|
+
+ raise RuntimeError(
|
|
33
|
+
+ "Nothing is currently staged. With --staged-only, git-explain does "
|
|
34
|
+
+ "not run git add; stage your changes first, then try again."
|
|
35
|
+
+ )
|
|
36
|
+
"""
|
|
37
|
+
assert infer_fix_subject_from_diff(diff) is not None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_refine_refactor_to_fix() -> None:
|
|
41
|
+
diff = "split commits are not available with --staged-only"
|
|
42
|
+
ct, msg = refine_type_and_message_from_diff(
|
|
43
|
+
"REFACTOR", "Update git-explain CLI", diff
|
|
44
|
+
)
|
|
45
|
+
assert ct == "FIX"
|
|
46
|
+
assert "staged-only" in msg.lower()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_refine_does_not_override_docs() -> None:
|
|
50
|
+
diff = "split commits are not available with --staged-only"
|
|
51
|
+
ct, msg = refine_type_and_message_from_diff("DOCS", "Update README", diff)
|
|
52
|
+
assert ct == "DOCS"
|
|
53
|
+
assert msg == "Update README"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_suggest_from_changes_with_staged_only_diff() -> None:
|
|
57
|
+
diff = """
|
|
58
|
+
## Unstaged diff
|
|
59
|
+
diff --git a/git_explain/cli.py b/git_explain/cli.py
|
|
60
|
+
+ if staged_only:
|
|
61
|
+
+ "split commits are not available with --staged-only"
|
|
62
|
+
"""
|
|
63
|
+
s = suggest_from_changes(
|
|
64
|
+
changes=[
|
|
65
|
+
("M", "git_explain/cli.py"),
|
|
66
|
+
("M", "git_explain/run.py"),
|
|
67
|
+
],
|
|
68
|
+
has_commits=True,
|
|
69
|
+
diff_text=diff,
|
|
70
|
+
)
|
|
71
|
+
assert s.commit_type == "FIX"
|
|
72
|
+
assert "staged-only" in s.commit_message.lower()
|
|
@@ -62,6 +62,21 @@ def test_is_generic_message_flags_update_project_files() -> None:
|
|
|
62
62
|
assert _is_generic_message("Update tests for gemini and heuristics") is False
|
|
63
63
|
|
|
64
64
|
|
|
65
|
+
def test_is_generic_message_flags_readme_docs_cli_combo() -> None:
|
|
66
|
+
msg = "Update README, docs, and CLI for project"
|
|
67
|
+
assert _is_generic_message(msg) is True
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_is_generic_message_flags_for_clause_with_same_topic() -> None:
|
|
71
|
+
msg = "Update git explain for git_explain"
|
|
72
|
+
assert _is_generic_message(msg) is True
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_is_generic_message_flags_update_git_explain() -> None:
|
|
76
|
+
assert _is_generic_message("Update git explain") is True
|
|
77
|
+
assert _is_generic_message("Update project CLI") is True
|
|
78
|
+
|
|
79
|
+
|
|
65
80
|
def test_fallback_uses_test_hints_for_test_files() -> None:
|
|
66
81
|
ctype, msg = _fallback_type_and_message_with_context(
|
|
67
82
|
files=["tests/test_gemini.py", "tests/test_heuristics.py"],
|
|
@@ -71,3 +86,45 @@ def test_fallback_uses_test_hints_for_test_files() -> None:
|
|
|
71
86
|
assert ctype == "TEST"
|
|
72
87
|
assert "gemini" in msg.lower()
|
|
73
88
|
assert "heuristics" in msg.lower()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_fallback_uses_generic_code_topics_for_many_paths() -> None:
|
|
92
|
+
ctype, msg = _fallback_type_and_message_with_context(
|
|
93
|
+
files=[
|
|
94
|
+
"src/api/router.py",
|
|
95
|
+
"src/ui/view.ts",
|
|
96
|
+
"services/auth/index.js",
|
|
97
|
+
"README.md",
|
|
98
|
+
],
|
|
99
|
+
added_any=False,
|
|
100
|
+
has_commits=True,
|
|
101
|
+
)
|
|
102
|
+
assert ctype in {"REFACTOR", "FIX", "FEAT"}
|
|
103
|
+
low = msg.lower()
|
|
104
|
+
assert "git-explain cli" not in low
|
|
105
|
+
assert "api" in low or "ui" in low or "auth" in low
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_fallback_avoids_redundant_scope_suffix() -> None:
|
|
109
|
+
_ctype, msg = _fallback_type_and_message_with_context(
|
|
110
|
+
files=["git_explain/cli.py"],
|
|
111
|
+
added_any=False,
|
|
112
|
+
has_commits=True,
|
|
113
|
+
)
|
|
114
|
+
low = msg.lower()
|
|
115
|
+
assert "for git_explain" not in low
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_fallback_prefers_stems_when_folder_is_same() -> None:
|
|
119
|
+
_ctype, msg = _fallback_type_and_message_with_context(
|
|
120
|
+
files=[
|
|
121
|
+
"git_explain/cli.py",
|
|
122
|
+
"git_explain/gemini.py",
|
|
123
|
+
"git_explain/git.py",
|
|
124
|
+
],
|
|
125
|
+
added_any=False,
|
|
126
|
+
has_commits=True,
|
|
127
|
+
)
|
|
128
|
+
low = msg.lower()
|
|
129
|
+
assert "update git explain" not in low
|
|
130
|
+
assert "cli" in low or "gemini" in low or "git" in low
|
|
@@ -18,7 +18,23 @@ def test_added_files_prefer_feat() -> None:
|
|
|
18
18
|
has_commits=True,
|
|
19
19
|
)
|
|
20
20
|
assert s.commit_type == "FEAT"
|
|
21
|
-
assert s.commit_message.lower().startswith(
|
|
21
|
+
assert s.commit_message.lower().startswith(
|
|
22
|
+
"add"
|
|
23
|
+
) or s.commit_message.lower().startswith("update")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_many_code_paths_use_generic_module_topics() -> None:
|
|
27
|
+
s = suggest_from_changes(
|
|
28
|
+
changes=[
|
|
29
|
+
("M", "src/api/router.py"),
|
|
30
|
+
("M", "src/ui/view.ts"),
|
|
31
|
+
("M", "services/auth/index.js"),
|
|
32
|
+
],
|
|
33
|
+
has_commits=True,
|
|
34
|
+
)
|
|
35
|
+
m = s.commit_message.lower()
|
|
36
|
+
assert "git-explain cli" not in m
|
|
37
|
+
assert "api" in m or "ui" in m or "auth" in m
|
|
22
38
|
|
|
23
39
|
|
|
24
40
|
def test_mostly_tests_or_config_is_test() -> None:
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.1.0"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|