lgit-cli 3.7.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.
- lgit/__init__.py +75 -0
- lgit/__main__.py +8 -0
- lgit/analysis.py +326 -0
- lgit/api.py +1077 -0
- lgit/cache.py +338 -0
- lgit/changelog.py +523 -0
- lgit/cli.py +1104 -0
- lgit/compose.py +2110 -0
- lgit/config.py +437 -0
- lgit/diffing.py +384 -0
- lgit/errors.py +137 -0
- lgit/git.py +852 -0
- lgit/map_reduce.py +508 -0
- lgit/markdown_output.py +709 -0
- lgit/models.py +924 -0
- lgit/normalization.py +411 -0
- lgit/patch.py +784 -0
- lgit/profile.py +426 -0
- lgit/py.typed +0 -0
- lgit/repo.py +287 -0
- lgit/resources/__init__.py +1 -0
- lgit/resources/commit_types.json +242 -0
- lgit/resources/prompts/analysis/default.md +237 -0
- lgit/resources/prompts/analysis/markdown.md +112 -0
- lgit/resources/prompts/changelog/default.md +89 -0
- lgit/resources/prompts/changelog/markdown.md +60 -0
- lgit/resources/prompts/compose-bind/default.md +40 -0
- lgit/resources/prompts/compose-bind/markdown.md +41 -0
- lgit/resources/prompts/compose-intent/default.md +63 -0
- lgit/resources/prompts/compose-intent/markdown.md +59 -0
- lgit/resources/prompts/fast/default.md +46 -0
- lgit/resources/prompts/fast/markdown.md +51 -0
- lgit/resources/prompts/map/default.md +67 -0
- lgit/resources/prompts/map/markdown.md +63 -0
- lgit/resources/prompts/reduce/default.md +81 -0
- lgit/resources/prompts/reduce/markdown.md +68 -0
- lgit/resources/prompts/summary/default.md +74 -0
- lgit/resources/prompts/summary/markdown.md +77 -0
- lgit/resources/validation_data.json +1 -0
- lgit/rewrite.py +392 -0
- lgit/style.py +295 -0
- lgit/templates.py +385 -0
- lgit/testing/__init__.py +62 -0
- lgit/testing/compare.py +57 -0
- lgit/testing/fixture.py +386 -0
- lgit/testing/report.py +201 -0
- lgit/testing/runner.py +256 -0
- lgit/tokens.py +90 -0
- lgit/validation.py +545 -0
- lgit_cli-3.7.0.dist-info/METADATA +288 -0
- lgit_cli-3.7.0.dist-info/RECORD +54 -0
- lgit_cli-3.7.0.dist-info/WHEEL +4 -0
- lgit_cli-3.7.0.dist-info/entry_points.txt +2 -0
- lgit_cli-3.7.0.dist-info/licenses/LICENSE +21 -0
lgit/cli.py
ADDED
|
@@ -0,0 +1,1104 @@
|
|
|
1
|
+
"""Public argparse CLI and orchestration for lgit."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import asyncio
|
|
7
|
+
import inspect
|
|
8
|
+
import os
|
|
9
|
+
import platform
|
|
10
|
+
import shutil
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
from collections.abc import Iterable, Sequence
|
|
14
|
+
from dataclasses import replace
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from . import cache, git, profile, repo, style
|
|
19
|
+
from .analysis import ScopeAnalyzer, extract_scope_candidates
|
|
20
|
+
from .api import generate_analysis_with_map_reduce, generate_fast_commit, generate_summary_from_analysis
|
|
21
|
+
from .changelog import run_changelog_flow
|
|
22
|
+
from .config import CommitConfig
|
|
23
|
+
from .diffing import classify_diff_whitespace, smart_truncate_diff, strip_whitespace_only_files, truncate_diff_by_lines
|
|
24
|
+
from .errors import LgitError, NoChanges, ValidationFailure
|
|
25
|
+
from .map_reduce import should_use_map_reduce
|
|
26
|
+
from .markdown_output import fallback_summary
|
|
27
|
+
from .models import ConventionalAnalysis, ConventionalCommit, Mode, resolve_model_name
|
|
28
|
+
from .normalization import post_process_commit_message
|
|
29
|
+
from .tokens import create_token_counter
|
|
30
|
+
from .validation import check_type_scope_consistency, validate_commit_message, validate_summary_quality
|
|
31
|
+
|
|
32
|
+
_COMPLETION_SHELLS = ("bash", "zsh", "fish", "powershell", "elvish")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
36
|
+
"""Build the public command-line parser."""
|
|
37
|
+
parser = argparse.ArgumentParser(
|
|
38
|
+
prog="lgit",
|
|
39
|
+
description="Generate conventional git commit messages with an LLM.",
|
|
40
|
+
allow_abbrev=False,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
parser.add_argument("context", nargs="*", help="additional context passed to the model")
|
|
44
|
+
|
|
45
|
+
standard = parser.add_argument_group("standard")
|
|
46
|
+
standard.add_argument(
|
|
47
|
+
"--mode", choices=[mode.value for mode in Mode], default=Mode.STAGED.value, help="change source to analyze"
|
|
48
|
+
)
|
|
49
|
+
standard.add_argument("--target", help="commit/ref to analyze when --mode commit is used")
|
|
50
|
+
standard.add_argument(
|
|
51
|
+
"--copy", action="store_true", help="copy the generated message to the clipboard instead of committing"
|
|
52
|
+
)
|
|
53
|
+
standard.add_argument("--dry-run", action="store_true", help="print the generated message without committing")
|
|
54
|
+
standard.add_argument("--push", "-p", action="store_true", help="push after creating a commit")
|
|
55
|
+
standard.add_argument("--dir", default=".", help="repository directory")
|
|
56
|
+
standard.add_argument("--model", "-m", help="model override for analysis and summary calls")
|
|
57
|
+
|
|
58
|
+
footers = parser.add_argument_group("footers and git commit options")
|
|
59
|
+
footers.add_argument("--fixes", nargs="+", action="append", metavar="REF", help="add Fixes trailers")
|
|
60
|
+
footers.add_argument("--closes", nargs="+", action="append", metavar="REF", help="add Closes trailers")
|
|
61
|
+
footers.add_argument("--resolves", nargs="+", action="append", metavar="REF", help="add Resolves trailers")
|
|
62
|
+
footers.add_argument("--refs", nargs="+", action="append", metavar="REF", help="add Refs trailers")
|
|
63
|
+
footers.add_argument("--breaking", action="store_true", help="add a BREAKING CHANGE trailer")
|
|
64
|
+
footers.add_argument("--sign", "-S", action="store_true", help="GPG-sign the commit")
|
|
65
|
+
footers.add_argument("--signoff", "-s", action="store_true", help="add a Signed-off-by trailer")
|
|
66
|
+
footers.add_argument("--amend", action="store_true", help="amend HEAD instead of creating a new commit")
|
|
67
|
+
footers.add_argument("--skip-hooks", "-n", action="store_true", help="pass --no-verify to git commit")
|
|
68
|
+
|
|
69
|
+
config = parser.add_argument_group("config and completion")
|
|
70
|
+
config.add_argument("--config", help="config TOML path override")
|
|
71
|
+
config.add_argument("--completions", choices=_COMPLETION_SHELLS, help="print a shell-completion script")
|
|
72
|
+
|
|
73
|
+
routes = parser.add_argument_group("modes")
|
|
74
|
+
routes.add_argument(
|
|
75
|
+
"--fast", "-f", action="store_true", help="use one LLM call to generate the complete commit message"
|
|
76
|
+
)
|
|
77
|
+
routes.add_argument("--rewrite", action="store_true", help="rewrite recent commit messages")
|
|
78
|
+
routes.add_argument("--test", action="store_true", help="run fixture-test mode")
|
|
79
|
+
|
|
80
|
+
rewrite = parser.add_argument_group("rewrite")
|
|
81
|
+
rewrite.add_argument(
|
|
82
|
+
"--rewrite-preview", type=int, metavar="N", help="preview the first N commits without rewriting"
|
|
83
|
+
)
|
|
84
|
+
rewrite.add_argument("--rewrite-start", metavar="REF", help="oldest commit/ref to include in rewrite mode")
|
|
85
|
+
rewrite.add_argument(
|
|
86
|
+
"--rewrite-parallel", type=int, default=10, metavar="N", help="maximum parallel rewrite generations"
|
|
87
|
+
)
|
|
88
|
+
rewrite.add_argument(
|
|
89
|
+
"--rewrite-dry-run", action="store_true", help="generate rewrite messages without applying them"
|
|
90
|
+
)
|
|
91
|
+
rewrite.add_argument(
|
|
92
|
+
"--rewrite-hide-old-types", action="store_true", help="hide old conventional types in rewrite previews"
|
|
93
|
+
)
|
|
94
|
+
rewrite.add_argument(
|
|
95
|
+
"--exclude-old-message", action="store_true", help="exclude old commit messages from commit-mode diffs/prompts"
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
compose = parser.add_argument_group("compose")
|
|
99
|
+
compose.add_argument("--compose", action="store_true", help="split current worktree changes into multiple commits")
|
|
100
|
+
compose.add_argument("--compose-preview", action="store_true", help="plan compose commits without applying them")
|
|
101
|
+
compose.add_argument("--compose-max-commits", type=int, metavar="N", help="maximum compose commits to plan")
|
|
102
|
+
compose.add_argument(
|
|
103
|
+
"--compose-test-after-each", action="store_true", help="run configured test command after each compose commit"
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
changelog = parser.add_argument_group("changelog")
|
|
107
|
+
changelog.add_argument(
|
|
108
|
+
"--no-changelog", action="store_true", help="disable changelog generation for this invocation"
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
debug = parser.add_argument_group("debug")
|
|
112
|
+
debug.add_argument("--debug-output", metavar="DIR", help="write raw LLM/debug artifacts to DIR")
|
|
113
|
+
debug.add_argument("--trace-output", metavar="FILE", help="write JSONL profile trace to FILE")
|
|
114
|
+
|
|
115
|
+
tests = parser.add_argument_group("test")
|
|
116
|
+
tests.add_argument("--test-update", action="store_true", help="update fixture snapshots")
|
|
117
|
+
tests.add_argument("--test-add", metavar="COMMIT", help="add a fixture from COMMIT")
|
|
118
|
+
tests.add_argument("--test-name", metavar="NAME", help="fixture name for --test-add")
|
|
119
|
+
tests.add_argument("--test-filter", metavar="PATTERN", help="only run fixtures matching PATTERN")
|
|
120
|
+
tests.add_argument("--test-list", action="store_true", help="list fixtures")
|
|
121
|
+
tests.add_argument("--fixtures-dir", metavar="DIR", help="fixture directory")
|
|
122
|
+
tests.add_argument("--test-report", metavar="FILE", help="write fixture-test report")
|
|
123
|
+
|
|
124
|
+
return parser
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
|
|
128
|
+
"""Parse command-line arguments."""
|
|
129
|
+
parser = build_parser()
|
|
130
|
+
args = parser.parse_args(argv)
|
|
131
|
+
_validate_arg_conflicts(parser, args)
|
|
132
|
+
return args
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
136
|
+
"""Console-script entry point."""
|
|
137
|
+
try:
|
|
138
|
+
args = parse_args(argv)
|
|
139
|
+
return asyncio.run(run_cli(args))
|
|
140
|
+
except KeyboardInterrupt:
|
|
141
|
+
print("lgit: interrupted", file=sys.stderr)
|
|
142
|
+
return 130
|
|
143
|
+
except BrokenPipeError:
|
|
144
|
+
return 1
|
|
145
|
+
except LgitError as exc:
|
|
146
|
+
print(f"lgit: {exc}", file=sys.stderr)
|
|
147
|
+
return 1
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
async def run_cli(args: argparse.Namespace) -> int:
|
|
151
|
+
"""Run the selected CLI workflow."""
|
|
152
|
+
if args.completions:
|
|
153
|
+
print(_completion_script(args.completions), end="")
|
|
154
|
+
return 0
|
|
155
|
+
|
|
156
|
+
trace_path = profile.trace_file_path(args)
|
|
157
|
+
trace_guard = profile.init_file_tracing(trace_path) if trace_path is not None else None
|
|
158
|
+
collector = profile.create_timing_collector(profile.timings_enabled(args))
|
|
159
|
+
args._timing_collector = collector
|
|
160
|
+
try:
|
|
161
|
+
with profile.section("load_config", collector):
|
|
162
|
+
config = _load_config(args)
|
|
163
|
+
with profile.section("init_git_command_settings", collector):
|
|
164
|
+
git.init_git_command_settings(config)
|
|
165
|
+
with profile.section("init_cache", collector):
|
|
166
|
+
cache.init(config)
|
|
167
|
+
|
|
168
|
+
if _wants_test(args):
|
|
169
|
+
return await _run_test_mode(args, config)
|
|
170
|
+
|
|
171
|
+
with profile.section("ensure_git_repo", collector):
|
|
172
|
+
git.ensure_git_repo(args.dir)
|
|
173
|
+
|
|
174
|
+
if _wants_rewrite(args):
|
|
175
|
+
return await _run_rewrite(args, config)
|
|
176
|
+
if _wants_compose(args):
|
|
177
|
+
return await _run_compose(args, config)
|
|
178
|
+
return await _run_standard(args, config)
|
|
179
|
+
finally:
|
|
180
|
+
try:
|
|
181
|
+
if collector.enabled:
|
|
182
|
+
report = profile.emit_timing_report(args, collector)
|
|
183
|
+
if args.debug_output:
|
|
184
|
+
profile.write_timings_json(Path(args.debug_output) / "timings.json", report)
|
|
185
|
+
finally:
|
|
186
|
+
if trace_guard is not None:
|
|
187
|
+
trace_guard.close()
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
async def _run_standard(args: argparse.Namespace, config: CommitConfig) -> int:
|
|
191
|
+
collector = _timing_collector(args)
|
|
192
|
+
mode = Mode.from_raw(args.mode)
|
|
193
|
+
if mode is Mode.COMPOSE:
|
|
194
|
+
return await _run_compose(args, config)
|
|
195
|
+
|
|
196
|
+
if mode is Mode.STAGED:
|
|
197
|
+
with profile.section("auto_stage_if_needed", collector):
|
|
198
|
+
_auto_stage_if_needed(args, config)
|
|
199
|
+
|
|
200
|
+
if mode is Mode.STAGED and not args.dry_run:
|
|
201
|
+
with profile.section("write_real_index_tree", collector):
|
|
202
|
+
staged_index_tree = git.write_real_index_tree(args.dir)
|
|
203
|
+
else:
|
|
204
|
+
staged_index_tree = None
|
|
205
|
+
user_context = " ".join(args.context).strip() or None
|
|
206
|
+
with profile.section("read_change_inputs", collector):
|
|
207
|
+
diff, stat, numstat = _read_change_inputs(mode, args, config)
|
|
208
|
+
|
|
209
|
+
with profile.section("detect_reformat_shortcut", collector):
|
|
210
|
+
reformat_commit = _detect_reformat_shortcut(diff, config, args)
|
|
211
|
+
if reformat_commit is not None:
|
|
212
|
+
style.status(f"{style.info('›')} {style.dim('Detected whitespace-only changes; recording as reformat')}")
|
|
213
|
+
message = reformat_commit
|
|
214
|
+
else:
|
|
215
|
+
if args.fast:
|
|
216
|
+
message = await _generate_fast_workflow(mode, config, args, user_context, diff, stat, numstat, collector)
|
|
217
|
+
else:
|
|
218
|
+
if int(config.auto_fast_threshold_lines) > 0:
|
|
219
|
+
with profile.section("auto_fast_changed_lines", collector):
|
|
220
|
+
changed_lines = _auto_fast_changed_lines(numstat, config)
|
|
221
|
+
if changed_lines is not None:
|
|
222
|
+
style.status(
|
|
223
|
+
f"{style.info('›')} "
|
|
224
|
+
f"{style.dim(f'Auto-switching to fast mode ({changed_lines} changed lines <= {config.auto_fast_threshold_lines})')}"
|
|
225
|
+
)
|
|
226
|
+
message = await _generate_fast_workflow(
|
|
227
|
+
mode, config, args, user_context, diff, stat, numstat, collector
|
|
228
|
+
)
|
|
229
|
+
else:
|
|
230
|
+
message = await _generate_standard_workflow(
|
|
231
|
+
mode,
|
|
232
|
+
args,
|
|
233
|
+
config,
|
|
234
|
+
user_context,
|
|
235
|
+
diff,
|
|
236
|
+
stat,
|
|
237
|
+
numstat,
|
|
238
|
+
collector,
|
|
239
|
+
)
|
|
240
|
+
else:
|
|
241
|
+
message = await _generate_standard_workflow(
|
|
242
|
+
mode,
|
|
243
|
+
args,
|
|
244
|
+
config,
|
|
245
|
+
user_context,
|
|
246
|
+
diff,
|
|
247
|
+
stat,
|
|
248
|
+
numstat,
|
|
249
|
+
collector,
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
async with profile.section("validate_and_process", collector):
|
|
253
|
+
message, validation_failed = await _validate_and_process(
|
|
254
|
+
_with_cli_footers(message, args, config),
|
|
255
|
+
stat,
|
|
256
|
+
tuple(message.body),
|
|
257
|
+
user_context,
|
|
258
|
+
config,
|
|
259
|
+
args,
|
|
260
|
+
collector,
|
|
261
|
+
)
|
|
262
|
+
if validation_failed:
|
|
263
|
+
print(f"Warning: Generated message failed validation even after retry: {validation_failed}", file=sys.stderr)
|
|
264
|
+
print("You may want to manually edit the message before committing.", file=sys.stderr)
|
|
265
|
+
|
|
266
|
+
with profile.section("check_type_scope_consistency", collector):
|
|
267
|
+
_warn_type_scope_consistency(message, stat)
|
|
268
|
+
with profile.section("format_commit_message", collector):
|
|
269
|
+
formatted_message = message.format_commit_message()
|
|
270
|
+
with profile.section("display_output", collector):
|
|
271
|
+
_print_message(message, title="Generated Commit Message")
|
|
272
|
+
|
|
273
|
+
if args.copy:
|
|
274
|
+
with profile.section("copy_to_clipboard", collector):
|
|
275
|
+
try:
|
|
276
|
+
_copy_to_clipboard(formatted_message)
|
|
277
|
+
style.status(f"\n{style.success('Copied to clipboard')}")
|
|
278
|
+
except Exception as exc:
|
|
279
|
+
style.status(f"\nNote: Failed to copy to clipboard ({type(exc).__name__}): {exc}")
|
|
280
|
+
|
|
281
|
+
if mode in (Mode.STAGED, Mode.UNSTAGED):
|
|
282
|
+
if validation_failed:
|
|
283
|
+
print(
|
|
284
|
+
f"\n{style.warning('Skipping commit due to validation failure. Use --dry-run to test or manually commit.')}",
|
|
285
|
+
file=sys.stderr,
|
|
286
|
+
)
|
|
287
|
+
raise ValidationFailure("Commit message validation failed", field="commit")
|
|
288
|
+
|
|
289
|
+
snapshot_tree = staged_index_tree
|
|
290
|
+
if mode is Mode.UNSTAGED and not args.dry_run:
|
|
291
|
+
with profile.section("stage_all", collector):
|
|
292
|
+
_stage_all(args.dir)
|
|
293
|
+
|
|
294
|
+
if _should_update_changelog(args, config, mode) and not args.dry_run:
|
|
295
|
+
async with profile.section("run_changelog_flow", collector):
|
|
296
|
+
await run_changelog_flow(args, config)
|
|
297
|
+
with profile.section("write_real_index_tree_after_changelog", collector):
|
|
298
|
+
snapshot_tree = git.write_real_index_tree(args.dir)
|
|
299
|
+
elif mode is Mode.UNSTAGED and not args.dry_run:
|
|
300
|
+
with profile.section("write_real_index_tree_after_stage", collector):
|
|
301
|
+
snapshot_tree = git.write_real_index_tree(args.dir)
|
|
302
|
+
|
|
303
|
+
style.status(f"\n{style.info('Preparing to commit...')}")
|
|
304
|
+
with profile.section("git_commit", collector):
|
|
305
|
+
commit_hash = _commit_staged_message(formatted_message, snapshot_tree, args, config)
|
|
306
|
+
if commit_hash:
|
|
307
|
+
print(commit_hash)
|
|
308
|
+
if args.push and not args.dry_run:
|
|
309
|
+
with profile.section("git_push", collector):
|
|
310
|
+
_push_changes(args.dir)
|
|
311
|
+
|
|
312
|
+
return 0
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
async def _run_compose(args: argparse.Namespace, config: CommitConfig) -> int:
|
|
316
|
+
from .compose import run_compose_mode
|
|
317
|
+
|
|
318
|
+
hashes = await run_compose_mode(args, config)
|
|
319
|
+
if hashes:
|
|
320
|
+
for commit_hash in hashes:
|
|
321
|
+
print(commit_hash)
|
|
322
|
+
elif args.compose_preview:
|
|
323
|
+
print("Compose preview written; no commits created.")
|
|
324
|
+
else:
|
|
325
|
+
print("No compose commits created.")
|
|
326
|
+
if hashes and args.push:
|
|
327
|
+
git.run_git(["push"], cwd=args.dir)
|
|
328
|
+
return 0
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
async def _run_rewrite(args: argparse.Namespace, config: CommitConfig) -> int:
|
|
332
|
+
from .rewrite import run_rewrite_mode
|
|
333
|
+
|
|
334
|
+
result = await run_rewrite_mode(args, config)
|
|
335
|
+
for conversion in result.conversions:
|
|
336
|
+
old = conversion.old_subject
|
|
337
|
+
new = conversion.new_subject or "(unchanged)"
|
|
338
|
+
print(f"{conversion.index}. {old} -> {new}")
|
|
339
|
+
if result.backup_branch:
|
|
340
|
+
print(f"Backup branch: {result.backup_branch}")
|
|
341
|
+
return 0
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
async def _run_test_mode(args: argparse.Namespace, config: CommitConfig) -> int:
|
|
345
|
+
try:
|
|
346
|
+
from .testing import run_test_mode as runner
|
|
347
|
+
except ImportError:
|
|
348
|
+
from .testing.runner import run_test_mode as runner
|
|
349
|
+
try:
|
|
350
|
+
result = runner(args, config)
|
|
351
|
+
if inspect.isawaitable(result):
|
|
352
|
+
result = await result
|
|
353
|
+
except RuntimeError as exc:
|
|
354
|
+
print(f"lgit test: {exc}", file=sys.stderr)
|
|
355
|
+
return 1
|
|
356
|
+
if isinstance(result, int):
|
|
357
|
+
return result
|
|
358
|
+
all_passed = getattr(result, "all_passed", None)
|
|
359
|
+
if callable(all_passed):
|
|
360
|
+
return 0 if all_passed() else 1
|
|
361
|
+
return 0
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _load_config(args: argparse.Namespace) -> CommitConfig:
|
|
365
|
+
config = CommitConfig.load(args.config)
|
|
366
|
+
if args.model:
|
|
367
|
+
resolved_model = resolve_model_name(args.model)
|
|
368
|
+
config.analysis_model = resolved_model
|
|
369
|
+
config.summary_model = resolved_model
|
|
370
|
+
if args.sign:
|
|
371
|
+
config.gpg_sign = True
|
|
372
|
+
if args.signoff:
|
|
373
|
+
config.signoff = True
|
|
374
|
+
if args.no_changelog:
|
|
375
|
+
config.changelog_enabled = False
|
|
376
|
+
if args.exclude_old_message:
|
|
377
|
+
config.exclude_old_message = True
|
|
378
|
+
return config
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _timing_collector(args: argparse.Namespace) -> profile.TimingCollector | None:
|
|
382
|
+
return getattr(args, "_timing_collector", None)
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _read_change_inputs(mode: Mode, args: argparse.Namespace, config: CommitConfig) -> tuple[str, str, str]:
|
|
386
|
+
return (
|
|
387
|
+
git.get_git_diff(mode, args.target, args.dir, config),
|
|
388
|
+
git.get_git_stat(mode, args.target, args.dir, config),
|
|
389
|
+
git.get_git_numstat(mode, args.target, args.dir, config),
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _auto_stage_if_needed(args: argparse.Namespace, config: CommitConfig) -> None:
|
|
394
|
+
try:
|
|
395
|
+
git.get_git_diff(Mode.STAGED, args.target, args.dir, config)
|
|
396
|
+
except NoChanges:
|
|
397
|
+
has_unstaged = True
|
|
398
|
+
try:
|
|
399
|
+
git.get_git_diff(Mode.UNSTAGED, args.target, args.dir, config)
|
|
400
|
+
except NoChanges:
|
|
401
|
+
has_unstaged = False
|
|
402
|
+
untracked = git.run_git(["ls-files", "--others", "--exclude-standard"], cwd=args.dir).stdout
|
|
403
|
+
if not has_unstaged and not untracked:
|
|
404
|
+
raise NoChanges("working directory (nothing to commit)") from None
|
|
405
|
+
style.status(f"{style.info('›')} {style.dim('No staged changes; running git add -A')}")
|
|
406
|
+
_stage_all(args.dir)
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _stage_all(dir: str | os.PathLike[str]) -> None:
|
|
410
|
+
git.run_git(["add", "-A"], cwd=dir)
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
async def _generate_fast_workflow(
|
|
414
|
+
mode: Mode,
|
|
415
|
+
config: CommitConfig,
|
|
416
|
+
args: argparse.Namespace,
|
|
417
|
+
user_context: str | None,
|
|
418
|
+
diff: str,
|
|
419
|
+
stat: str,
|
|
420
|
+
numstat: str,
|
|
421
|
+
collector: profile.TimingCollector | None = None,
|
|
422
|
+
) -> ConventionalCommit:
|
|
423
|
+
with profile.section("strip_whitespace_only", collector):
|
|
424
|
+
diff = strip_whitespace_only_files(diff) or diff
|
|
425
|
+
with profile.section("truncate_diff_by_lines", collector):
|
|
426
|
+
diff = truncate_diff_by_lines(diff, 10_000, config)
|
|
427
|
+
with profile.section("extract_scope_candidates", collector):
|
|
428
|
+
scope_candidates, _wide = (
|
|
429
|
+
extract_scope_candidates(numstat, args.target, args.dir, config) if numstat.strip() else ("(none)", False)
|
|
430
|
+
)
|
|
431
|
+
style.status(f"{style.dim('›')} {style.dim('fast mode:')} {style.model(_resolve_fast_mode_model(args, config))}")
|
|
432
|
+
style.status(f"{style.info('›')} Analyzing {style.bold(mode.value)} changes...")
|
|
433
|
+
return await _generate_fast_message(config, stat, diff, scope_candidates, user_context, args, collector)
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
async def _generate_standard_workflow(
|
|
437
|
+
mode: Mode,
|
|
438
|
+
args: argparse.Namespace,
|
|
439
|
+
config: CommitConfig,
|
|
440
|
+
user_context: str | None,
|
|
441
|
+
diff: str,
|
|
442
|
+
stat: str,
|
|
443
|
+
numstat: str,
|
|
444
|
+
collector: profile.TimingCollector | None = None,
|
|
445
|
+
) -> ConventionalCommit:
|
|
446
|
+
style.status(f"{style.info('›')} Analyzing {style.bold(mode.value)} changes...")
|
|
447
|
+
with profile.section("strip_whitespace_only", collector):
|
|
448
|
+
diff = strip_whitespace_only_files(diff) or diff
|
|
449
|
+
with profile.section("extract_scope_candidates", collector):
|
|
450
|
+
scope_candidates, _wide = (
|
|
451
|
+
extract_scope_candidates(numstat, args.target, args.dir, config) if numstat.strip() else ("(none)", False)
|
|
452
|
+
)
|
|
453
|
+
with profile.section("create_token_counter", collector):
|
|
454
|
+
token_counter = create_token_counter(config)
|
|
455
|
+
style.status(
|
|
456
|
+
f"{style.dim('›')} {style.dim('models:')} {style.dim('analysis')} "
|
|
457
|
+
f"{style.model(config.analysis_model)} {style.dim('summary')} {style.model(config.summary_model)}"
|
|
458
|
+
)
|
|
459
|
+
with profile.section("prepare_diff", collector):
|
|
460
|
+
if should_use_map_reduce(diff, config, token_counter):
|
|
461
|
+
analysis_diff = diff
|
|
462
|
+
elif len(diff) > int(config.max_diff_length):
|
|
463
|
+
print(style.warning(f"Applying smart truncation (diff size: {len(diff)} characters)"))
|
|
464
|
+
analysis_diff = smart_truncate_diff(diff, int(config.max_diff_length), config, token_counter)
|
|
465
|
+
else:
|
|
466
|
+
analysis_diff = diff
|
|
467
|
+
analysis = await _generate_analysis(config, stat, analysis_diff, scope_candidates, user_context, args, collector)
|
|
468
|
+
return await _message_from_analysis(analysis, config, stat, user_context, args, collector)
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def _resolve_fast_mode_model(args: argparse.Namespace, config: CommitConfig) -> str:
|
|
472
|
+
return str(config.analysis_model if args.model or config.legacy_model else resolve_model_name("haiku"))
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
async def _generate_fast_message(
|
|
476
|
+
config: CommitConfig,
|
|
477
|
+
stat: str,
|
|
478
|
+
diff: str,
|
|
479
|
+
scope_candidates: str,
|
|
480
|
+
user_context: str | None,
|
|
481
|
+
args: argparse.Namespace,
|
|
482
|
+
collector: profile.TimingCollector | None = None,
|
|
483
|
+
) -> ConventionalCommit:
|
|
484
|
+
fast_config = replace(config, analysis_model=_resolve_fast_mode_model(args, config))
|
|
485
|
+
try:
|
|
486
|
+
async with profile.section("generate_fast_commit", collector):
|
|
487
|
+
message = await generate_fast_commit(
|
|
488
|
+
fast_config,
|
|
489
|
+
stat,
|
|
490
|
+
diff,
|
|
491
|
+
scope_candidates,
|
|
492
|
+
user_context=user_context,
|
|
493
|
+
debug_output=args.debug_output,
|
|
494
|
+
debug_prefix="fast",
|
|
495
|
+
)
|
|
496
|
+
with profile.section("validate_fast_commit", collector):
|
|
497
|
+
if validate_commit_message(message, config, stat=stat).ok:
|
|
498
|
+
return message
|
|
499
|
+
except Exception:
|
|
500
|
+
pass
|
|
501
|
+
analysis = await _generate_analysis(config, stat, diff, scope_candidates, user_context, args, collector)
|
|
502
|
+
return await _message_from_analysis(analysis, config, stat, user_context, args, collector)
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
async def _generate_analysis(
|
|
506
|
+
config: CommitConfig,
|
|
507
|
+
stat: str,
|
|
508
|
+
diff: str,
|
|
509
|
+
scope_candidates: str,
|
|
510
|
+
user_context: str | None,
|
|
511
|
+
args: argparse.Namespace,
|
|
512
|
+
collector: profile.TimingCollector | None = None,
|
|
513
|
+
) -> ConventionalAnalysis:
|
|
514
|
+
with profile.section("collect_analysis_context", collector):
|
|
515
|
+
project_context = None
|
|
516
|
+
detected = repo.detect(args.dir)
|
|
517
|
+
if detected is not None:
|
|
518
|
+
project_context = detected.format_for_prompt()
|
|
519
|
+
common_scopes = _format_common_scopes(args.dir)
|
|
520
|
+
recent_commits = _format_recent_commits(args.dir)
|
|
521
|
+
async with profile.section("generate_analysis", collector):
|
|
522
|
+
return await generate_analysis_with_map_reduce(
|
|
523
|
+
config,
|
|
524
|
+
stat,
|
|
525
|
+
diff,
|
|
526
|
+
scope_candidates,
|
|
527
|
+
user_context=user_context,
|
|
528
|
+
recent_commits=recent_commits,
|
|
529
|
+
common_scopes=common_scopes,
|
|
530
|
+
project_context=project_context,
|
|
531
|
+
debug_output=args.debug_output,
|
|
532
|
+
debug_prefix="analysis",
|
|
533
|
+
)
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
async def _message_from_analysis(
|
|
537
|
+
analysis: ConventionalAnalysis,
|
|
538
|
+
config: CommitConfig,
|
|
539
|
+
stat: str,
|
|
540
|
+
user_context: str | None,
|
|
541
|
+
args: argparse.Namespace,
|
|
542
|
+
collector: profile.TimingCollector | None = None,
|
|
543
|
+
) -> ConventionalCommit:
|
|
544
|
+
commit_type = str(analysis.commit_type)
|
|
545
|
+
scope = None if analysis.scope is None else str(analysis.scope)
|
|
546
|
+
summary = analysis.summary or ""
|
|
547
|
+
max_retries = max(1, int(config.max_retries))
|
|
548
|
+
for attempt in range(max_retries):
|
|
549
|
+
if not summary or attempt > 0:
|
|
550
|
+
async with profile.section("generate_summary", collector):
|
|
551
|
+
summary = await generate_summary_from_analysis(
|
|
552
|
+
config,
|
|
553
|
+
analysis,
|
|
554
|
+
stat,
|
|
555
|
+
user_context=user_context,
|
|
556
|
+
debug_output=args.debug_output,
|
|
557
|
+
debug_prefix=f"summary-{attempt + 1}",
|
|
558
|
+
)
|
|
559
|
+
with profile.section("validate_summary_quality", collector):
|
|
560
|
+
summary_report = validate_summary_quality(summary, commit_type, stat)
|
|
561
|
+
if summary_report.ok:
|
|
562
|
+
break
|
|
563
|
+
summary = ""
|
|
564
|
+
if not summary:
|
|
565
|
+
with profile.section("fallback_summary", collector):
|
|
566
|
+
summary = fallback_summary(stat, analysis.body_texts(), limit=int(config.summary_guideline))
|
|
567
|
+
with profile.section("build_commit_message", collector):
|
|
568
|
+
message = ConventionalCommit.from_raw(
|
|
569
|
+
commit_type=commit_type,
|
|
570
|
+
scope=scope,
|
|
571
|
+
summary=summary,
|
|
572
|
+
body=analysis.body_texts(),
|
|
573
|
+
summary_max_length=int(config.summary_hard_limit),
|
|
574
|
+
)
|
|
575
|
+
return post_process_commit_message(message, config)
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def _with_cli_footers(
|
|
579
|
+
message: ConventionalCommit, args: argparse.Namespace, config: CommitConfig
|
|
580
|
+
) -> ConventionalCommit:
|
|
581
|
+
footers = [*message.footers, *_cli_footers(args)]
|
|
582
|
+
if not footers:
|
|
583
|
+
return message
|
|
584
|
+
updated = ConventionalCommit.from_raw(
|
|
585
|
+
commit_type=str(message.commit_type),
|
|
586
|
+
scope=None if message.scope is None else str(message.scope),
|
|
587
|
+
summary=str(message.summary),
|
|
588
|
+
body=message.body,
|
|
589
|
+
footers=footers,
|
|
590
|
+
summary_max_length=int(config.summary_hard_limit),
|
|
591
|
+
)
|
|
592
|
+
return post_process_commit_message(updated, config)
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def _cli_footers(args: argparse.Namespace) -> list[str]:
|
|
596
|
+
footers: list[str] = []
|
|
597
|
+
for attr, label in (("fixes", "Fixes"), ("closes", "Closes"), ("resolves", "Resolves"), ("refs", "Refs")):
|
|
598
|
+
for value in _flatten(getattr(args, attr, None)):
|
|
599
|
+
footers.append(f"{label} #{value.strip().lstrip('#')}")
|
|
600
|
+
if args.breaking:
|
|
601
|
+
footers.append("BREAKING CHANGE: This commit introduces breaking changes")
|
|
602
|
+
return footers
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
async def _validate_and_process(
|
|
606
|
+
message: ConventionalCommit,
|
|
607
|
+
stat: str,
|
|
608
|
+
detail_points: tuple[str, ...],
|
|
609
|
+
user_context: str | None,
|
|
610
|
+
config: CommitConfig,
|
|
611
|
+
args: argparse.Namespace,
|
|
612
|
+
collector: profile.TimingCollector | None = None,
|
|
613
|
+
) -> tuple[ConventionalCommit, str | None]:
|
|
614
|
+
current = message
|
|
615
|
+
validation_error: str | None = None
|
|
616
|
+
for attempt in range(3):
|
|
617
|
+
with profile.section("post_process_commit_message", collector):
|
|
618
|
+
current = post_process_commit_message(current, config)
|
|
619
|
+
if attempt == 0 and _first_line_length(current) > int(config.summary_soft_limit):
|
|
620
|
+
print(f"Summary too long ({_first_line_length(current)} chars), retrying generation...", file=sys.stderr)
|
|
621
|
+
try:
|
|
622
|
+
retry_analysis = ConventionalAnalysis(
|
|
623
|
+
commit_type=str(current.commit_type),
|
|
624
|
+
scope=None if current.scope is None else str(current.scope),
|
|
625
|
+
details=detail_points,
|
|
626
|
+
)
|
|
627
|
+
async with profile.section("generate_validation_retry_summary", collector):
|
|
628
|
+
summary = await generate_summary_from_analysis(
|
|
629
|
+
config,
|
|
630
|
+
retry_analysis,
|
|
631
|
+
stat,
|
|
632
|
+
user_context=user_context,
|
|
633
|
+
debug_output=None,
|
|
634
|
+
debug_prefix=None,
|
|
635
|
+
)
|
|
636
|
+
except Exception as exc:
|
|
637
|
+
print(f"Retry generation failed ({type(exc).__name__}): {exc}, using fallback", file=sys.stderr)
|
|
638
|
+
with profile.section("fallback_validation_summary", collector):
|
|
639
|
+
summary = fallback_summary(stat, detail_points, limit=int(config.summary_guideline))
|
|
640
|
+
current = _replace_commit(current, summary=summary, config=config, args=args)
|
|
641
|
+
continue
|
|
642
|
+
|
|
643
|
+
with profile.section("validate_commit_message", collector):
|
|
644
|
+
report = validate_commit_message(current, config, stat=stat, project_names=_project_names(args.dir))
|
|
645
|
+
if report.ok:
|
|
646
|
+
return current, None
|
|
647
|
+
|
|
648
|
+
if current.scope is not None and any(issue.code == "project_name_scope" for issue in report.errors):
|
|
649
|
+
style.warn("Scope matches project name, removing scope...")
|
|
650
|
+
current = _replace_commit(current, scope=None, config=config, args=args)
|
|
651
|
+
with profile.section("validate_commit_message_after_scope_removal", collector):
|
|
652
|
+
report = validate_commit_message(current, config, stat=stat, project_names=_project_names(args.dir))
|
|
653
|
+
if report.ok:
|
|
654
|
+
return current, None
|
|
655
|
+
print(
|
|
656
|
+
f"Validation failed after scope removal: {'; '.join(issue.message for issue in report.errors)}",
|
|
657
|
+
file=sys.stderr,
|
|
658
|
+
)
|
|
659
|
+
|
|
660
|
+
validation_error = "; ".join(issue.message for issue in report.errors)
|
|
661
|
+
print(f"Validation attempt {attempt + 1} failed: {validation_error}", file=sys.stderr)
|
|
662
|
+
if attempt < 2:
|
|
663
|
+
with profile.section("fallback_validation_summary", collector):
|
|
664
|
+
summary = fallback_summary(stat, detail_points, limit=int(config.summary_guideline))
|
|
665
|
+
current = _replace_commit(current, summary=summary, config=config, args=args)
|
|
666
|
+
continue
|
|
667
|
+
break
|
|
668
|
+
return current, validation_error
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
def _first_line_length(message: ConventionalCommit) -> int:
|
|
672
|
+
return len(message.format_commit_message().splitlines()[0])
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
def _replace_commit(
|
|
676
|
+
message: ConventionalCommit,
|
|
677
|
+
*,
|
|
678
|
+
summary: str | None = None,
|
|
679
|
+
scope: str | None | object = ...,
|
|
680
|
+
config: CommitConfig,
|
|
681
|
+
args: argparse.Namespace,
|
|
682
|
+
) -> ConventionalCommit:
|
|
683
|
+
updated = ConventionalCommit.from_raw(
|
|
684
|
+
commit_type=str(message.commit_type),
|
|
685
|
+
scope=(None if message.scope is None else str(message.scope)) if scope is ... else scope,
|
|
686
|
+
summary=str(message.summary) if summary is None else summary,
|
|
687
|
+
body=message.body,
|
|
688
|
+
footers=message.footers,
|
|
689
|
+
summary_max_length=int(config.summary_hard_limit),
|
|
690
|
+
)
|
|
691
|
+
return post_process_commit_message(updated, config)
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
def _detect_reformat_shortcut(diff: str, config: CommitConfig, args: argparse.Namespace) -> ConventionalCommit | None:
|
|
695
|
+
report = classify_diff_whitespace(diff)
|
|
696
|
+
if not report.all_whitespace:
|
|
697
|
+
return None
|
|
698
|
+
return _build_reformat_commit(report.whitespace_only_files, config, args)
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
def _build_reformat_commit(files: Sequence[str], config: CommitConfig, args: argparse.Namespace) -> ConventionalCommit:
|
|
702
|
+
if len(files) == 1:
|
|
703
|
+
name = files[0].rsplit("/", 1)[-1]
|
|
704
|
+
summary = f"reformatted {name}"
|
|
705
|
+
else:
|
|
706
|
+
summary = f"reformatted {len(files)} files"
|
|
707
|
+
message = ConventionalCommit.from_raw(
|
|
708
|
+
commit_type="style",
|
|
709
|
+
summary=summary,
|
|
710
|
+
summary_max_length=int(config.summary_hard_limit),
|
|
711
|
+
)
|
|
712
|
+
return post_process_commit_message(message, config)
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
def _auto_fast_changed_lines(numstat: str, config: CommitConfig) -> int | None:
|
|
716
|
+
if int(config.auto_fast_threshold_lines) == 0:
|
|
717
|
+
return None
|
|
718
|
+
changed_lines = ScopeAnalyzer.count_changed_lines(numstat, config)
|
|
719
|
+
if changed_lines == 0 or changed_lines > int(config.auto_fast_threshold_lines):
|
|
720
|
+
return None
|
|
721
|
+
return changed_lines
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
def _commit_staged_message(
|
|
725
|
+
message: str, snapshot_tree: str | None, args: argparse.Namespace, config: CommitConfig
|
|
726
|
+
) -> str | None:
|
|
727
|
+
sign = bool(args.sign or config.gpg_sign)
|
|
728
|
+
signoff = bool(args.signoff or config.signoff)
|
|
729
|
+
if args.dry_run:
|
|
730
|
+
_print_dry_run_commit(message, args, config)
|
|
731
|
+
return None
|
|
732
|
+
if snapshot_tree is not None and not git.index_matches_tree(snapshot_tree, args.dir):
|
|
733
|
+
style.status(
|
|
734
|
+
f"{style.info('›')} "
|
|
735
|
+
f"{style.dim('Index changed during generation; committing the analyzed snapshot (hooks skipped)')}"
|
|
736
|
+
)
|
|
737
|
+
commit_hash = git.commit_snapshot_tree(
|
|
738
|
+
message, snapshot_tree, args.dir, sign=sign, signoff=signoff, amend=args.amend
|
|
739
|
+
)
|
|
740
|
+
if commit_hash:
|
|
741
|
+
style.status(
|
|
742
|
+
f"{style.success(style.icons.SUCCESS)} {style.success(f'Successfully committed snapshot as {commit_hash[:8]}')}"
|
|
743
|
+
)
|
|
744
|
+
else:
|
|
745
|
+
style.status(f"{style.info('›')} {style.dim('Snapshot already committed; nothing to do')}")
|
|
746
|
+
return commit_hash
|
|
747
|
+
|
|
748
|
+
git_args = ["commit", "-F", "-"]
|
|
749
|
+
if sign:
|
|
750
|
+
git_args.append("-S")
|
|
751
|
+
if signoff:
|
|
752
|
+
git_args.append("--signoff")
|
|
753
|
+
if args.skip_hooks:
|
|
754
|
+
git_args.append("--no-verify")
|
|
755
|
+
if args.amend:
|
|
756
|
+
git_args.append("--amend")
|
|
757
|
+
git.run_git(git_args, cwd=args.dir, input_text=message)
|
|
758
|
+
commit_hash = git.get_head_hash(args.dir)
|
|
759
|
+
style.status(
|
|
760
|
+
f"{style.success(style.icons.SUCCESS)} {style.success(f'Successfully committed as {commit_hash[:8]}')}"
|
|
761
|
+
)
|
|
762
|
+
return commit_hash
|
|
763
|
+
|
|
764
|
+
|
|
765
|
+
def _print_message(message: ConventionalCommit, *, title: str) -> None:
|
|
766
|
+
text = message.format_commit_message()
|
|
767
|
+
if style.pipe_mode():
|
|
768
|
+
sys.stdout.write(text)
|
|
769
|
+
return
|
|
770
|
+
print(f"\n{style.boxed_message(title, text, style.term_width())}")
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
def _print_dry_run_commit(message: str, args: argparse.Namespace, config: CommitConfig) -> None:
|
|
774
|
+
sign_flag = " -S" if bool(args.sign or config.gpg_sign) else ""
|
|
775
|
+
signoff_flag = " -s" if bool(args.signoff or config.signoff) else ""
|
|
776
|
+
hooks_flag = " --no-verify" if args.skip_hooks else ""
|
|
777
|
+
amend_flag = " --amend" if args.amend else ""
|
|
778
|
+
escaped = message.replace("\n", "\\n")
|
|
779
|
+
command = f'git commit{sign_flag}{signoff_flag}{hooks_flag}{amend_flag} -m "{escaped}"'
|
|
780
|
+
output = style.boxed_message("DRY RUN", command, min(style.term_width(), 60))
|
|
781
|
+
style.status(f"\n{output}")
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
def _copy_to_clipboard(text: str) -> None:
|
|
785
|
+
candidates: list[list[str]] = []
|
|
786
|
+
system = platform.system().lower()
|
|
787
|
+
if system == "darwin":
|
|
788
|
+
candidates.append(["pbcopy"])
|
|
789
|
+
elif system == "windows":
|
|
790
|
+
candidates.append(["clip"])
|
|
791
|
+
else:
|
|
792
|
+
candidates.extend((["wl-copy"], ["xclip", "-selection", "clipboard"], ["xsel", "--clipboard", "--input"]))
|
|
793
|
+
for command in candidates:
|
|
794
|
+
if shutil.which(command[0]):
|
|
795
|
+
subprocess.run(command, input=text, text=True, check=True)
|
|
796
|
+
return
|
|
797
|
+
raise ValidationFailure("no supported clipboard command found", field="copy")
|
|
798
|
+
|
|
799
|
+
|
|
800
|
+
def _warn_type_scope_consistency(message: ConventionalCommit, stat: str) -> None:
|
|
801
|
+
report = check_type_scope_consistency(message, stat)
|
|
802
|
+
for issue in report.warnings:
|
|
803
|
+
style.warn(issue.message)
|
|
804
|
+
|
|
805
|
+
|
|
806
|
+
def _should_update_changelog(args: argparse.Namespace, config: CommitConfig, mode: Mode) -> bool:
|
|
807
|
+
return mode in (Mode.STAGED, Mode.UNSTAGED) and bool(config.changelog_enabled) and not bool(args.no_changelog)
|
|
808
|
+
|
|
809
|
+
|
|
810
|
+
def _push_changes(dir: str | os.PathLike[str]) -> None:
|
|
811
|
+
style.status(f"\n{style.info('Pushing changes...')}")
|
|
812
|
+
result = git.run_git(["push"], cwd=dir)
|
|
813
|
+
if result.stdout:
|
|
814
|
+
style.status_text(result.stdout)
|
|
815
|
+
if result.stderr:
|
|
816
|
+
style.status_text(result.stderr)
|
|
817
|
+
style.status(f"{style.success(style.icons.SUCCESS)} {style.success('Successfully pushed!')}")
|
|
818
|
+
|
|
819
|
+
|
|
820
|
+
def _format_recent_commits(dir: str | os.PathLike[str]) -> str | None:
|
|
821
|
+
try:
|
|
822
|
+
commits = git.get_recent_commits(dir, count=10)
|
|
823
|
+
except LgitError:
|
|
824
|
+
return None
|
|
825
|
+
return "\n".join(commits) if commits else None
|
|
826
|
+
|
|
827
|
+
|
|
828
|
+
def _format_common_scopes(dir: str | os.PathLike[str]) -> str | None:
|
|
829
|
+
try:
|
|
830
|
+
scopes = git.get_common_scopes(dir, limit=100)
|
|
831
|
+
except LgitError:
|
|
832
|
+
return None
|
|
833
|
+
if not scopes:
|
|
834
|
+
return None
|
|
835
|
+
return ", ".join(f"{scope} ({count})" for scope, count in scopes[:10])
|
|
836
|
+
|
|
837
|
+
|
|
838
|
+
def _project_names(dir: str | os.PathLike[str]) -> tuple[str, ...]:
|
|
839
|
+
"""Names that count as project-wide for scope dropping.
|
|
840
|
+
|
|
841
|
+
Combines the repository directory name with the repo's sole top-level Python
|
|
842
|
+
package (e.g. ``lgit`` in the ``llm-git`` repo), so a scope naming the whole
|
|
843
|
+
project is dropped rather than kept. A multi-package repo contributes only its
|
|
844
|
+
directory name, leaving per-package scopes meaningful.
|
|
845
|
+
"""
|
|
846
|
+
try:
|
|
847
|
+
root = git.run_git(["rev-parse", "--show-toplevel"], cwd=dir).stdout.strip()
|
|
848
|
+
except LgitError:
|
|
849
|
+
return ()
|
|
850
|
+
if not root:
|
|
851
|
+
return ()
|
|
852
|
+
root_path = Path(root)
|
|
853
|
+
names = [root_path.name]
|
|
854
|
+
packages = [child.name for child in root_path.iterdir() if (child / "__init__.py").is_file()]
|
|
855
|
+
if len(packages) == 1:
|
|
856
|
+
names.append(packages[0])
|
|
857
|
+
return tuple(dict.fromkeys(names))
|
|
858
|
+
|
|
859
|
+
|
|
860
|
+
def _flatten(values: Iterable[Iterable[str]] | None) -> list[str]:
|
|
861
|
+
if not values:
|
|
862
|
+
return []
|
|
863
|
+
return [item for group in values for item in group if item]
|
|
864
|
+
|
|
865
|
+
|
|
866
|
+
def _wants_compose(args: argparse.Namespace) -> bool:
|
|
867
|
+
return bool(args.compose or args.compose_preview or args.mode == Mode.COMPOSE.value)
|
|
868
|
+
|
|
869
|
+
|
|
870
|
+
def _wants_rewrite(args: argparse.Namespace) -> bool:
|
|
871
|
+
return bool(args.rewrite or args.rewrite_preview is not None or args.rewrite_dry_run)
|
|
872
|
+
|
|
873
|
+
|
|
874
|
+
def _wants_test(args: argparse.Namespace) -> bool:
|
|
875
|
+
return bool(
|
|
876
|
+
args.test
|
|
877
|
+
or args.test_update
|
|
878
|
+
or args.test_add
|
|
879
|
+
or args.test_name
|
|
880
|
+
or args.test_filter
|
|
881
|
+
or args.test_list
|
|
882
|
+
or args.fixtures_dir
|
|
883
|
+
or args.test_report
|
|
884
|
+
)
|
|
885
|
+
|
|
886
|
+
|
|
887
|
+
def _validate_arg_conflicts(parser: argparse.ArgumentParser, args: argparse.Namespace) -> None:
|
|
888
|
+
selected_routes = [
|
|
889
|
+
name
|
|
890
|
+
for name, active in (
|
|
891
|
+
("--compose", _wants_compose(args)),
|
|
892
|
+
("--rewrite", _wants_rewrite(args)),
|
|
893
|
+
("--test", _wants_test(args)),
|
|
894
|
+
)
|
|
895
|
+
if active
|
|
896
|
+
]
|
|
897
|
+
if len(selected_routes) > 1:
|
|
898
|
+
parser.error(f"conflicting modes: {' and '.join(selected_routes)}")
|
|
899
|
+
if args.fast and selected_routes:
|
|
900
|
+
parser.error("--fast cannot be combined with compose, rewrite, or test mode")
|
|
901
|
+
if args.mode == Mode.COMMIT.value and not args.target:
|
|
902
|
+
parser.error("--target is required with --mode commit")
|
|
903
|
+
if args.test_add and not args.test_name:
|
|
904
|
+
parser.error("--test-name is required with --test-add")
|
|
905
|
+
if args.rewrite_parallel is not None and args.rewrite_parallel < 1:
|
|
906
|
+
parser.error("--rewrite-parallel must be at least 1")
|
|
907
|
+
if args.rewrite_preview is not None and args.rewrite_preview < 0:
|
|
908
|
+
parser.error("--rewrite-preview must be non-negative")
|
|
909
|
+
if args.compose_max_commits is not None and args.compose_max_commits < 1:
|
|
910
|
+
parser.error("--compose-max-commits must be at least 1")
|
|
911
|
+
|
|
912
|
+
|
|
913
|
+
def _completion_script(shell: str) -> str:
|
|
914
|
+
specs = _completion_specs(build_parser())
|
|
915
|
+
if shell == "bash":
|
|
916
|
+
return _bash_completion(specs)
|
|
917
|
+
if shell == "zsh":
|
|
918
|
+
return _zsh_completion(specs)
|
|
919
|
+
if shell == "fish":
|
|
920
|
+
return _fish_completion(specs)
|
|
921
|
+
if shell == "powershell":
|
|
922
|
+
return _powershell_completion(specs)
|
|
923
|
+
if shell == "elvish":
|
|
924
|
+
return _elvish_completion(specs)
|
|
925
|
+
raise ValueError(f"unsupported shell: {shell}")
|
|
926
|
+
|
|
927
|
+
|
|
928
|
+
def _completion_specs(parser: argparse.ArgumentParser) -> list[dict[str, Any]]:
|
|
929
|
+
specs: list[dict[str, Any]] = []
|
|
930
|
+
for action in parser._actions:
|
|
931
|
+
if not action.option_strings or action.help is argparse.SUPPRESS:
|
|
932
|
+
continue
|
|
933
|
+
choices = tuple(str(choice) for choice in action.choices) if action.choices is not None else ()
|
|
934
|
+
specs.append(
|
|
935
|
+
{
|
|
936
|
+
"options": tuple(action.option_strings),
|
|
937
|
+
"help": (action.help or "").rstrip("."),
|
|
938
|
+
"choices": choices,
|
|
939
|
+
"takes_value": _action_takes_value(action),
|
|
940
|
+
"metavar": _completion_metavar(action),
|
|
941
|
+
}
|
|
942
|
+
)
|
|
943
|
+
return specs
|
|
944
|
+
|
|
945
|
+
|
|
946
|
+
def _action_takes_value(action: argparse.Action) -> bool:
|
|
947
|
+
return not isinstance(
|
|
948
|
+
action,
|
|
949
|
+
(
|
|
950
|
+
argparse._HelpAction,
|
|
951
|
+
argparse._StoreConstAction,
|
|
952
|
+
argparse._StoreFalseAction,
|
|
953
|
+
argparse._StoreTrueAction,
|
|
954
|
+
),
|
|
955
|
+
)
|
|
956
|
+
|
|
957
|
+
|
|
958
|
+
def _completion_metavar(action: argparse.Action) -> str:
|
|
959
|
+
if action.metavar is not None:
|
|
960
|
+
if isinstance(action.metavar, tuple):
|
|
961
|
+
return str(action.metavar[0]).lower()
|
|
962
|
+
return str(action.metavar).lower()
|
|
963
|
+
if action.dest == argparse.SUPPRESS:
|
|
964
|
+
return "value"
|
|
965
|
+
return str(action.dest).replace("_", "-")
|
|
966
|
+
|
|
967
|
+
|
|
968
|
+
def _bash_completion(specs: list[dict[str, Any]]) -> str:
|
|
969
|
+
options = " ".join(option for spec in specs for option in spec["options"])
|
|
970
|
+
cases = []
|
|
971
|
+
for spec in specs:
|
|
972
|
+
choices = spec["choices"]
|
|
973
|
+
if not choices:
|
|
974
|
+
continue
|
|
975
|
+
patterns = "|".join(spec["options"])
|
|
976
|
+
cases.append(
|
|
977
|
+
f" {patterns})\n"
|
|
978
|
+
f' COMPREPLY=( $(compgen -W {_sh_single_quote(" ".join(choices))} -- "$cur") )\n'
|
|
979
|
+
" return\n"
|
|
980
|
+
" ;;"
|
|
981
|
+
)
|
|
982
|
+
case_block = "\n".join(cases)
|
|
983
|
+
return (
|
|
984
|
+
"# bash completion for lgit\n"
|
|
985
|
+
"_lgit() {\n"
|
|
986
|
+
" local cur prev\n"
|
|
987
|
+
" cur=${COMP_WORDS[COMP_CWORD]}\n"
|
|
988
|
+
" prev=${COMP_WORDS[COMP_CWORD-1]}\n"
|
|
989
|
+
' case "$prev" in\n'
|
|
990
|
+
f"{case_block}\n"
|
|
991
|
+
" esac\n"
|
|
992
|
+
" if [[ $cur == -* ]]; then\n"
|
|
993
|
+
f' COMPREPLY=( $(compgen -W {_sh_single_quote(options)} -- "$cur") )\n'
|
|
994
|
+
" else\n"
|
|
995
|
+
" COMPREPLY=()\n"
|
|
996
|
+
" fi\n"
|
|
997
|
+
"}\n"
|
|
998
|
+
"complete -F _lgit lgit\n"
|
|
999
|
+
)
|
|
1000
|
+
|
|
1001
|
+
|
|
1002
|
+
def _zsh_completion(specs: list[dict[str, Any]]) -> str:
|
|
1003
|
+
lines = ["#compdef lgit", "", "_lgit() {", " _arguments -s -S \\"]
|
|
1004
|
+
for spec in specs:
|
|
1005
|
+
lines.append(f" {_sh_single_quote(_zsh_option_spec(spec))} \\")
|
|
1006
|
+
lines.append(" '*:context:_files'")
|
|
1007
|
+
lines.append("}")
|
|
1008
|
+
lines.append("")
|
|
1009
|
+
lines.append('_lgit "$@"')
|
|
1010
|
+
return "\n".join(lines) + "\n"
|
|
1011
|
+
|
|
1012
|
+
|
|
1013
|
+
def _zsh_option_spec(spec: dict[str, Any]) -> str:
|
|
1014
|
+
options = spec["options"]
|
|
1015
|
+
opt_expr = "{" + ",".join(options) + "}" if len(options) > 1 else options[0]
|
|
1016
|
+
desc = _zsh_escape(spec["help"])
|
|
1017
|
+
if spec["choices"]:
|
|
1018
|
+
values = " ".join(_zsh_escape(choice) for choice in spec["choices"])
|
|
1019
|
+
return f"{opt_expr}[{desc}]:{spec['metavar']}:({values})"
|
|
1020
|
+
if spec["takes_value"]:
|
|
1021
|
+
return f"{opt_expr}[{desc}]:{spec['metavar']}:_files"
|
|
1022
|
+
return f"{opt_expr}[{desc}]"
|
|
1023
|
+
|
|
1024
|
+
|
|
1025
|
+
def _fish_completion(specs: list[dict[str, Any]]) -> str:
|
|
1026
|
+
lines = ["# fish completion for lgit", "complete -c lgit -f"]
|
|
1027
|
+
for spec in specs:
|
|
1028
|
+
parts = ["complete", "-c", "lgit"]
|
|
1029
|
+
short = next(
|
|
1030
|
+
(option[1:] for option in spec["options"] if option.startswith("-") and not option.startswith("--")), None
|
|
1031
|
+
)
|
|
1032
|
+
long = next((option[2:] for option in spec["options"] if option.startswith("--")), None)
|
|
1033
|
+
if short is not None:
|
|
1034
|
+
parts.extend(("-s", short))
|
|
1035
|
+
if long is not None:
|
|
1036
|
+
parts.extend(("-l", long))
|
|
1037
|
+
if spec["choices"]:
|
|
1038
|
+
parts.extend(("-xa", " ".join(spec["choices"])))
|
|
1039
|
+
elif spec["takes_value"]:
|
|
1040
|
+
parts.append("-r")
|
|
1041
|
+
if spec["help"]:
|
|
1042
|
+
parts.extend(("-d", spec["help"]))
|
|
1043
|
+
lines.append(" ".join(_fish_quote(part) for part in parts))
|
|
1044
|
+
return "\n".join(lines) + "\n"
|
|
1045
|
+
|
|
1046
|
+
|
|
1047
|
+
def _powershell_completion(specs: list[dict[str, Any]]) -> str:
|
|
1048
|
+
options = [option for spec in specs for option in spec["options"]]
|
|
1049
|
+
value_entries: list[str] = []
|
|
1050
|
+
for spec in specs:
|
|
1051
|
+
if spec["choices"]:
|
|
1052
|
+
values = ", ".join(_ps_single_quote(choice) for choice in spec["choices"])
|
|
1053
|
+
for option in spec["options"]:
|
|
1054
|
+
value_entries.append(f" {_ps_single_quote(option)} = @({values})")
|
|
1055
|
+
option_array = ", ".join(_ps_single_quote(option) for option in options)
|
|
1056
|
+
value_map = "\n".join(value_entries)
|
|
1057
|
+
return (
|
|
1058
|
+
"# PowerShell completion for lgit\n"
|
|
1059
|
+
"Register-ArgumentCompleter -Native -CommandName lgit -ScriptBlock {\n"
|
|
1060
|
+
" param($wordToComplete, $commandAst, $cursorPosition)\n"
|
|
1061
|
+
f" $options = @({option_array})\n"
|
|
1062
|
+
" $valueMap = @{\n"
|
|
1063
|
+
f"{value_map}\n"
|
|
1064
|
+
" }\n"
|
|
1065
|
+
" $tokens = $commandAst.CommandElements | ForEach-Object { $_.ToString() }\n"
|
|
1066
|
+
" $previous = if ($tokens.Count -gt 1) { $tokens[$tokens.Count - 2] } else { '' }\n"
|
|
1067
|
+
" $candidates = if ($valueMap.ContainsKey($previous)) { $valueMap[$previous] } else { $options }\n"
|
|
1068
|
+
" $candidates |\n"
|
|
1069
|
+
' Where-Object { $_ -like "$wordToComplete*" } |\n'
|
|
1070
|
+
" ForEach-Object { [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_) }\n"
|
|
1071
|
+
"}\n"
|
|
1072
|
+
)
|
|
1073
|
+
|
|
1074
|
+
|
|
1075
|
+
def _elvish_completion(specs: list[dict[str, Any]]) -> str:
|
|
1076
|
+
options = " ".join(option for spec in specs for option in spec["options"])
|
|
1077
|
+
mode_choices = next((" ".join(spec["choices"]) for spec in specs if "--mode" in spec["options"]), "")
|
|
1078
|
+
return (
|
|
1079
|
+
"# elvish completion for lgit\n"
|
|
1080
|
+
"set edit:completion:arg-completer[lgit] = {|@words|\n"
|
|
1081
|
+
" var previous = ''\n"
|
|
1082
|
+
" if (> (count $words) 1) { set previous = $words[-2] }\n"
|
|
1083
|
+
f" if (== $previous '--mode') {{ put {mode_choices} }} else {{ put {options} }}\n"
|
|
1084
|
+
"}\n"
|
|
1085
|
+
)
|
|
1086
|
+
|
|
1087
|
+
|
|
1088
|
+
def _sh_single_quote(value: str) -> str:
|
|
1089
|
+
return "'" + value.replace("'", "'\"'\"'") + "'"
|
|
1090
|
+
|
|
1091
|
+
|
|
1092
|
+
def _fish_quote(value: str) -> str:
|
|
1093
|
+
return "'" + value.replace("\\", "\\\\").replace("'", "\\'") + "'"
|
|
1094
|
+
|
|
1095
|
+
|
|
1096
|
+
def _ps_single_quote(value: str) -> str:
|
|
1097
|
+
return "'" + value.replace("'", "''") + "'"
|
|
1098
|
+
|
|
1099
|
+
|
|
1100
|
+
def _zsh_escape(value: str) -> str:
|
|
1101
|
+
return value.replace("\\", "\\\\").replace("[", "\\[").replace("]", "\\]").replace(":", "\\:")
|
|
1102
|
+
|
|
1103
|
+
|
|
1104
|
+
__all__ = ["build_parser", "main", "parse_args", "run_cli"]
|