commitstash 0.3.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.
- autocommit/__init__.py +1 -0
- autocommit/changelog.py +86 -0
- autocommit/cli.py +777 -0
- autocommit/config.py +35 -0
- autocommit/explain.py +43 -0
- autocommit/git.py +167 -0
- autocommit/llm.py +298 -0
- autocommit/pr.py +78 -0
- autocommit/providers.py +119 -0
- autocommit/review.py +83 -0
- autocommit/secrets.py +85 -0
- autocommit/split.py +106 -0
- commitstash-0.3.0.dist-info/METADATA +429 -0
- commitstash-0.3.0.dist-info/RECORD +17 -0
- commitstash-0.3.0.dist-info/WHEEL +4 -0
- commitstash-0.3.0.dist-info/entry_points.txt +2 -0
- commitstash-0.3.0.dist-info/licenses/LICENSE +21 -0
autocommit/cli.py
ADDED
|
@@ -0,0 +1,777 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
from rich.panel import Panel
|
|
6
|
+
from rich.text import Text
|
|
7
|
+
|
|
8
|
+
from .changelog import build_changelog
|
|
9
|
+
from .config import load_config, save_config
|
|
10
|
+
from .explain import explain as _explain
|
|
11
|
+
from .git import (
|
|
12
|
+
get_branch_commits,
|
|
13
|
+
get_branch_diff,
|
|
14
|
+
get_commit_subjects_since,
|
|
15
|
+
get_current_branch,
|
|
16
|
+
get_default_branch,
|
|
17
|
+
get_last_tag,
|
|
18
|
+
get_recent_commit_subjects,
|
|
19
|
+
get_staged_diff,
|
|
20
|
+
get_staged_files,
|
|
21
|
+
get_unstaged_files,
|
|
22
|
+
is_git_repo,
|
|
23
|
+
make_commit,
|
|
24
|
+
stage_all,
|
|
25
|
+
stage_files,
|
|
26
|
+
unstage_all,
|
|
27
|
+
)
|
|
28
|
+
from .llm import generate
|
|
29
|
+
from .pr import write_pr
|
|
30
|
+
from .review import offline_review
|
|
31
|
+
from .review import review as _review
|
|
32
|
+
from .secrets import scan_diff
|
|
33
|
+
from .split import propose_groups_ai
|
|
34
|
+
|
|
35
|
+
console = Console()
|
|
36
|
+
|
|
37
|
+
# Provider choices shared across the main command and subcommands.
|
|
38
|
+
# 'local' = offline heuristic (no API key); 'ollama' = local LLM via Ollama.
|
|
39
|
+
PROVIDER_CHOICES = ["anthropic", "openai", "ollama", "local"]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# ──────────────────────────────────────────────────────────────────────────────
|
|
43
|
+
# Helpers
|
|
44
|
+
# ──────────────────────────────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _show_message(message, title="Suggested commit message", style="green"):
|
|
48
|
+
console.print()
|
|
49
|
+
console.print(
|
|
50
|
+
Panel(
|
|
51
|
+
Text(message, style=f"bold {style}"), title=f"[bold]{title}[/bold]", border_style=style
|
|
52
|
+
)
|
|
53
|
+
)
|
|
54
|
+
console.print()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _do_commit(message):
|
|
58
|
+
success, out, err = make_commit(message)
|
|
59
|
+
if success:
|
|
60
|
+
console.print("[bold green]✓ Committed successfully[/bold green]")
|
|
61
|
+
if out.strip():
|
|
62
|
+
console.print(f"[dim]{out.strip()}[/dim]")
|
|
63
|
+
else:
|
|
64
|
+
console.print(f"[red]Commit failed:[/red] {err.strip()}")
|
|
65
|
+
sys.exit(1)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _generate_with_spinner(diff, files, config):
|
|
69
|
+
if config.get("provider") in ("local", "none", "heuristic"):
|
|
70
|
+
text = "[bold blue]Analyzing changes...[/bold blue]"
|
|
71
|
+
else:
|
|
72
|
+
text = "[bold blue]Generating commit message...[/bold blue]"
|
|
73
|
+
with console.status(text, spinner="dots"):
|
|
74
|
+
# Recent subjects teach the model this repo's commit conventions
|
|
75
|
+
return generate(diff, files, config, recent_subjects=get_recent_commit_subjects())
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _render_findings(findings, title="Secrets detected"):
|
|
79
|
+
lines = []
|
|
80
|
+
for f in findings:
|
|
81
|
+
lines.append(f"[bold red]{f.rule}[/bold red] [dim]{f.file}:{f.line}[/dim]")
|
|
82
|
+
lines.append(f" [yellow]{f.preview}[/yellow]")
|
|
83
|
+
console.print()
|
|
84
|
+
console.print(
|
|
85
|
+
Panel(
|
|
86
|
+
"\n".join(lines),
|
|
87
|
+
title=f"[bold]{title} ({len(findings)})[/bold]",
|
|
88
|
+
border_style="red",
|
|
89
|
+
)
|
|
90
|
+
)
|
|
91
|
+
console.print()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _render_issues(issues, title="Review"):
|
|
95
|
+
lines = []
|
|
96
|
+
for i in issues:
|
|
97
|
+
lines.append(f"[bold yellow]{i.kind}[/bold yellow] [dim]{i.file}:{i.line}[/dim]")
|
|
98
|
+
lines.append(f" {i.detail}")
|
|
99
|
+
console.print()
|
|
100
|
+
console.print(
|
|
101
|
+
Panel(
|
|
102
|
+
"\n".join(lines),
|
|
103
|
+
title=f"[bold]{title} ({len(issues)})[/bold]",
|
|
104
|
+
border_style="yellow",
|
|
105
|
+
)
|
|
106
|
+
)
|
|
107
|
+
console.print()
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _secret_gate(diff, yes):
|
|
111
|
+
"""Scan the diff and block the commit if secrets are found.
|
|
112
|
+
|
|
113
|
+
Aborts (exit 1) under --yes; otherwise asks for an explicit override.
|
|
114
|
+
"""
|
|
115
|
+
findings = scan_diff(diff)
|
|
116
|
+
if not findings:
|
|
117
|
+
return
|
|
118
|
+
_render_findings(findings)
|
|
119
|
+
console.print("[red]Possible secrets in your staged changes.[/red]")
|
|
120
|
+
if yes:
|
|
121
|
+
console.print("[red]✗ Aborting (--yes won't auto-commit secrets).[/red]")
|
|
122
|
+
console.print("[dim]Review, then re-run with scan_secrets disabled to override.[/dim]")
|
|
123
|
+
sys.exit(1)
|
|
124
|
+
try:
|
|
125
|
+
confirm = input("Commit anyway? [y/N] > ").strip().lower()
|
|
126
|
+
except (KeyboardInterrupt, EOFError):
|
|
127
|
+
console.print("\n[dim]Aborted.[/dim]")
|
|
128
|
+
sys.exit(1)
|
|
129
|
+
if confirm != "y":
|
|
130
|
+
console.print("[dim]Aborted.[/dim]")
|
|
131
|
+
sys.exit(1)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# ──────────────────────────────────────────────────────────────────────────────
|
|
135
|
+
# Main command
|
|
136
|
+
# ──────────────────────────────────────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@click.group(invoke_without_command=True)
|
|
140
|
+
@click.pass_context
|
|
141
|
+
@click.option(
|
|
142
|
+
"--style",
|
|
143
|
+
"-s",
|
|
144
|
+
type=click.Choice(["conventional", "angular", "simple"]),
|
|
145
|
+
help="Commit message style",
|
|
146
|
+
)
|
|
147
|
+
@click.option("--emoji", "-e", is_flag=True, default=None, help="Add emoji prefix to type")
|
|
148
|
+
@click.option("--body", "-b", is_flag=True, default=None, help="Include a commit body")
|
|
149
|
+
@click.option(
|
|
150
|
+
"--provider",
|
|
151
|
+
"-p",
|
|
152
|
+
type=click.Choice(PROVIDER_CHOICES),
|
|
153
|
+
help="LLM provider ('local' = no-AI offline heuristic)",
|
|
154
|
+
)
|
|
155
|
+
@click.option(
|
|
156
|
+
"--no-ai", "no_ai", is_flag=True, help="Generate offline with no API key (heuristic mode)"
|
|
157
|
+
)
|
|
158
|
+
@click.option(
|
|
159
|
+
"--all", "-a", "stage_all_files", is_flag=True, help="Stage all changes first (git add -A)"
|
|
160
|
+
)
|
|
161
|
+
@click.option(
|
|
162
|
+
"--yes", "-y", is_flag=True, help="Auto-accept the first suggestion without prompting"
|
|
163
|
+
)
|
|
164
|
+
def cli(ctx, style, emoji, body, provider, no_ai, stage_all_files, yes):
|
|
165
|
+
"""AI-powered git commit message generator.
|
|
166
|
+
|
|
167
|
+
Run inside any git repo. Reads your staged diff and generates a
|
|
168
|
+
conventional commit message using Claude or GPT — or fully offline
|
|
169
|
+
with --no-ai if you don't have an API key.
|
|
170
|
+
|
|
171
|
+
\b
|
|
172
|
+
Quick start:
|
|
173
|
+
export ANTHROPIC_API_KEY=sk-ant-...
|
|
174
|
+
autocommit # generate from staged changes
|
|
175
|
+
autocommit -a # stage everything, then generate
|
|
176
|
+
autocommit -a -y # stage + auto-accept (great for hooks)
|
|
177
|
+
autocommit --no-ai # no API key needed — offline heuristic
|
|
178
|
+
"""
|
|
179
|
+
if ctx.invoked_subcommand is not None:
|
|
180
|
+
return
|
|
181
|
+
|
|
182
|
+
if not is_git_repo():
|
|
183
|
+
console.print("[red]✗ Not inside a git repository.[/red]")
|
|
184
|
+
sys.exit(1)
|
|
185
|
+
|
|
186
|
+
config = load_config()
|
|
187
|
+
|
|
188
|
+
# CLI flag overrides
|
|
189
|
+
if style:
|
|
190
|
+
config["style"] = style
|
|
191
|
+
if emoji is not None:
|
|
192
|
+
config["emoji"] = emoji
|
|
193
|
+
if body is not None:
|
|
194
|
+
config["include_body"] = body
|
|
195
|
+
if provider:
|
|
196
|
+
config["provider"] = provider
|
|
197
|
+
if no_ai:
|
|
198
|
+
config["provider"] = "local"
|
|
199
|
+
|
|
200
|
+
if stage_all_files:
|
|
201
|
+
stage_all()
|
|
202
|
+
|
|
203
|
+
diff, err = get_staged_diff()
|
|
204
|
+
if err:
|
|
205
|
+
console.print(f"[red]Git error:[/red] {err}")
|
|
206
|
+
sys.exit(1)
|
|
207
|
+
|
|
208
|
+
if not diff.strip():
|
|
209
|
+
console.print("[yellow]Nothing staged.[/yellow] Stage changes first:\n")
|
|
210
|
+
console.print(" [dim]git add <file>[/dim] stage specific files")
|
|
211
|
+
console.print(" [dim]autocommit -a[/dim] stage everything and generate")
|
|
212
|
+
sys.exit(1)
|
|
213
|
+
|
|
214
|
+
files, _ = get_staged_files()
|
|
215
|
+
|
|
216
|
+
# Keep the full diff for secret scanning before any truncation
|
|
217
|
+
full_diff = diff
|
|
218
|
+
|
|
219
|
+
# Truncate very large diffs
|
|
220
|
+
max_lines = config.get("max_diff_lines", 500)
|
|
221
|
+
lines = diff.split("\n")
|
|
222
|
+
if len(lines) > max_lines:
|
|
223
|
+
diff = "\n".join(lines[:max_lines])
|
|
224
|
+
console.print(f"[dim]Diff truncated to {max_lines} lines.[/dim]")
|
|
225
|
+
|
|
226
|
+
# Show what's staged
|
|
227
|
+
console.print(f"\n[dim]Staged ({len(files)} file{'s' if len(files) != 1 else ''}):[/dim]")
|
|
228
|
+
for f in files[:8]:
|
|
229
|
+
console.print(f" [dim]· {f}[/dim]")
|
|
230
|
+
if len(files) > 8:
|
|
231
|
+
console.print(f" [dim]· ... and {len(files) - 8} more[/dim]")
|
|
232
|
+
|
|
233
|
+
# Block the commit if the staged diff introduces secrets
|
|
234
|
+
if config.get("scan_secrets", True):
|
|
235
|
+
_secret_gate(full_diff, yes)
|
|
236
|
+
|
|
237
|
+
# Generate + interactive loop
|
|
238
|
+
message = None
|
|
239
|
+
while True:
|
|
240
|
+
try:
|
|
241
|
+
message = _generate_with_spinner(diff, files, config)
|
|
242
|
+
except (EnvironmentError, ImportError) as e:
|
|
243
|
+
console.print(f"\n[red]✗ {e}[/red]")
|
|
244
|
+
sys.exit(1)
|
|
245
|
+
except Exception as e:
|
|
246
|
+
console.print(f"\n[red]✗ Unexpected error:[/red] {e}")
|
|
247
|
+
sys.exit(1)
|
|
248
|
+
|
|
249
|
+
_show_message(message)
|
|
250
|
+
|
|
251
|
+
if yes:
|
|
252
|
+
_do_commit(message)
|
|
253
|
+
break
|
|
254
|
+
|
|
255
|
+
console.print(
|
|
256
|
+
"[dim]\\[Enter][/dim] commit [bold]e[/bold] edit [bold]r[/bold] regenerate [bold]q[/bold] quit"
|
|
257
|
+
)
|
|
258
|
+
try:
|
|
259
|
+
choice = input("> ").strip().lower()
|
|
260
|
+
except (KeyboardInterrupt, EOFError):
|
|
261
|
+
console.print("\n[dim]Aborted.[/dim]")
|
|
262
|
+
sys.exit(0)
|
|
263
|
+
|
|
264
|
+
if choice == "":
|
|
265
|
+
_do_commit(message)
|
|
266
|
+
break
|
|
267
|
+
|
|
268
|
+
elif choice == "e":
|
|
269
|
+
edited = click.edit(message)
|
|
270
|
+
if edited and edited.strip():
|
|
271
|
+
message = edited.strip()
|
|
272
|
+
_show_message(message, title="Edited message", style="yellow")
|
|
273
|
+
try:
|
|
274
|
+
confirm = input("Commit with this message? [y/N] > ").strip().lower()
|
|
275
|
+
except (KeyboardInterrupt, EOFError):
|
|
276
|
+
console.print("\n[dim]Aborted.[/dim]")
|
|
277
|
+
sys.exit(0)
|
|
278
|
+
if confirm == "y":
|
|
279
|
+
_do_commit(message)
|
|
280
|
+
break
|
|
281
|
+
else:
|
|
282
|
+
console.print("[dim]No changes made.[/dim]")
|
|
283
|
+
|
|
284
|
+
elif choice == "r":
|
|
285
|
+
console.print("[dim]Regenerating...[/dim]")
|
|
286
|
+
continue
|
|
287
|
+
|
|
288
|
+
elif choice == "q":
|
|
289
|
+
console.print("[dim]Aborted.[/dim]")
|
|
290
|
+
sys.exit(0)
|
|
291
|
+
|
|
292
|
+
else:
|
|
293
|
+
console.print(
|
|
294
|
+
"[dim]Press Enter to commit, e to edit, r to regenerate, q to quit.[/dim]"
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
# ──────────────────────────────────────────────────────────────────────────────
|
|
299
|
+
# Subcommands
|
|
300
|
+
# ──────────────────────────────────────────────────────────────────────────────
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
@cli.command()
|
|
304
|
+
def configure():
|
|
305
|
+
"""Interactive setup — choose provider, style, and preferences."""
|
|
306
|
+
config = load_config()
|
|
307
|
+
|
|
308
|
+
provider = click.prompt(
|
|
309
|
+
"LLM provider ('ollama' = local LLM, 'local' = no-AI offline mode)",
|
|
310
|
+
type=click.Choice(PROVIDER_CHOICES),
|
|
311
|
+
default=config.get("provider", "anthropic"),
|
|
312
|
+
)
|
|
313
|
+
if provider == "ollama":
|
|
314
|
+
config["ollama_model"] = click.prompt(
|
|
315
|
+
"Ollama model", default=config.get("ollama_model", "llama3.2")
|
|
316
|
+
)
|
|
317
|
+
config["ollama_host"] = click.prompt(
|
|
318
|
+
"Ollama host", default=config.get("ollama_host", "http://localhost:11434")
|
|
319
|
+
)
|
|
320
|
+
style = click.prompt(
|
|
321
|
+
"Commit style",
|
|
322
|
+
type=click.Choice(["conventional", "angular", "simple"]),
|
|
323
|
+
default=config.get("style", "conventional"),
|
|
324
|
+
)
|
|
325
|
+
include_scope = click.confirm(
|
|
326
|
+
"Include scope in commit message?", default=config.get("include_scope", True)
|
|
327
|
+
)
|
|
328
|
+
include_body = click.confirm(
|
|
329
|
+
"Include a commit body?", default=config.get("include_body", False)
|
|
330
|
+
)
|
|
331
|
+
emoji = click.confirm("Add emoji prefixes?", default=config.get("emoji", False))
|
|
332
|
+
scan_secrets = click.confirm(
|
|
333
|
+
"Scan staged changes for secrets before committing?",
|
|
334
|
+
default=config.get("scan_secrets", True),
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
config.update(
|
|
338
|
+
{
|
|
339
|
+
"provider": provider,
|
|
340
|
+
"style": style,
|
|
341
|
+
"include_scope": include_scope,
|
|
342
|
+
"include_body": include_body,
|
|
343
|
+
"emoji": emoji,
|
|
344
|
+
"scan_secrets": scan_secrets,
|
|
345
|
+
}
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
path = save_config(config)
|
|
349
|
+
console.print(f"\n[green]✓ Config saved to {path}[/green]")
|
|
350
|
+
if provider == "local":
|
|
351
|
+
console.print(
|
|
352
|
+
"\n[dim]Offline mode — no API key needed. Just run [bold]autocommit[/bold].[/dim]"
|
|
353
|
+
)
|
|
354
|
+
elif provider == "ollama":
|
|
355
|
+
console.print(
|
|
356
|
+
"\n[dim]Local LLM via Ollama — no API key needed. Make sure Ollama is running:[/dim]"
|
|
357
|
+
)
|
|
358
|
+
console.print(" [dim]ollama serve[/dim]")
|
|
359
|
+
console.print(f" [dim]ollama pull {config.get('ollama_model', 'llama3.2')}[/dim]")
|
|
360
|
+
elif provider == "anthropic":
|
|
361
|
+
console.print("\n[dim]Set your API key:[/dim]")
|
|
362
|
+
console.print(" [dim]export ANTHROPIC_API_KEY=sk-ant-...[/dim]")
|
|
363
|
+
else:
|
|
364
|
+
console.print("\n[dim]Set your API key:[/dim]")
|
|
365
|
+
console.print(" [dim]export OPENAI_API_KEY=sk-...[/dim]")
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
@cli.command("install-hook")
|
|
369
|
+
def install_hook():
|
|
370
|
+
"""Install autocommit as a prepare-commit-msg git hook in the current repo.
|
|
371
|
+
|
|
372
|
+
After installing, running `git commit` will automatically suggest a message.
|
|
373
|
+
You can still edit it in your editor as usual.
|
|
374
|
+
"""
|
|
375
|
+
import stat
|
|
376
|
+
from pathlib import Path
|
|
377
|
+
|
|
378
|
+
hooks_dir = Path(".git/hooks")
|
|
379
|
+
if not hooks_dir.exists():
|
|
380
|
+
console.print("[red]No .git/hooks directory found. Are you in a git repo?[/red]")
|
|
381
|
+
sys.exit(1)
|
|
382
|
+
|
|
383
|
+
hook_path = hooks_dir / "prepare-commit-msg"
|
|
384
|
+
|
|
385
|
+
hook_script = """\
|
|
386
|
+
#!/bin/sh
|
|
387
|
+
# autocommit — AI commit message generator
|
|
388
|
+
# https://github.com/suryaSPS/autocommit
|
|
389
|
+
COMMIT_MSG_FILE="$1"
|
|
390
|
+
COMMIT_SOURCE="$2"
|
|
391
|
+
|
|
392
|
+
# Only run on blank commits (skip merge, squash, fixup, etc.)
|
|
393
|
+
if [ -z "$COMMIT_SOURCE" ]; then
|
|
394
|
+
autocommit --yes 2>/dev/null || true
|
|
395
|
+
fi
|
|
396
|
+
"""
|
|
397
|
+
|
|
398
|
+
hook_path.write_text(hook_script)
|
|
399
|
+
hook_path.chmod(hook_path.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
|
|
400
|
+
|
|
401
|
+
console.print(f"[green]✓ Hook installed at {hook_path}[/green]")
|
|
402
|
+
console.print("[dim]Now `git commit` will auto-generate and accept a message.[/dim]")
|
|
403
|
+
console.print("[dim]To uninstall: rm .git/hooks/prepare-commit-msg[/dim]")
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
@cli.command()
|
|
407
|
+
def scan():
|
|
408
|
+
"""Scan the staged diff for secrets and credentials.
|
|
409
|
+
|
|
410
|
+
Exits non-zero if anything is found, so it works as a pre-commit gate.
|
|
411
|
+
Only ADDED lines are scanned; the full secret is never printed.
|
|
412
|
+
"""
|
|
413
|
+
if not is_git_repo():
|
|
414
|
+
console.print("[red]✗ Not inside a git repository.[/red]")
|
|
415
|
+
sys.exit(1)
|
|
416
|
+
|
|
417
|
+
diff, err = get_staged_diff()
|
|
418
|
+
if err:
|
|
419
|
+
console.print(f"[red]Git error:[/red] {err}")
|
|
420
|
+
sys.exit(1)
|
|
421
|
+
if not diff.strip():
|
|
422
|
+
console.print("[yellow]Nothing staged.[/yellow]")
|
|
423
|
+
return
|
|
424
|
+
|
|
425
|
+
findings = scan_diff(diff)
|
|
426
|
+
if not findings:
|
|
427
|
+
console.print("[green]✓ No secrets detected in staged changes.[/green]")
|
|
428
|
+
return
|
|
429
|
+
|
|
430
|
+
_render_findings(findings)
|
|
431
|
+
sys.exit(1)
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
@cli.command()
|
|
435
|
+
@click.option(
|
|
436
|
+
"--provider",
|
|
437
|
+
"-p",
|
|
438
|
+
type=click.Choice(PROVIDER_CHOICES),
|
|
439
|
+
help="LLM provider to review with",
|
|
440
|
+
)
|
|
441
|
+
@click.option("--no-ai", "no_ai", is_flag=True, help="Offline pattern checks only (no API key)")
|
|
442
|
+
def review(provider, no_ai):
|
|
443
|
+
"""Review the staged diff for bugs and issues before committing.
|
|
444
|
+
|
|
445
|
+
AI providers give a real review; --no-ai runs deterministic pattern
|
|
446
|
+
checks (leftover debug code, conflict markers, new TODOs) only.
|
|
447
|
+
"""
|
|
448
|
+
if not is_git_repo():
|
|
449
|
+
console.print("[red]✗ Not inside a git repository.[/red]")
|
|
450
|
+
sys.exit(1)
|
|
451
|
+
|
|
452
|
+
config = load_config()
|
|
453
|
+
if provider:
|
|
454
|
+
config["provider"] = provider
|
|
455
|
+
if no_ai:
|
|
456
|
+
config["provider"] = "local"
|
|
457
|
+
|
|
458
|
+
diff, err = get_staged_diff()
|
|
459
|
+
if err:
|
|
460
|
+
console.print(f"[red]Git error:[/red] {err}")
|
|
461
|
+
sys.exit(1)
|
|
462
|
+
if not diff.strip():
|
|
463
|
+
console.print("[yellow]Nothing staged.[/yellow] Stage changes first.")
|
|
464
|
+
sys.exit(1)
|
|
465
|
+
|
|
466
|
+
files, _ = get_staged_files()
|
|
467
|
+
|
|
468
|
+
try:
|
|
469
|
+
with console.status("[bold blue]Reviewing changes...[/bold blue]", spinner="dots"):
|
|
470
|
+
text, is_offline = _review(diff, files, config)
|
|
471
|
+
except (EnvironmentError, ImportError) as e:
|
|
472
|
+
console.print(f"\n[red]✗ {e}[/red]")
|
|
473
|
+
sys.exit(1)
|
|
474
|
+
except Exception as e:
|
|
475
|
+
console.print(f"\n[red]✗ Unexpected error:[/red] {e}")
|
|
476
|
+
sys.exit(1)
|
|
477
|
+
|
|
478
|
+
if is_offline:
|
|
479
|
+
issues = offline_review(diff)
|
|
480
|
+
if not issues:
|
|
481
|
+
console.print("[green]✓ No obvious issues found.[/green]")
|
|
482
|
+
console.print("[dim]Offline mode runs pattern checks only, not a correctness review.[/dim]")
|
|
483
|
+
else:
|
|
484
|
+
_render_issues(issues)
|
|
485
|
+
else:
|
|
486
|
+
_show_message(text, title="Code review", style="cyan")
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
@cli.command()
|
|
490
|
+
@click.option("--base", help="Base branch to compare against (default: autodetected)")
|
|
491
|
+
@click.option(
|
|
492
|
+
"--provider",
|
|
493
|
+
"-p",
|
|
494
|
+
type=click.Choice(PROVIDER_CHOICES),
|
|
495
|
+
help="LLM provider to write the description with",
|
|
496
|
+
)
|
|
497
|
+
@click.option("--no-ai", "no_ai", is_flag=True, help="Assemble offline from commit subjects")
|
|
498
|
+
def pr(base, provider, no_ai):
|
|
499
|
+
"""Generate a pull request title and description for the current branch."""
|
|
500
|
+
if not is_git_repo():
|
|
501
|
+
console.print("[red]✗ Not inside a git repository.[/red]")
|
|
502
|
+
sys.exit(1)
|
|
503
|
+
|
|
504
|
+
config = load_config()
|
|
505
|
+
if provider:
|
|
506
|
+
config["provider"] = provider
|
|
507
|
+
if no_ai:
|
|
508
|
+
config["provider"] = "local"
|
|
509
|
+
|
|
510
|
+
branch = get_current_branch()
|
|
511
|
+
base = base or get_default_branch()
|
|
512
|
+
if not base:
|
|
513
|
+
console.print(
|
|
514
|
+
"[red]Could not determine a base branch.[/red] Pass one with [dim]--base <branch>[/dim]."
|
|
515
|
+
)
|
|
516
|
+
sys.exit(1)
|
|
517
|
+
if base == branch:
|
|
518
|
+
console.print(
|
|
519
|
+
f"[yellow]Current branch [bold]{branch}[/bold] is the base branch.[/yellow]"
|
|
520
|
+
)
|
|
521
|
+
console.print("[dim]Check out a feature branch, or pass --base <branch>.[/dim]")
|
|
522
|
+
sys.exit(1)
|
|
523
|
+
|
|
524
|
+
commits, cerr = get_branch_commits(base)
|
|
525
|
+
if cerr:
|
|
526
|
+
console.print(f"[red]Git error:[/red] {cerr}")
|
|
527
|
+
sys.exit(1)
|
|
528
|
+
diff, derr = get_branch_diff(base)
|
|
529
|
+
if derr:
|
|
530
|
+
console.print(f"[red]Git error:[/red] {derr}")
|
|
531
|
+
sys.exit(1)
|
|
532
|
+
|
|
533
|
+
if not commits and not (diff or "").strip():
|
|
534
|
+
console.print(f"[yellow]No commits on [bold]{branch}[/bold] beyond [bold]{base}[/bold].[/yellow]")
|
|
535
|
+
sys.exit(1)
|
|
536
|
+
|
|
537
|
+
console.print(
|
|
538
|
+
f"\n[dim]{len(commits)} commit{'s' if len(commits) != 1 else ''} on "
|
|
539
|
+
f"[bold]{branch}[/bold] vs [bold]{base}[/bold][/dim]"
|
|
540
|
+
)
|
|
541
|
+
|
|
542
|
+
try:
|
|
543
|
+
with console.status("[bold blue]Writing PR description...[/bold blue]", spinner="dots"):
|
|
544
|
+
title, body = write_pr(branch, base, commits, diff or "", config)
|
|
545
|
+
except (EnvironmentError, ImportError) as e:
|
|
546
|
+
console.print(f"\n[red]✗ {e}[/red]")
|
|
547
|
+
sys.exit(1)
|
|
548
|
+
except Exception as e:
|
|
549
|
+
console.print(f"\n[red]✗ Unexpected error:[/red] {e}")
|
|
550
|
+
sys.exit(1)
|
|
551
|
+
|
|
552
|
+
from rich.markdown import Markdown
|
|
553
|
+
|
|
554
|
+
_show_message(title, title="PR title", style="magenta")
|
|
555
|
+
console.print(Markdown(body))
|
|
556
|
+
console.print()
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
@cli.command()
|
|
560
|
+
@click.option("--all", "-a", "stage_all_files", is_flag=True, help="Stage all changes first")
|
|
561
|
+
@click.option("--yes", "-y", is_flag=True, help="Commit each group without prompting")
|
|
562
|
+
@click.option(
|
|
563
|
+
"--provider",
|
|
564
|
+
"-p",
|
|
565
|
+
type=click.Choice(PROVIDER_CHOICES),
|
|
566
|
+
help="LLM provider for grouping and messages",
|
|
567
|
+
)
|
|
568
|
+
@click.option("--no-ai", "no_ai", is_flag=True, help="Deterministic grouping, no API key")
|
|
569
|
+
def split(stage_all_files, yes, provider, no_ai):
|
|
570
|
+
"""Split the staged changes into a series of atomic commits.
|
|
571
|
+
|
|
572
|
+
Groups the staged files into logical commits (source by scope, then
|
|
573
|
+
tests, docs, config) and commits each group with its own generated
|
|
574
|
+
message. Splitting is file-level: one file never spans two commits.
|
|
575
|
+
"""
|
|
576
|
+
if not is_git_repo():
|
|
577
|
+
console.print("[red]✗ Not inside a git repository.[/red]")
|
|
578
|
+
sys.exit(1)
|
|
579
|
+
|
|
580
|
+
config = load_config()
|
|
581
|
+
if provider:
|
|
582
|
+
config["provider"] = provider
|
|
583
|
+
if no_ai:
|
|
584
|
+
config["provider"] = "local"
|
|
585
|
+
|
|
586
|
+
if stage_all_files:
|
|
587
|
+
stage_all()
|
|
588
|
+
|
|
589
|
+
diff, err = get_staged_diff()
|
|
590
|
+
if err:
|
|
591
|
+
console.print(f"[red]Git error:[/red] {err}")
|
|
592
|
+
sys.exit(1)
|
|
593
|
+
if not diff.strip():
|
|
594
|
+
console.print("[yellow]Nothing staged.[/yellow] Stage changes first (or use -a).")
|
|
595
|
+
sys.exit(1)
|
|
596
|
+
|
|
597
|
+
files, _ = get_staged_files()
|
|
598
|
+
if len(files) < 2:
|
|
599
|
+
console.print("[yellow]Only one file staged — nothing to split.[/yellow]")
|
|
600
|
+
sys.exit(1)
|
|
601
|
+
|
|
602
|
+
# A file with BOTH staged and unstaged edits would drag its unstaged
|
|
603
|
+
# edits into a group when re-added. Refuse rather than commit surprises.
|
|
604
|
+
overlap = sorted(set(files) & set(get_unstaged_files()))
|
|
605
|
+
if overlap:
|
|
606
|
+
console.print("[red]These files have unstaged edits on top of staged ones:[/red]")
|
|
607
|
+
for f in overlap:
|
|
608
|
+
console.print(f" [yellow]· {f}[/yellow]")
|
|
609
|
+
console.print("[dim]Stash them first ([bold]git stash -k[/bold]) or stage everything.[/dim]")
|
|
610
|
+
sys.exit(1)
|
|
611
|
+
|
|
612
|
+
if config.get("scan_secrets", True):
|
|
613
|
+
_secret_gate(diff, yes)
|
|
614
|
+
|
|
615
|
+
try:
|
|
616
|
+
with console.status("[bold blue]Planning commit groups...[/bold blue]", spinner="dots"):
|
|
617
|
+
groups, used_ai = propose_groups_ai(diff, files, config)
|
|
618
|
+
except (EnvironmentError, ImportError) as e:
|
|
619
|
+
console.print(f"\n[red]✗ {e}[/red]")
|
|
620
|
+
sys.exit(1)
|
|
621
|
+
|
|
622
|
+
if len(groups) < 2:
|
|
623
|
+
console.print("[yellow]These changes already belong in a single commit.[/yellow]")
|
|
624
|
+
sys.exit(0)
|
|
625
|
+
|
|
626
|
+
source = "AI grouping" if used_ai else "heuristic grouping"
|
|
627
|
+
console.print(f"\n[bold]Proposed split[/bold] [dim]({len(groups)} commits, {source})[/dim]\n")
|
|
628
|
+
for i, group in enumerate(groups, 1):
|
|
629
|
+
console.print(f" [bold cyan]Commit {i}[/bold cyan] [dim]— {group.reason}[/dim]")
|
|
630
|
+
for f in group.files:
|
|
631
|
+
console.print(f" [dim]· {f}[/dim]")
|
|
632
|
+
console.print()
|
|
633
|
+
|
|
634
|
+
if not yes:
|
|
635
|
+
try:
|
|
636
|
+
confirm = input("Create these commits? [y/N] > ").strip().lower()
|
|
637
|
+
except (KeyboardInterrupt, EOFError):
|
|
638
|
+
console.print("\n[dim]Aborted.[/dim]")
|
|
639
|
+
sys.exit(0)
|
|
640
|
+
if confirm != "y":
|
|
641
|
+
console.print("[dim]Aborted — staging left untouched.[/dim]")
|
|
642
|
+
sys.exit(0)
|
|
643
|
+
|
|
644
|
+
recent = get_recent_commit_subjects()
|
|
645
|
+
created: list = []
|
|
646
|
+
for i, group in enumerate(groups, 1):
|
|
647
|
+
unstage_all()
|
|
648
|
+
stage_files(group.files)
|
|
649
|
+
gdiff, gerr = get_staged_diff()
|
|
650
|
+
if gerr or not (gdiff or "").strip():
|
|
651
|
+
_restore_and_die(files, f"could not stage group {i}", created)
|
|
652
|
+
try:
|
|
653
|
+
message = generate(gdiff, group.files, config, recent_subjects=recent)
|
|
654
|
+
except Exception as e:
|
|
655
|
+
_restore_and_die(files, str(e), created)
|
|
656
|
+
success, _, cerr = make_commit(message)
|
|
657
|
+
if not success:
|
|
658
|
+
_restore_and_die(files, cerr.strip(), created)
|
|
659
|
+
created.append(message)
|
|
660
|
+
console.print(f"[green]✓ {i}/{len(groups)}[/green] {message}")
|
|
661
|
+
|
|
662
|
+
console.print(f"\n[bold green]✓ Created {len(created)} commits.[/bold green]")
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def _restore_and_die(all_files, reason, created):
|
|
666
|
+
"""Re-stage whatever wasn't committed yet, report, and exit."""
|
|
667
|
+
unstage_all()
|
|
668
|
+
stage_files(all_files) # already-committed files produce no diff; the rest re-stage
|
|
669
|
+
console.print(f"\n[red]✗ Split failed:[/red] {reason}")
|
|
670
|
+
if created:
|
|
671
|
+
console.print(f"[dim]{len(created)} commit(s) were already created and remain.[/dim]")
|
|
672
|
+
console.print("[dim]Remaining changes have been re-staged.[/dim]")
|
|
673
|
+
sys.exit(1)
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
@cli.command()
|
|
677
|
+
@click.option(
|
|
678
|
+
"--provider",
|
|
679
|
+
"-p",
|
|
680
|
+
type=click.Choice(PROVIDER_CHOICES),
|
|
681
|
+
help="LLM provider to explain with",
|
|
682
|
+
)
|
|
683
|
+
def explain(provider):
|
|
684
|
+
"""Explain the staged diff: what changed, why, impact, and risk."""
|
|
685
|
+
if not is_git_repo():
|
|
686
|
+
console.print("[red]✗ Not inside a git repository.[/red]")
|
|
687
|
+
sys.exit(1)
|
|
688
|
+
|
|
689
|
+
config = load_config()
|
|
690
|
+
if provider:
|
|
691
|
+
config["provider"] = provider
|
|
692
|
+
|
|
693
|
+
diff, err = get_staged_diff()
|
|
694
|
+
if err:
|
|
695
|
+
console.print(f"[red]Git error:[/red] {err}")
|
|
696
|
+
sys.exit(1)
|
|
697
|
+
if not diff.strip():
|
|
698
|
+
console.print("[yellow]Nothing staged.[/yellow] Stage changes first.")
|
|
699
|
+
sys.exit(1)
|
|
700
|
+
|
|
701
|
+
files, _ = get_staged_files()
|
|
702
|
+
|
|
703
|
+
try:
|
|
704
|
+
with console.status("[bold blue]Reading the diff...[/bold blue]", spinner="dots"):
|
|
705
|
+
text = _explain(diff, files, config)
|
|
706
|
+
except (EnvironmentError, ImportError) as e:
|
|
707
|
+
console.print(f"\n[red]✗ {e}[/red]")
|
|
708
|
+
sys.exit(1)
|
|
709
|
+
|
|
710
|
+
if text is None:
|
|
711
|
+
console.print(
|
|
712
|
+
"[yellow]explain needs an AI provider[/yellow] — the offline heuristic can "
|
|
713
|
+
"classify a change but not explain it."
|
|
714
|
+
)
|
|
715
|
+
console.print("[dim]Try: autocommit explain -p ollama (free, local)[/dim]")
|
|
716
|
+
sys.exit(1)
|
|
717
|
+
|
|
718
|
+
from rich.markdown import Markdown
|
|
719
|
+
|
|
720
|
+
console.print()
|
|
721
|
+
console.print(Markdown(text))
|
|
722
|
+
console.print()
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
@cli.command()
|
|
726
|
+
@click.option("--since", help="Start after this tag/ref (default: last tag, else all history)")
|
|
727
|
+
@click.option("--label", default=None, help="Release heading (default: Unreleased)")
|
|
728
|
+
@click.option("--write", "-w", "write_file", is_flag=True, help="Prepend to CHANGELOG.md")
|
|
729
|
+
def changelog(since, label, write_file):
|
|
730
|
+
"""Generate a changelog section from conventional commit history.
|
|
731
|
+
|
|
732
|
+
Deterministic by design — the same history always produces the same
|
|
733
|
+
changelog. No API key needed.
|
|
734
|
+
"""
|
|
735
|
+
if not is_git_repo():
|
|
736
|
+
console.print("[red]✗ Not inside a git repository.[/red]")
|
|
737
|
+
sys.exit(1)
|
|
738
|
+
|
|
739
|
+
since = since or get_last_tag()
|
|
740
|
+
subjects, err = get_commit_subjects_since(since)
|
|
741
|
+
if err:
|
|
742
|
+
console.print(f"[red]Git error:[/red] {err.strip()}")
|
|
743
|
+
sys.exit(1)
|
|
744
|
+
if not subjects:
|
|
745
|
+
console.print("[yellow]No commits found in that range.[/yellow]")
|
|
746
|
+
sys.exit(1)
|
|
747
|
+
|
|
748
|
+
heading = label or "Unreleased"
|
|
749
|
+
block = build_changelog(subjects, label=heading)
|
|
750
|
+
range_desc = f"since {since}" if since else "entire history"
|
|
751
|
+
console.print(f"[dim]{len(subjects)} commits ({range_desc})[/dim]\n")
|
|
752
|
+
|
|
753
|
+
if write_file:
|
|
754
|
+
from pathlib import Path
|
|
755
|
+
|
|
756
|
+
path = Path("CHANGELOG.md")
|
|
757
|
+
existing = path.read_text() if path.exists() else ""
|
|
758
|
+
if existing.startswith("# Changelog"):
|
|
759
|
+
head, _, rest = existing.partition("\n\n")
|
|
760
|
+
existing = rest
|
|
761
|
+
else:
|
|
762
|
+
head = "# Changelog"
|
|
763
|
+
path.write_text(f"{head}\n\n{block}\n{existing}".rstrip() + "\n")
|
|
764
|
+
console.print(f"[green]✓ Written to {path}[/green]")
|
|
765
|
+
else:
|
|
766
|
+
from rich.markdown import Markdown
|
|
767
|
+
|
|
768
|
+
console.print(Markdown(block))
|
|
769
|
+
console.print()
|
|
770
|
+
|
|
771
|
+
|
|
772
|
+
@cli.command()
|
|
773
|
+
def version():
|
|
774
|
+
"""Show version."""
|
|
775
|
+
from . import __version__
|
|
776
|
+
|
|
777
|
+
console.print(f"autocommit {__version__}")
|