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.
- git_explain/__init__.py +1 -0
- git_explain/cli.py +481 -0
- git_explain/gemini.py +324 -0
- git_explain/git.py +170 -0
- git_explain/heuristics.py +123 -0
- git_explain/run.py +54 -0
- git_explain-1.1.0.dist-info/METADATA +143 -0
- git_explain-1.1.0.dist-info/RECORD +12 -0
- git_explain-1.1.0.dist-info/WHEEL +5 -0
- git_explain-1.1.0.dist-info/entry_points.txt +2 -0
- git_explain-1.1.0.dist-info/licenses/LICENSE +201 -0
- git_explain-1.1.0.dist-info/top_level.txt +1 -0
git_explain/__init__.py
ADDED
|
@@ -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)
|