git-copilot-commit 0.4.5__py3-none-any.whl → 0.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
git_copilot_commit/cli.py CHANGED
@@ -2,15 +2,32 @@
2
2
  git-copilot-commit - AI-powered Git commit assistant
3
3
  """
4
4
 
5
+ from dataclasses import dataclass
5
6
  from pathlib import Path
7
+ import os
8
+ import sys
9
+ from typing import Annotated, Sequence
6
10
 
7
11
  import rich
8
12
  import typer
9
13
  from rich.console import Console
10
14
  from rich.panel import Panel
11
15
  from rich.prompt import Confirm
12
-
13
- from .git import GitRepository, GitError, NotAGitRepositoryError
16
+ from typer.main import get_command
17
+
18
+ from .git import GitRepository, GitError, GitStatus, NotAGitRepositoryError
19
+ from .split_commits import (
20
+ PatchUnit,
21
+ SplitCommitPlan,
22
+ SplitCommitLimitExceededError,
23
+ SplitPlanningError,
24
+ build_split_plan_prompt,
25
+ build_status_for_patch_units,
26
+ evaluate_auto_split,
27
+ extract_patch_units,
28
+ group_patch_units,
29
+ parse_split_plan_response,
30
+ )
14
31
  from .settings import Settings
15
32
  from .version import __version__
16
33
  from . import github_copilot
@@ -18,6 +35,129 @@ from . import github_copilot
18
35
  console = Console()
19
36
  app = typer.Typer(help=__doc__, add_completion=False)
20
37
 
38
+ COMMIT_MESSAGE_PROMPT_FILENAME = "commit-message-generator-prompt.md"
39
+ SPLIT_COMMIT_PLANNER_PROMPT_FILENAME = "split-commit-planner-prompt.md"
40
+ DEFAULT_AUTO_MAX_COMMITS = 10
41
+ SPLIT_DIFF_ARGS = [
42
+ "--binary",
43
+ "--full-index",
44
+ "--find-renames",
45
+ "--no-color",
46
+ "--no-ext-diff",
47
+ "--src-prefix=a/",
48
+ "--dst-prefix=b/",
49
+ "--unified=3",
50
+ ]
51
+
52
+ CA_BUNDLE_HELP = "Path to a custom CA bundle (PEM)"
53
+ NATIVE_TLS_HELP = (
54
+ "Use the OS's native certificate store via 'truststore' for httpx instead of "
55
+ "the Python bundle. Ignored if --ca-bundle or --insecure is used."
56
+ )
57
+
58
+ CaBundleOption = Annotated[
59
+ str | None,
60
+ typer.Option("--ca-bundle", metavar="PATH", help=CA_BUNDLE_HELP),
61
+ ]
62
+ InsecureOption = Annotated[
63
+ bool,
64
+ typer.Option("--insecure", help="Disable SSL certificate verification."),
65
+ ]
66
+ NativeTlsOption = Annotated[
67
+ bool,
68
+ typer.Option("--native-tls/--no-native-tls", help=NATIVE_TLS_HELP),
69
+ ]
70
+
71
+
72
+ SplitOption = Annotated[
73
+ bool,
74
+ typer.Option(
75
+ "--split",
76
+ help=(
77
+ "Split staged hunks into multiple commits automatically. Pass "
78
+ "`--split=N` to prefer up to N commits."
79
+ ),
80
+ ),
81
+ ]
82
+ SplitCountOption = Annotated[
83
+ int | None,
84
+ typer.Option(
85
+ "--split-count",
86
+ hidden=True,
87
+ min=1,
88
+ ),
89
+ ]
90
+
91
+
92
+ @dataclass(frozen=True, slots=True)
93
+ class PreparedSplitCommit:
94
+ """A split commit with its generated message and assigned patch units."""
95
+
96
+ message: str
97
+ patch_units: tuple[PatchUnit, ...]
98
+
99
+
100
+ def preprocess_cli_args(args: Sequence[str]) -> list[str]:
101
+ """Normalize CLI arguments before Click parses them."""
102
+ processed_args: list[str] = []
103
+ in_commit_command = False
104
+ index = 0
105
+
106
+ while index < len(args):
107
+ arg = args[index]
108
+
109
+ if not in_commit_command and not arg.startswith("-"):
110
+ processed_args.append(arg)
111
+ if arg == "commit":
112
+ in_commit_command = True
113
+ index += 1
114
+ continue
115
+
116
+ if in_commit_command and arg.startswith("--split="):
117
+ split_value = arg.split("=", 1)[1].strip().lower()
118
+ if split_value == "auto":
119
+ processed_args.append("--split")
120
+ index += 1
121
+ continue
122
+ if split_value.isdigit():
123
+ processed_args.extend(["--split-count", split_value])
124
+ index += 1
125
+ continue
126
+
127
+ processed_args.append(arg)
128
+ index += 1
129
+ continue
130
+
131
+ if (
132
+ in_commit_command
133
+ and arg == "--split"
134
+ and index + 1 < len(args)
135
+ ):
136
+ split_value = args[index + 1].strip().lower()
137
+ if split_value == "auto":
138
+ processed_args.append("--split")
139
+ index += 2
140
+ continue
141
+ if split_value.isdigit():
142
+ processed_args.extend(["--split-count", split_value])
143
+ index += 2
144
+ continue
145
+
146
+ processed_args.append(arg)
147
+ index += 1
148
+
149
+ return processed_args
150
+
151
+
152
+ def run(args: Sequence[str] | None = None) -> None:
153
+ """Run the CLI entrypoint with argument normalization."""
154
+ raw_args = list(args) if args is not None else sys.argv[1:]
155
+ command = get_command(app)
156
+ command.main(
157
+ args=preprocess_cli_args(raw_args),
158
+ prog_name=Path(sys.argv[0]).name,
159
+ )
160
+
21
161
 
22
162
  def version_callback(value: bool):
23
163
  if value:
@@ -47,12 +187,10 @@ def main(
47
187
  )
48
188
 
49
189
 
50
- def get_prompt_locations():
190
+ def get_prompt_locations(filename: str):
51
191
  """Get potential prompt file locations in order of preference."""
52
192
  import importlib.resources
53
193
 
54
- filename = "commit-message-generator-prompt.md"
55
-
56
194
  return [
57
195
  Path(Settings().data_dir) / "prompts" / filename, # User customizable
58
196
  importlib.resources.files("git_copilot_commit")
@@ -61,37 +199,84 @@ def get_prompt_locations():
61
199
  ]
62
200
 
63
201
 
64
- def get_active_prompt_path():
65
- """Get the path of the prompt file that will be used."""
66
- for path in get_prompt_locations():
67
- try:
68
- path.read_text(encoding="utf-8")
69
- return str(path)
70
- except (FileNotFoundError, AttributeError):
71
- continue
72
- return None
202
+ def resolve_prompt_file() -> Path | None:
203
+ settings = Settings()
204
+ try:
205
+ configured_prompt_file = settings.default_prompt_file
206
+ except ValueError:
207
+ console.print(
208
+ f"[red]Configured default prompt file in {settings.config_file} is invalid.[/red]"
209
+ )
210
+ raise typer.Exit(1)
211
+
212
+ if configured_prompt_file is None:
213
+ return None
214
+
215
+ return Path(configured_prompt_file).expanduser()
73
216
 
74
217
 
75
218
  def load_system_prompt() -> str:
76
219
  """Load the system prompt from the markdown file."""
77
- for path in get_prompt_locations():
220
+ resolved_prompt_file = resolve_prompt_file()
221
+ if resolved_prompt_file is not None:
222
+ try:
223
+ return resolved_prompt_file.read_text(encoding="utf-8")
224
+ except OSError as exc:
225
+ console.print(
226
+ f"[red]Error reading prompt file {resolved_prompt_file}: {exc}[/red]"
227
+ )
228
+ raise typer.Exit(1)
229
+
230
+ return load_named_prompt(COMMIT_MESSAGE_PROMPT_FILENAME)
231
+
232
+
233
+ def load_named_prompt(filename: str) -> str:
234
+ """Load a packaged prompt by filename, optionally overridden via user data."""
235
+ for path in get_prompt_locations(filename):
78
236
  try:
79
237
  return path.read_text(encoding="utf-8")
80
238
  except (FileNotFoundError, AttributeError):
81
239
  continue
82
240
 
83
- console.print("[red]Error: Prompt file not found in any location[/red]")
241
+ console.print(f"[red]Error: Prompt file {filename} not found in any location[/red]")
84
242
  raise typer.Exit(1)
85
243
 
86
244
 
87
- def generate_commit_message(
88
- repo: GitRepository, model: str | None = None, context: str = ""
89
- ) -> str:
90
- """Generate a conventional commit message using Copilot API."""
245
+ def build_http_client_config(
246
+ *,
247
+ ca_bundle: str | None,
248
+ insecure: bool,
249
+ native_tls: bool,
250
+ ) -> github_copilot.HttpClientConfig:
251
+ if ca_bundle is not None:
252
+ ca_bundle = os.path.expanduser(ca_bundle)
253
+ return github_copilot.HttpClientConfig(
254
+ native_tls=native_tls,
255
+ insecure=insecure,
256
+ ca_bundle=ca_bundle,
257
+ )
91
258
 
92
- # Refresh status after staging
93
- status = repo.get_status()
94
259
 
260
+ def print_copilot_error(message: str, exc: github_copilot.CopilotError) -> None:
261
+ """Render Copilot errors, with rich formatting for model selection issues."""
262
+ if isinstance(exc, github_copilot.ModelSelectionError):
263
+ console.print(f"[red]{message}[/red]")
264
+ github_copilot.print_model_selection_error(exc)
265
+ return
266
+
267
+ console.print(f"[red]{message}: {exc}[/red]")
268
+
269
+
270
+ def display_selected_model(model: github_copilot.CopilotModel) -> None:
271
+ """Show the resolved Copilot model for the current command."""
272
+ details = [github_copilot.infer_api_surface(model)]
273
+ if model.vendor:
274
+ details.insert(0, model.vendor)
275
+ console.print(f"[green]Using model:[/green] {model.id} ({', '.join(details)})")
276
+
277
+
278
+ def build_commit_message_prompt(status: GitStatus, context: str = "") -> str:
279
+ """Build the prompt used to generate a commit message."""
95
280
  if not status.has_staged_changes:
96
281
  console.print("[red]No staged changes to commit.[/red]")
97
282
  raise typer.Exit()
@@ -106,36 +291,91 @@ def generate_commit_message(
106
291
  if context.strip():
107
292
  prompt_parts.insert(0, f"User-provided context:\n\n{context.strip()}\n\n")
108
293
 
109
- prompt_parts.append("\nGenerate a conventional commit message:")
294
+ return "\n".join(prompt_parts)
110
295
 
111
- prompt = "\n".join(prompt_parts)
112
296
 
113
- if model is None:
114
- model = "gpt-5.1-codex"
297
+ def normalize_model_name(model: str | None) -> str | None:
298
+ """Normalize model names accepted by the CLI to Copilot API model ids."""
299
+ if model is not None and model.startswith("github_copilot/"):
300
+ return model.replace("github_copilot/", "", 1)
301
+ return model
115
302
 
116
- if model.startswith("github_copilot/"):
117
- model = model.replace("github_copilot/", "")
118
303
 
304
+ def ask_copilot_with_system_prompt(
305
+ system_prompt: str,
306
+ prompt: str,
307
+ model: str | None = None,
308
+ http_client_config: github_copilot.HttpClientConfig | None = None,
309
+ ) -> str:
310
+ """Send a prepared prompt to Copilot using the provided system prompt."""
119
311
  return github_copilot.ask(
120
312
  f"""
