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