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.
Files changed (54) hide show
  1. lgit/__init__.py +75 -0
  2. lgit/__main__.py +8 -0
  3. lgit/analysis.py +326 -0
  4. lgit/api.py +1077 -0
  5. lgit/cache.py +338 -0
  6. lgit/changelog.py +523 -0
  7. lgit/cli.py +1104 -0
  8. lgit/compose.py +2110 -0
  9. lgit/config.py +437 -0
  10. lgit/diffing.py +384 -0
  11. lgit/errors.py +137 -0
  12. lgit/git.py +852 -0
  13. lgit/map_reduce.py +508 -0
  14. lgit/markdown_output.py +709 -0
  15. lgit/models.py +924 -0
  16. lgit/normalization.py +411 -0
  17. lgit/patch.py +784 -0
  18. lgit/profile.py +426 -0
  19. lgit/py.typed +0 -0
  20. lgit/repo.py +287 -0
  21. lgit/resources/__init__.py +1 -0
  22. lgit/resources/commit_types.json +242 -0
  23. lgit/resources/prompts/analysis/default.md +237 -0
  24. lgit/resources/prompts/analysis/markdown.md +112 -0
  25. lgit/resources/prompts/changelog/default.md +89 -0
  26. lgit/resources/prompts/changelog/markdown.md +60 -0
  27. lgit/resources/prompts/compose-bind/default.md +40 -0
  28. lgit/resources/prompts/compose-bind/markdown.md +41 -0
  29. lgit/resources/prompts/compose-intent/default.md +63 -0
  30. lgit/resources/prompts/compose-intent/markdown.md +59 -0
  31. lgit/resources/prompts/fast/default.md +46 -0
  32. lgit/resources/prompts/fast/markdown.md +51 -0
  33. lgit/resources/prompts/map/default.md +67 -0
  34. lgit/resources/prompts/map/markdown.md +63 -0
  35. lgit/resources/prompts/reduce/default.md +81 -0
  36. lgit/resources/prompts/reduce/markdown.md +68 -0
  37. lgit/resources/prompts/summary/default.md +74 -0
  38. lgit/resources/prompts/summary/markdown.md +77 -0
  39. lgit/resources/validation_data.json +1 -0
  40. lgit/rewrite.py +392 -0
  41. lgit/style.py +295 -0
  42. lgit/templates.py +385 -0
  43. lgit/testing/__init__.py +62 -0
  44. lgit/testing/compare.py +57 -0
  45. lgit/testing/fixture.py +386 -0
  46. lgit/testing/report.py +201 -0
  47. lgit/testing/runner.py +256 -0
  48. lgit/tokens.py +90 -0
  49. lgit/validation.py +545 -0
  50. lgit_cli-3.7.0.dist-info/METADATA +288 -0
  51. lgit_cli-3.7.0.dist-info/RECORD +54 -0
  52. lgit_cli-3.7.0.dist-info/WHEEL +4 -0
  53. lgit_cli-3.7.0.dist-info/entry_points.txt +2 -0
  54. 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"]