121
313
  # System Prompt
122
314
 
123
- {load_system_prompt()}
315
+ {system_prompt}
124
316
 
125
317
  # Prompt
126
318
 
127
319
  {prompt}
128
320
  """,
321
+ model=normalize_model_name(model),
322
+ http_client_config=http_client_config,
323
+ )
324
+
325
+
326
+ def generate_commit_message_for_prompt(
327
+ prompt: str,
328
+ model: str | None = None,
329
+ http_client_config: github_copilot.HttpClientConfig | None = None,
330
+ ) -> str:
331
+ """Generate a conventional commit message from a prepared prompt."""
332
+ return ask_copilot_with_system_prompt(
333
+ load_system_prompt(),
334
+ prompt,
335
+ model=model,
336
+ http_client_config=http_client_config,
337
+ )
338
+
339
+
340
+ def generate_commit_message_for_status(
341
+ status: GitStatus,
342
+ model: str | None = None,
343
+ context: str = "",
344
+ http_client_config: github_copilot.HttpClientConfig | None = None,
345
+ ) -> str:
346
+ """Generate a commit message for a staged status snapshot."""
347
+ prompt = build_commit_message_prompt(status, context=context)
348
+ return generate_commit_message_for_prompt(
349
+ prompt,
350
+ model=model,
351
+ http_client_config=http_client_config,
352
+ )
353
+
354
+
355
+ def generate_commit_message(
356
+ repo: GitRepository,
357
+ model: str | None = None,
358
+ context: str = "",
359
+ http_client_config: github_copilot.HttpClientConfig | None = None,
360
+ ) -> str:
361
+ """Generate a conventional commit message using the repository's staged diff."""
362
+ return generate_commit_message_for_status(
363
+ repo.get_status(),
129
364
  model=model,
365
+ context=context,
366
+ http_client_config=http_client_config,
130
367
  )
