git-explain 1.1.0__py3-none-any.whl

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.
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
git_explain/cli.py ADDED
@@ -0,0 +1,481 @@
1
+ """CLI for git-explain: suggest and optionally apply commit message from diffs."""
2
+
3
+ import subprocess
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+ from typing import Iterable
7
+
8
+ import typer
9
+ from dotenv import load_dotenv
10
+ from rich.console import Console
11
+ from rich.panel import Panel
12
+ from rich.text import Text
13
+
14
+ from git_explain.gemini import suggest_commands
15
+ from git_explain.heuristics import suggest_from_changes
16
+ from git_explain.git import get_combined_diff, get_diff_for_paths
17
+ from git_explain.run import apply_commands
18
+
19
+ load_dotenv()
20
+ app = typer.Typer()
21
+ console = Console()
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class Change:
26
+ status: str # A/M/D/R/C
27
+ path: str
28
+ sections: tuple[str, ...] # Staged/Unstaged/Untracked
29
+
30
+
31
+ def _ps_quote(arg: str) -> str:
32
+ # PowerShell single-quote escaping: ' becomes ''
33
+ return "'" + arg.replace("'", "''") + "'"
34
+
35
+
36
+ def _parse_combined(combined: str) -> tuple[bool | None, list[Change]]:
37
+ has_commits: bool | None = None
38
+ section: str | None = None
39
+ by_path: dict[str, dict[str, object]] = {}
40
+ for raw in combined.splitlines():
41
+ line = raw.strip()
42
+ if not line:
43
+ continue
44
+ if line.startswith("## "):
45
+ section = line[3:].strip()
46
+ continue
47
+ if section == "Meta" and line.lower().startswith("has_commits:"):
48
+ v = line.split(":", 1)[1].strip().lower()
49
+ if v in ("true", "false"):
50
+ has_commits = v == "true"
51
+ continue
52
+ m = __import__("re").match(
53
+ r"^([AMDRC])\s+(.+)$", line, __import__("re").IGNORECASE
54
+ )
55
+ if not m:
56
+ continue
57
+ status = m.group(1).upper()
58
+ path = m.group(2).strip()
59
+ rec = by_path.get(path)
60
+ if rec is None:
61
+ by_path[path] = {"status": status, "sections": {section or "Unknown"}}
62
+ else:
63
+ rec["sections"].add(section or "Unknown") # type: ignore[union-attr]
64
+ # Prefer A over M, M over others for display
65
+ cur = rec["status"] # type: ignore[index]
66
+ if cur != "A" and status == "A":
67
+ rec["status"] = "A" # type: ignore[index]
68
+ elif cur not in ("A", "M") and status == "M":
69
+ rec["status"] = "M" # type: ignore[index]
70
+ changes: list[Change] = []
71
+ for path, rec in sorted(by_path.items(), key=lambda kv: kv[0].lower()):
72
+ changes.append(
73
+ Change(
74
+ status=str(rec["status"]),
75
+ path=path,
76
+ sections=tuple(sorted(rec["sections"])), # type: ignore[arg-type]
77
+ )
78
+ )
79
+ return has_commits, changes
80
+
81
+
82
+ def _render_combined(
83
+ has_commits: bool | None, items: Iterable[tuple[str, str]], title: str
84
+ ) -> str:
85
+ parts = []
86
+ if has_commits is not None:
87
+ parts.append("## Meta\nhas_commits: " + ("true" if has_commits else "false"))
88
+ parts.append(f"## {title}\n" + "\n".join([f"{s} {p}" for s, p in items]))
89
+ return "\n\n".join(parts).strip()
90
+
91
+
92
+ def _parse_selection(selection: str, n: int) -> tuple[list[int], list[str]]:
93
+ """Parse a selection string into numeric indices and explicit path tokens.
94
+
95
+ Supports:
96
+ - \"\" / a / all -> all indices 1..n
97
+ - 1,2,5-7 -> numeric indices/ranges
98
+ - anything not numeric -> treated as a path token (e.g. git_explain/cli.py)
99
+ """
100
+ s = (selection or "").strip()
101
+ if s.lower() in ("", "a", "all"):
102
+ return list(range(1, n + 1)), []
103
+ out_indices: set[int] = set()
104
+ path_tokens: list[str] = []
105
+ for part in s.split(","):
106
+ part = part.strip()
107
+ if not part:
108
+ continue
109
+ if "-" in part:
110
+ a, b = part.split("-", 1)
111
+ try:
112
+ start = int(a.strip())
113
+ end = int(b.strip())
114
+ except ValueError:
115
+ path_tokens.append(part)
116
+ continue
117
+ for i in range(min(start, end), max(start, end) + 1):
118
+ if 1 <= i <= n:
119
+ out_indices.add(i)
120
+ continue
121
+ try:
122
+ i = int(part)
123
+ except ValueError:
124
+ path_tokens.append(part)
125
+ continue
126
+ if 1 <= i <= n:
127
+ out_indices.add(i)
128
+ return sorted(out_indices), path_tokens
129
+
130
+
131
+ def _group_changes(changes: list[tuple[str, str]]) -> dict[str, list[tuple[str, str]]]:
132
+ # Simple grouping: docs, tests, config, code, other
133
+ def is_doc(p: str) -> bool:
134
+ p2 = p.lower()
135
+ return p2.endswith((".md", ".rst", ".txt")) or p2.endswith(
136
+ ("readme", "readme.md", "features.md")
137
+ )
138
+
139
+ def is_test(p: str) -> bool:
140
+ p2 = p.lower().replace("\\", "/")
141
+ base = p2.split("/")[-1]
142
+ return (
143
+ p2.startswith("tests/")
144
+ or "/tests/" in p2
145
+ or base.startswith("test_")
146
+ or base.endswith("_test.py")
147
+ or ".spec." in base
148
+ )
149
+
150
+ def is_config(p: str) -> bool:
151
+ p2 = p.lower()
152
+ base = p2.split("/")[-1].split("\\")[-1]
153
+ return base in {
154
+ "pyproject.toml",
155
+ "requirements.txt",
156
+ "setup.cfg",
157
+ "setup.py",
158
+ ".gitignore",
159
+ } or p2.endswith((".toml", ".yml", ".yaml", ".json", ".ini", ".cfg", ".lock"))
160
+
161
+ groups: dict[str, list[tuple[str, str]]] = {
162
+ "docs": [],
163
+ "tests": [],
164
+ "config": [],
165
+ "code": [],
166
+ "other": [],
167
+ }
168
+ for st, p in changes:
169
+ if is_doc(p):
170
+ groups["docs"].append((st, p))
171
+ elif is_test(p):
172
+ groups["tests"].append((st, p))
173
+ elif is_config(p):
174
+ groups["config"].append((st, p))
175
+ elif p.lower().replace("\\", "/").startswith("git_explain/"):
176
+ groups["code"].append((st, p))
177
+ else:
178
+ groups["other"].append((st, p))
179
+ return {k: v for k, v in groups.items() if v}
180
+
181
+
182
+ @app.callback(invoke_without_command=True)
183
+ def main(
184
+ ctx: typer.Context,
185
+ auto: bool = typer.Option(
186
+ False, "--auto", help="Apply suggestion without prompting"
187
+ ),
188
+ ai: bool = typer.Option(
189
+ False, "--ai", help="Use Gemini to suggest commit message (default: off)"
190
+ ),
191
+ staged_only: bool = typer.Option(
192
+ False,
193
+ "--staged-only",
194
+ help="Commit only already-staged changes (do not run git add). Useful for partial staging.",
195
+ ),
196
+ cwd: str | None = typer.Option(
197
+ None, "--cwd", help="Working directory (default: current)"
198
+ ),
199
+ model: str | None = typer.Option(
200
+ None,
201
+ "--model",
202
+ help=(
203
+ "Override Gemini model name for --ai "
204
+ "(defaults to GEMINI_MODEL env var or internal default)."
205
+ ),
206
+ ),
207
+ with_diff: bool = typer.Option(
208
+ False,
209
+ "--with-diff",
210
+ help="With --ai: send full diff to the model for detailed, specific commit messages (opt-in).",
211
+ ),
212
+ ) -> None:
213
+ if ctx.invoked_subcommand is not None:
214
+ return
215
+ run(
216
+ cwd=Path(cwd) if cwd else None,
217
+ auto=auto,
218
+ ai=ai,
219
+ staged_only=staged_only,
220
+ model=model,
221
+ with_diff=with_diff,
222
+ )
223
+
224
+
225
+ def run(
226
+ cwd: Path | None = None,
227
+ auto: bool = False,
228
+ ai: bool = False,
229
+ staged_only: bool = False,
230
+ model: str | None = None,
231
+ with_diff: bool = False,
232
+ ) -> None:
233
+ console.print(Text("git-explain", style="bold"))
234
+ if with_diff and not ai:
235
+ console.print(
236
+ "[yellow]Warning:[/yellow] --with-diff has no effect without --ai. "
237
+ "It only affects AI-generated commit messages."
238
+ )
239
+ enable = typer.prompt("Enable AI? (y/n)", default="n").strip().lower()
240
+ if enable in ("y", "yes"):
241
+ ai = True
242
+ else:
243
+ with_diff = False
244
+
245
+ try:
246
+ combined, repo_root = get_combined_diff(cwd=cwd)
247
+ except RuntimeError as e:
248
+ console.print(f"[red]Error:[/red] {e}")
249
+ raise typer.Exit(1)
250
+ if not combined.strip():
251
+ console.print("[yellow]No staged, unstaged, or untracked changes.[/yellow]")
252
+ return
253
+ has_commits, changes = _parse_combined(combined)
254
+ console.print(Panel(combined, title="Changed files", border_style="dim"))
255
+
256
+ if staged_only:
257
+ changes = [c for c in changes if "Staged" in c.sections]
258
+ console.print(
259
+ "[dim]Note:[/dim] staged-only mode: only already-staged files are selectable."
260
+ )
261
+
262
+ if not changes:
263
+ console.print("[yellow]No selectable changes found.[/yellow]")
264
+ return
265
+
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
+ display_items: list[tuple[str, list[int]]] = []
282
+ seen_untracked_roots: set[str] = set()
283
+ 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]))
297
+
298
+ lines = []
299
+ for idx, (label, _idxs) in enumerate(display_items, start=1):
300
+ lines.append(f"{idx:>2}. {label}")
301
+ console.print(Panel("\n".join(lines), title="Select files", border_style="blue"))
302
+ selection = typer.prompt(
303
+ "Select files to include (e.g. 1,2,5-7, 'all', or a path like folder/file.txt)",
304
+ default="all",
305
+ )
306
+ picks, path_tokens = _parse_selection(selection, len(display_items))
307
+ if not picks and not path_tokens:
308
+ console.print("[yellow]No files selected.[/yellow]")
309
+ return
310
+
311
+ selected_indices: set[int] = set()
312
+ for display_idx in picks:
313
+ if 1 <= display_idx <= len(display_items):
314
+ _, idxs = display_items[display_idx - 1]
315
+ selected_indices.update(idxs)
316
+
317
+ for token in path_tokens:
318
+ t_norm = token.replace("\\", "/").strip()
319
+ for idx, np in enumerate(norm_paths):
320
+ if np == t_norm or np.startswith(t_norm.rstrip("/") + "/"):
321
+ selected_indices.add(idx)
322
+
323
+ if not selected_indices:
324
+ console.print("[yellow]No files matched your selection.[/yellow]")
325
+ return
326
+
327
+ selected = [changes[i] for i in sorted(selected_indices)]
328
+ if not staged_only:
329
+ risky = [
330
+ c for c in selected if ("Staged" in c.sections and "Unstaged" in c.sections)
331
+ ]
332
+ if risky:
333
+ msg = "\n".join([f"- {c.path}" for c in risky])
334
+ console.print(
335
+ Panel(
336
+ "These files have both staged and unstaged changes.\n"
337
+ "If you apply, git-explain will stage the whole file, which can override partial staging.\n\n"
338
+ + msg
339
+ + "\n\nTip: re-run with --staged-only to commit only what's already staged.",
340
+ title="Warning: partial staging",
341
+ border_style="yellow",
342
+ )
343
+ )
344
+ cont = typer.prompt("Continue anyway? (y/n)", default="n").strip().lower()
345
+ if cont not in ("y", "yes"):
346
+ return
347
+
348
+ def suggest_for(
349
+ change_items: list[tuple[str, str]], title: str
350
+ ) -> tuple[list[str], str, str, str]:
351
+ # Returns (paths, type, message, raw_text)
352
+ if ai:
353
+ payload = _render_combined(has_commits, change_items, title=title)
354
+ if with_diff:
355
+ paths_for_diff = [p for _, p in change_items]
356
+ diff_text = get_diff_for_paths(paths_for_diff, cwd=repo_root)
357
+ if diff_text:
358
+ payload = payload + "\n\n## Diff\n" + diff_text
359
+ try:
360
+ sug, raw = suggest_commands(payload, model=model, with_diff=with_diff)
361
+ if sug is None:
362
+ raise RuntimeError("Could not parse AI suggestion.")
363
+ return sug.add_args, sug.commit_type, sug.commit_message, raw
364
+ except Exception as e:
365
+ # Fall back to heuristics on quota / API errors
366
+ h = suggest_from_changes(changes=change_items, has_commits=has_commits)
367
+ return (
368
+ h.add_args,
369
+ h.commit_type,
370
+ h.commit_message,
371
+ f"AI unavailable: {e}",
372
+ )
373
+ h = suggest_from_changes(changes=change_items, has_commits=has_commits)
374
+ return h.add_args, h.commit_type, h.commit_message, ""
375
+
376
+ selected_pairs = [(ch.status, ch.path) for ch in selected]
377
+ unique_paths = {p for _, p in selected_pairs}
378
+
379
+ mode = "one"
380
+ if len(unique_paths) > 1:
381
+ mode_input = (
382
+ typer.prompt("Commit mode: one or split", default="one").strip().lower()
383
+ )
384
+ if mode_input in ("one", "split"):
385
+ mode = mode_input
386
+
387
+ plan: list[tuple[str, list[str], str, str]] = []
388
+ if mode == "split":
389
+ groups = _group_changes(selected_pairs)
390
+ for gname, items in groups.items():
391
+ paths, ctype, cmsg, _raw = suggest_for(items, title=gname.capitalize())
392
+ plan.append((gname, paths, ctype, cmsg))
393
+ else:
394
+ paths, ctype, cmsg, _raw = suggest_for(selected_pairs, title="Selected")
395
+ plan.append(("one", paths, ctype, cmsg))
396
+
397
+ def _render_plan(pl: list[tuple[str, list[str], str, str]]) -> str:
398
+ rendered: list[str] = []
399
+ for name, paths, ctype, cmsg in pl:
400
+ add_line = "git add -A -- " + " ".join(_ps_quote(p) for p in paths)
401
+ commit_line = f'git commit -m "[{ctype}] {cmsg}"'
402
+ rendered.append(f"### {name}\n{add_line}\n{commit_line}")
403
+ return "\n\n".join(rendered)
404
+
405
+ console.print(
406
+ Panel(
407
+ _render_plan(plan),
408
+ title="Suggested commands",
409
+ border_style="green",
410
+ )
411
+ )
412
+
413
+ if not auto:
414
+ edit_choice = (
415
+ typer.prompt(
416
+ "Edit commit message(s) before applying? (y/n)",
417
+ default="n",
418
+ )
419
+ .strip()
420
+ .lower()
421
+ )
422
+ if edit_choice in ("y", "yes"):
423
+ updated: list[tuple[str, list[str], str, str]] = []
424
+ for name, paths, ctype, cmsg in plan:
425
+ console.print(
426
+ f"[dim]{name}:[/dim] current message: [bold][{ctype}] {cmsg}[/bold]"
427
+ )
428
+ try:
429
+ from prompt_toolkit import prompt as pt_prompt
430
+
431
+ new_msg = (
432
+ pt_prompt(
433
+ "New commit message (subject only, no [TYPE] prefix): ",
434
+ default=cmsg,
435
+ ).strip()
436
+ or cmsg
437
+ )
438
+ except Exception:
439
+ new_msg = (
440
+ typer.prompt(
441
+ "New commit message (subject only, no [TYPE] prefix)",
442
+ default=cmsg,
443
+ ).strip()
444
+ ) or cmsg
445
+ updated.append((name, paths, ctype, new_msg))
446
+ plan = updated
447
+ console.print(
448
+ Panel(
449
+ _render_plan(plan),
450
+ title="Updated commands",
451
+ border_style="green",
452
+ )
453
+ )
454
+
455
+ if auto:
456
+ do_apply = True
457
+ else:
458
+ prompt = (
459
+ "Apply these commit(s)? (y/n/auto)"
460
+ if len(plan) > 1
461
+ else "Apply these commands? (y/n/auto)"
462
+ )
463
+ choice = typer.prompt(prompt, default="n").strip().lower()
464
+ do_apply = choice == "auto" or choice in ("y", "yes")
465
+
466
+ if do_apply:
467
+ for name, paths, ctype, cmsg in plan:
468
+ try:
469
+ apply_commands(repo_root, [] if staged_only else paths, ctype, cmsg)
470
+ console.print(f"[green]Commit created ({name}).[/green]")
471
+ except subprocess.CalledProcessError as e:
472
+ console.print("[red]git command failed.[/red]")
473
+ console.print(f"[dim]Command:[/dim] {e.cmd}")
474
+ if e.stdout:
475
+ console.print(e.stdout)
476
+ if e.stderr:
477
+ console.print(e.stderr)
478
+ raise typer.Exit(1)
479
+ except RuntimeError as e:
480
+ console.print(f"[red]Error:[/red] {e}")
481
+ raise typer.Exit(1)