131
368
 
132
369
 
133
370
  def commit_with_retry_no_verify(
134
- repo: GitRepository, message: str, use_editor: bool = False
371
+ repo: GitRepository,
372
+ message: str,
373
+ use_editor: bool = False,
374
+ env: dict[str, str] | None = None,
135
375
  ) -> str:
136
376
  """Run commit and offer one retry with -n on failure."""
137
377
  try:
138
- return repo.commit(message, use_editor=use_editor)
378
+ return repo.commit(message, use_editor=use_editor, env=env)
139
379
  except GitError as e:
140
380
  console.print(f"[red]Commit failed: {e}[/red]")
141
381
  if not Confirm.ask(
@@ -145,24 +385,526 @@ def commit_with_retry_no_verify(
145
385
  raise typer.Exit(1)
146
386
 
147
387
  try:
148
- return repo.commit(message, use_editor=use_editor, no_verify=True)
388
+ return repo.commit(message, use_editor=use_editor, no_verify=True, env=env)
149
389
  except GitError as retry_error:
150
390
  console.print(f"[red]Commit with -n failed: {retry_error}[/red]")
151
391
  raise typer.Exit(1)
152
392
 
153
393
 
394
+ def ensure_copilot_authentication(
395
+ http_client_config: github_copilot.HttpClientConfig,
396
+ ) -> None:
397
+ """Authenticate if no cached Copilot credentials are available."""
398
+ try:
399
+ existing_credentials = github_copilot.load_credentials()
400
+ except github_copilot.CopilotError:
401
+ existing_credentials = None
402
+
403
+ if existing_credentials is not None:
404
+ return
405
+
406
+ try:
407
+ github_copilot.login(
408
+ force=True,
409
+ http_client_config=http_client_config,
410
+ )
411
+ except github_copilot.CopilotError as exc:
412
+ print_copilot_error("Authentication failed", exc)
413
+ raise typer.Exit(1)
414
+
415
+
416
+ def stage_changes_for_commit(
417
+ repo: GitRepository, status: GitStatus, all_files: bool
418
+ ) -> GitStatus:
419
+ """Stage changes according to the command options and return refreshed status."""
420
+ if all_files:
421
+ repo.stage_files()
422
+ console.print("[green]Staged all files.[/green]")
423
+ return repo.get_status()
424
+
425
+ if status.has_unstaged_changes or status.has_untracked_files:
426
+ git_status_output = repo._run_git_command(["status"])
427
+ console.print(git_status_output.stdout)
428
+
429
+ if status.has_unstaged_changes:
430
+ if Confirm.ask(
431
+ "Modified files found. Add [bold yellow]all unstaged changes[/] to staging?",
432
+ default=True,
433
+ ):
434
+ repo.stage_modified()
435
+ console.print("[green]Staged modified files.[/green]")
436
+
437
+ if status.has_untracked_files:
438
+ if Confirm.ask(
439
+ "Untracked files found. Add [bold yellow]all untracked files and unstaged changes[/] to staging?",
440
+ default=True,
441
+ ):
442
+ repo.stage_files()
443
+ console.print("[green]Staged untracked files.[/green]")
444
+
445
+ return repo.get_status()
446
+
447
+
448
+ def request_commit_message(
449
+ status: GitStatus,
450
+ model: str | None = None,
451
+ context: str = "",
452
+ http_client_config: github_copilot.HttpClientConfig | None = None,
453
+ ) -> str:
454
+ """Request a commit message for the provided staged state."""
455
+ try:
456
+ with console.status(
457
+ "[yellow]Generating commit message based on [bold]`git diff --staged`[/] ...[/yellow]"
458
+ ):
459
+ return generate_commit_message_for_status(
460
+ status,
461
+ model=model,
462
+ context=context,
463
+ http_client_config=http_client_config,
464
+ )
465
+ except github_copilot.CopilotError as exc:
466
+ print_copilot_error("Could not generate a commit message", exc)
467
+ raise typer.Exit(1)
468
+
469
+
470
+ def request_split_commit_plan(
471
+ status: GitStatus,
472
+ patch_units: tuple[PatchUnit, ...],
473
+ *,
474
+ max_commits: int,
475
+ preferred_commits: int | None = None,
476
+ model: str | None = None,
477
+ context: str = "",
478
+ http_client_config: github_copilot.HttpClientConfig | None = None,
479
+ ) -> SplitCommitPlan:
480
+ """Request and validate a split-commit plan for the staged patch units."""
481
+ try:
482
+ planner_prompt = build_split_plan_prompt(
483
+ status,
484
+ patch_units,
485
+ max_commits=max_commits,
486
+ preferred_commits=preferred_commits,
487
+ context=context,
488
+ )
489
+
490
+ with console.status(
491
+ "[yellow]Planning split commits from [bold]staged hunks[/] ...[/yellow]"
492
+ ):
493
+ response = ask_copilot_with_system_prompt(
494
+ load_named_prompt(SPLIT_COMMIT_PLANNER_PROMPT_FILENAME),
495
+ planner_prompt,
496
+ model=model,
497
+ http_client_config=http_client_config,
498
+ )
499
+ return parse_split_plan_response(
500
+ response,
501
+ patch_units,
502
+ max_commits=max_commits,
503
+ )
504
+ except github_copilot.CopilotError as exc:
505
+ print_copilot_error("Could not generate a split commit plan", exc)
506
+ raise typer.Exit(1)
507
+
508
+
509
+ def request_split_commit_messages(
510
+ plan: SplitCommitPlan,
511
+ patch_units: tuple[PatchUnit, ...],
512
+ *,
513
+ model: str | None = None,
514
+ context: str = "",
515
+ http_client_config: github_copilot.HttpClientConfig | None = None,
516
+ ) -> list[PreparedSplitCommit]:
517
+ """Generate commit messages for each planned split-commit group."""
518
+ try:
519
+ prepared_commits: list[PreparedSplitCommit] = []
520
+ grouped_units = group_patch_units(patch_units, plan)
521
+ total_commits = len(grouped_units)
522
+
523
+ for index, unit_group in enumerate(grouped_units, start=1):
524
+ with console.status(
525
+ f"[yellow]Generating commit message {index}/{total_commits} based on [bold]planned staged diff[/] ...[/yellow]"
526
+ ):
527
+ message = generate_commit_message_for_status(
528
+ build_status_for_patch_units(unit_group),
529
+ model=model,
530
+ context=context,
531
+ http_client_config=http_client_config,
532
+ )
533
+
534
+ prepared_commits.append(
535
+ PreparedSplitCommit(message=message, patch_units=tuple(unit_group))
536
+ )
537
+
538
+ return prepared_commits
539
+ except github_copilot.CopilotError as exc:
540
+ print_copilot_error("Could not generate split commit messages", exc)
541
+ raise typer.Exit(1)
542
+
543
+
544
+ def resolve_split_commit_limit(
545
+ exc: SplitCommitLimitExceededError, *, yes: bool = False
546
+ ) -> SplitCommitPlan:
547
+ """Ask whether to proceed when the planner exceeds the configured limit."""
548
+ console.print(
549
+ f"[yellow]Split planning produced {exc.actual_commits} commits, exceeding the automatic review limit of {exc.max_commits}.[/yellow]"
550
+ )
551
+
552
+ if yes:
553
+ console.print(
554
+ "[red]Cannot ask whether to proceed because --yes was used. Re-run without --yes to review the larger plan.[/red]"
555
+ )
556
+ raise typer.Exit(1)
557
+
558
+ if Confirm.ask(
559
+ f"Proceed with [bold]{exc.actual_commits} commits[/] anyway?",
560
+ default=False,
561
+ ):
562
+ return exc.plan
563
+
564
+ console.print("Split commit plan cancelled.")
565
+ raise typer.Exit()
566
+
567
+
568
+ def display_commit_message(commit_message: str) -> None:
569
+ """Render the generated commit message."""
570
+ console.print("[yellow]Generated commit message.[/yellow]")
571
+ console.print(
572
+ Panel(
573
+ f"[bold]{commit_message}[/]",
574
+ title="Commit Message",
575
+ border_style="cyan",
576
+ width=len(commit_message) + 5,
577
+ )
578
+ )
579
+
580
+
581
+ def display_split_commit_plan(prepared_commits: list[PreparedSplitCommit]) -> None:
582
+ """Render the split-commit plan preview."""
583
+ console.print("[yellow]Generated split commit plan.[/yellow]")
584
+
585
+ for index, prepared_commit in enumerate(prepared_commits, start=1):
586
+ paths = list(dict.fromkeys(unit.path for unit in prepared_commit.patch_units))
587
+ file_lines = "\n".join(f"- {path}" for path in paths)
588
+ console.print(
589
+ Panel(
590
+ f"[bold]{prepared_commit.message}[/]\n\nFiles:\n{file_lines}",
591
+ title=f"Commit {index}",
592
+ border_style="cyan",
593
+ )
594
+ )
595
+
596
+
597
+ def execute_commit_action(
598
+ repo: GitRepository, commit_message: str, yes: bool = False
599
+ ) -> str:
600
+ """Run the chosen commit action using the provided message."""
601
+ if yes:
602
+ return commit_with_retry_no_verify(repo, commit_message)
603
+
604
+ choice = typer.prompt(
605
+ "Choose action: (c)ommit, (e)dit message, (q)uit",
606
+ default="c",
607
+ show_default=True,
608
+ ).lower()
609
+
610
+ if choice == "q":
611
+ console.print("Commit cancelled.")
612
+ raise typer.Exit()
613
+ if choice == "e":
614
+ console.print("[cyan]Opening git editor...[/cyan]")
615
+ return commit_with_retry_no_verify(repo, commit_message, use_editor=True)
616
+ if choice == "c":
617
+ return commit_with_retry_no_verify(repo, commit_message)
618
+
619
+ console.print("Invalid choice. Commit cancelled.")
620
+ raise typer.Exit()
621
+
622
+
623
+ def execute_split_commit_plan(
624
+ repo: GitRepository,
625
+ prepared_commits: list[PreparedSplitCommit],
626
+ *,
627
+ yes: bool = False,
628
+ ) -> list[str]:
629
+ """Run the split-commit plan against temporary alternate indexes."""
630
+ use_editor = False
631
+ if not yes:
632
+ choice = typer.prompt(
633
+ "Choose action: (c)ommit all, (e)dit each message, (q)uit",
634
+ default="c",
635
+ show_default=True,
636
+ ).lower()
637
+
638
+ if choice == "q":
639
+ console.print("Commit cancelled.")
640
+ raise typer.Exit()
641
+ if choice == "e":
642
+ use_editor = True
643
+ elif choice != "c":
644
+ console.print("Invalid choice. Commit cancelled.")
645
+ raise typer.Exit()
646
+
647
+ commit_shas: list[str] = []
648
+ total_commits = len(prepared_commits)
649
+
650
+ for index, prepared_commit in enumerate(prepared_commits, start=1):
651
+ console.print(
652
+ f"[cyan]Creating commit {index}/{total_commits}:[/cyan] {prepared_commit.message}"
653
+ )
654
+
655
+ with repo.temporary_alternate_index() as alternate_index:
656
+ try:
657
+ for patch_unit in prepared_commit.patch_units:
658
+ repo.check_patch_for_alternate_index(
659
+ patch_unit.patch,
660
+ index=alternate_index,
661
+ )
662
+ repo.apply_patch_to_alternate_index(
663
+ patch_unit.patch,
664
+ index=alternate_index,
665
+ )
666
+ except GitError as exc:
667
+ console.print(
668
+ f"[red]Failed to apply the planned changes for commit {index}: {exc}[/red]"
669
+ )
670
+ raise typer.Exit(1)
671
+
672
+ commit_shas.append(
673
+ commit_with_retry_no_verify(
674
+ repo,
675
+ prepared_commit.message,
676
+ use_editor=use_editor,
677
+ env=alternate_index.env,
678
+ )
679
+ )
680
+
681
+ return commit_shas
682
+
683
+
684
+ def handle_single_commit_flow(
685
+ repo: GitRepository,
686
+ status: GitStatus,
687
+ *,
688
+ model: str | None = None,
689
+ yes: bool = False,
690
+ context: str = "",
691
+ http_client_config: github_copilot.HttpClientConfig | None = None,
692
+ ) -> None:
693
+ """Generate, display, and execute the single-commit flow."""
694
+ commit_message = request_commit_message(
695
+ status,
696
+ model=model,
697
+ context=context,
698
+ http_client_config=http_client_config,
699
+ )
700
+ display_commit_message(commit_message)
701
+
702
+ commit_sha = execute_commit_action(repo, commit_message, yes=yes)
703
+ console.print(f"[green]✓ Successfully committed: {commit_sha[:8]}[/green]")
704
+
705
+
706
+ def handle_split_commit_flow(
707
+ repo: GitRepository,
708
+ status: GitStatus,
709
+ *,
710
+ preferred_commits: int | None = None,
711
+ model: str | None = None,
712
+ yes: bool = False,
713
+ context: str = "",
714
+ http_client_config: github_copilot.HttpClientConfig | None = None,
715
+ ) -> None:
716
+ """Generate, display, and execute the split-commit flow."""
717
+ patch_units = tuple(
718
+ extract_patch_units(repo.get_staged_diff(extra_args=SPLIT_DIFF_ARGS))
719
+ )
720
+
721
+ if not patch_units:
722
+ console.print(
723
+ "[yellow]No split patch units were extracted; falling back to a single commit.[/yellow]"
724
+ )
725
+ handle_single_commit_flow(
726
+ repo,
727
+ status,
728
+ model=model,
729
+ yes=yes,
730
+ context=context,
731
+ http_client_config=http_client_config,
732
+ )
733
+ return
734
+
735
+ if len(patch_units) == 1:
736
+ console.print(
737
+ "[yellow]Only one staged patch unit was found; creating a single commit.[/yellow]"
738
+ )
739
+ handle_single_commit_flow(
740
+ repo,
741
+ status,
742
+ model=model,
743
+ yes=yes,
744
+ context=context,
745
+ http_client_config=http_client_config,
746
+ )
747
+ return
748
+
749
+ if preferred_commits is None:
750
+ should_split, reason = evaluate_auto_split(patch_units)
751
+ if not should_split:
752
+ console.print(
753
+ "[yellow]Auto split not triggered: "
754
+ f"{reason}. Creating a single commit. Use [bold]--split N[/] to suggest an upper bound.[/yellow]"
755
+ )
756
+ handle_single_commit_flow(
757
+ repo,
758
+ status,
759
+ model=model,
760
+ yes=yes,
761
+ context=context,
762
+ http_client_config=http_client_config,
763
+ )
764
+ return
765
+
766
+ console.print(f"[yellow]Auto split triggered: {reason}.[/yellow]")
767
+ else:
768
+ console.print(
769
+ f"[yellow]Planning up to {preferred_commits} commits from the staged patch units.[/yellow]"
770
+ )
771
+
772
+ try:
773
+ split_plan = request_split_commit_plan(
774
+ status,
775
+ patch_units,
776
+ max_commits=(
777
+ DEFAULT_AUTO_MAX_COMMITS
778
+ if preferred_commits is None
779
+ else preferred_commits
780
+ ),
781
+ preferred_commits=preferred_commits,
782
+ model=model,
783
+ context=context,
784
+ http_client_config=http_client_config,
785
+ )
786
+ except SplitCommitLimitExceededError as exc:
787
+ split_plan = resolve_split_commit_limit(exc, yes=yes)
788
+ except SplitPlanningError as exc:
789
+ console.print(
790
+ "[yellow]Split planning returned an invalid plan; falling back to a single commit.[/yellow]"
791
+ )
792
+ console.print(f"[yellow]Reason:[/yellow] {exc}")
793
+ handle_single_commit_flow(
794
+ repo,
795
+ status,
796
+ model=model,
797
+ yes=yes,
798
+ context=context,
799
+ http_client_config=http_client_config,
800
+ )
801
+ return
802
+
803
+ prepared_commits = request_split_commit_messages(
804
+ split_plan,
805
+ patch_units,
806
+ model=model,
807
+ context=context,
808
+ http_client_config=http_client_config,
809
+ )
810
+
811
+ if len(prepared_commits) == 1:
812
+ console.print(
813
+ "[yellow]Split planning resulted in a single commit; using the standard commit flow.[/yellow]"
814
+ )
815
+ display_commit_message(prepared_commits[0].message)
816
+ commit_sha = execute_commit_action(repo, prepared_commits[0].message, yes=yes)
817
+ console.print(f"[green]✓ Successfully committed: {commit_sha[:8]}[/green]")
818
+ return
819
+
820
+ display_split_commit_plan(prepared_commits)
821
+ commit_shas = execute_split_commit_plan(repo, prepared_commits, yes=yes)
822
+
823
+ console.print(f"[green]✓ Successfully created {len(commit_shas)} commits.[/green]")
824
+ for commit_sha, prepared_commit in zip(commit_shas, prepared_commits, strict=True):
825
+ console.print(f"[green]{commit_sha[:8]}[/green] {prepared_commit.message}")
826
+
827
+
154
828
  @app.command("authenticate")
155
829
  @app.command("login", hidden=True)
156
830
  def authenticate(
831
+ enterprise_domain: str | None = typer.Option(
832
+ None,
833
+ "--enterprise-domain",
834
+ help="GitHub Enterprise hostname. Omit for github.com.",
835
+ ),
157
836
  force: bool = typer.Option(
158
837
  False, "--force", help="Replace cached GitHub Copilot credentials"
159
838
  ),
839
+ ca_bundle: CaBundleOption = None,
840
+ insecure: InsecureOption = False,
841
+ native_tls: NativeTlsOption = False,
160
842
  ):
161
843
  """Authenticate with GitHub Copilot and cache credentials locally."""
844
+ http_client_config = build_http_client_config(
845
+ ca_bundle=ca_bundle,
846
+ insecure=insecure,
847
+ native_tls=native_tls,
848
+ )
849
+ try:
850
+ github_copilot.login(
851
+ enterprise_domain=enterprise_domain,
852
+ force=force,
853
+ http_client_config=http_client_config,
854
+ )
855
+ except github_copilot.CopilotError as exc:
856
+ print_copilot_error("Authentication failed", exc)
857
+ raise typer.Exit(1)
858
+
859
+
860
+ @app.command("summary")
861
+ def summary(
862
+ ca_bundle: CaBundleOption = None,
863
+ insecure: InsecureOption = False,
864
+ native_tls: NativeTlsOption = False,
865
+ ):
866
+ """Show the current cached GitHub Copilot login summary."""
867
+ http_client_config = build_http_client_config(
868
+ ca_bundle=ca_bundle,
869
+ insecure=insecure,
870
+ native_tls=native_tls,
871
+ )
872
+ try:
873
+ github_copilot.show_login_summary(http_client_config=http_client_config)
874
+ except github_copilot.CopilotError as exc:
875
+ print_copilot_error("Could not load login summary", exc)
876
+ raise typer.Exit(1)
877
+
878
+
879
+ @app.command("models")
880
+ def models_command(
881
+ vendor: str | None = typer.Option(
882
+ None,
883
+ "--vendor",
884
+ help="Filter listed models by vendor: anthropic, gemini/google, or openai.",
885
+ ),
886
+ ca_bundle: CaBundleOption = None,
887
+ insecure: InsecureOption = False,
888
+ native_tls: NativeTlsOption = False,
889
+ ):
890
+ """List available Copilot models for the current account."""
891
+ http_client_config = build_http_client_config(
892
+ ca_bundle=ca_bundle,
893
+ insecure=insecure,
894
+ native_tls=native_tls,
895
+ )
896
+
162
897
  try:
163
- github_copilot.login(force=force)
898
+ credentials, models = github_copilot.get_available_models(
899
+ vendor=vendor,
900
+ http_client_config=http_client_config,
901
+ )
902
+
903
+ console.print(f"[green]Copilot base URL:[/green] {credentials.base_url()}")
904
+ console.print(f"[green]Model count:[/green] {len(models)}")
905
+ github_copilot.print_model_table(models)
164
906
  except github_copilot.CopilotError as exc:
165
- console.print(f"[red]Authentication failed: {exc}[/red]")
907
+ print_copilot_error("Could not load models", exc)
166
908
  raise typer.Exit(1)
167
909
 
168
910
 
@@ -171,8 +913,14 @@ def commit(
171
913
  all_files: bool = typer.Option(
172
914
  False, "--all", "-a", help="Stage all files before committing"
173
915
  ),
916
+ split: SplitOption = False,
917
+ split_count: SplitCountOption = None,
174
918
  model: str | None = typer.Option(
175
- None, "--model", "-m", help="Model to use for generating commit message"
919
+ None,
920
+ "--model",
921
+ "-m",
922
+ metavar="MODEL_ID",
923
+ help="Model to use for generating commit message",
176
924
  ),
177
925
  yes: bool = typer.Option(
178
926
  False, "--yes", "-y", help="Automatically accept the generated commit message"
@@ -183,6 +931,9 @@ def commit(
183
931
  "-c",
184
932
  help="Optional user-provided context to guide commit message",
185
933
  ),
934
+ ca_bundle: CaBundleOption = None,
935
+ insecure: InsecureOption = False,
936
+ native_tls: NativeTlsOption = False,
186
937
  ):
187
938
  """
188
939
  Generate commit message based on changes in the current git repository and commit them.
@@ -193,22 +944,12 @@ def commit(
193
944
  console.print("[red]Error: Not in a git repository[/red]")
194
945
  raise typer.Exit(1)
195
946
 
196
- try:
197
- existing_credentials = github_copilot.load_credentials()
198
- except github_copilot.CopilotError:
199
- existing_credentials = None
200
-
201
- if existing_credentials is None:
202
- try:
203
- github_copilot.login(force=True)
204
- except github_copilot.CopilotError as exc:
205
- console.print(f"[red]Authentication failed: {exc}[/red]")
206
- raise typer.Exit(1)
207
-
208
- # Load settings and use default model if none provided
209
- settings = Settings()
210
- if model is None:
211
- model = settings.default_model
947
+ http_client_config = build_http_client_config(
948
+ ca_bundle=ca_bundle,
949
+ insecure=insecure,
950
+ native_tls=native_tls,
951
+ )
952
+ ensure_copilot_authentication(http_client_config)
212
953
 
213
954
  # Get initial status
214
955
  status = repo.get_status()
@@ -217,90 +958,47 @@ def commit(
217
958
  console.print("[yellow]No changes to commit.[/yellow]")
218
959
  raise typer.Exit()
219
960
 
220
- # Handle staging based on options
221
- if all_files:
222
- repo.stage_files() # Stage all files
223
- console.print("[green]Staged all files.[/green]")
224
- else:
225
- # Show git status once if there are unstaged or untracked files to prompt about
226
- if status.has_unstaged_changes or status.has_untracked_files:
227
- git_status_output = repo._run_git_command(["status"])
228
- console.print(git_status_output.stdout)
229
-
230
- if status.has_unstaged_changes:
231
- if Confirm.ask(
232
- "Modified files found. Add [bold yellow]all unstaged changes[/] to staging?",
233
- default=True,
234
- ):
235
- repo.stage_modified()
236
- console.print("[green]Staged modified files.[/green]")
237
- if status.has_untracked_files:
238
- if Confirm.ask(
239
- "Untracked files found. Add [bold yellow]all untracked files and unstaged changes[/] to staging?",
240
- default=True,
241
- ):
242
- repo.stage_files()
243
- console.print("[green]Staged untracked files.[/green]")
961
+ status = stage_changes_for_commit(repo, status, all_files=all_files)
244
962
 
245
963
  if context:
246
964
  console.print(
247
965
  Panel(context.strip(), title="User Context", border_style="magenta")
248
966
  )
249
967
 
968
+ normalized_model = normalize_model_name(model)
250
969
  try:
251
- github_copilot.ensure_auth_ready(model=model)
252
-
253
- # Generate or use provided commit message
254
- with console.status(
255
- "[yellow]Generating commit message based on [bold]`git diff --staged`[/] ...[/yellow]"
256
- ):
257
- commit_message = generate_commit_message(repo, model, context=context)
970
+ selected_model = github_copilot.ensure_auth_ready(
971
+ model=normalized_model,
972
+ http_client_config=http_client_config,
973
+ )
258
974
  except github_copilot.CopilotError as exc:
259
- console.print(f"[red]Could not generate a commit message: {exc}[/red]")
975
+ print_copilot_error("Could not select a model", exc)
260
976
  raise typer.Exit(1)
261
977
 
262
- console.print("[yellow]Generated commit message.[/yellow]")
263
-
264
- # Display commit message
265
- console.print(
266
- Panel(
267
- f"[bold]{commit_message}[/]",
268
- title="Commit Message",
269
- border_style="cyan",
270
- width=len(commit_message) + 5,
978
+ display_selected_model(selected_model)
979
+ model = selected_model.id
980
+
981
+ if split or split_count is not None:
982
+ handle_split_commit_flow(
983
+ repo,
984
+ status,
985
+ preferred_commits=split_count,
986
+ model=model,
987
+ yes=yes,
988
+ context=context,
989
+ http_client_config=http_client_config,
271
990
  )
272
- )
991
+ return
273
992
 
274
- # Confirm commit or edit message (skip if --yes flag is used)
275
- if yes:
276
- # Automatically commit with generated message
277
- commit_sha = commit_with_retry_no_verify(repo, commit_message)
278
- else:
279
- choice = typer.prompt(
280
- "Choose action: (c)ommit, (e)dit message, (q)uit",
281
- default="c",
282
- show_default=True,
283
- ).lower()
284
-
285
- if choice == "q":
286
- console.print("Commit cancelled.")
287
- raise typer.Exit()
288
- elif choice == "e":
289
- # Use git's built-in editor with generated message as template
290
- console.print("[cyan]Opening git editor...[/cyan]")
291
- commit_sha = commit_with_retry_no_verify(
292
- repo, commit_message, use_editor=True
293
- )
294
- elif choice == "c":
295
- # Commit with generated message
296
- commit_sha = commit_with_retry_no_verify(repo, commit_message)
297
- else:
298
- console.print("Invalid choice. Commit cancelled.")
299
- raise typer.Exit()
300
-
301
- # Show success message
302
- console.print(f"[green]✓ Successfully committed: {commit_sha[:8]}[/green]")
993
+ handle_single_commit_flow(
994
+ repo,
995
+ status,
996
+ model=model,
997
+ yes=yes,
998
+ context=context,
999
+ http_client_config=http_client_config,
1000
+ )
303
1001
 
304
1002
 
305
1003
  if __name__ == "__main__":
306
- app()
1004
+ run()