git-copilot-commit 0.4.6__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,16 +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
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
+ 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
+ )
15
31
  from .settings import Settings
16
32
  from .version import __version__
17
33
  from . import github_copilot
@@ -19,9 +35,21 @@ from . import github_copilot
19
35
  console = Console()
20
36
  app = typer.Typer(help=__doc__, add_completion=False)
21
37
 
22
- CA_BUNDLE_HELP = (
23
- "Path to a custom CA bundle (PEM). Use this to test internal / company CAs."
24
- )
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)"
25
53
  NATIVE_TLS_HELP = (
26
54
  "Use the OS's native certificate store via 'truststore' for httpx instead of "
27
55
  "the Python bundle. Ignored if --ca-bundle or --insecure is used."
@@ -41,6 +69,96 @@ NativeTlsOption = Annotated[
41
69
  ]
42
70
 
43
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
+
161
+
44
162
  def version_callback(value: bool):
45
163
  if value:
46
164
  rich.print(f"git-copilot-commit [bold yellow]{__version__}[/]")
@@ -69,12 +187,10 @@ def main(
69
187
  )
70
188
 
71
189
 
72
- def get_prompt_locations():
190
+ def get_prompt_locations(filename: str):
73
191
  """Get potential prompt file locations in order of preference."""
74
192
  import importlib.resources
75
193
 
76
- filename = "commit-message-generator-prompt.md"
77
-
78
194
  return [
79
195
  Path(Settings().data_dir) / "prompts" / filename, # User customizable
80
196
  importlib.resources.files("git_copilot_commit")
@@ -83,26 +199,46 @@ def get_prompt_locations():
83
199
  ]
84
200
 
85
201
 
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
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()
95
216
 
96
217
 
97
218
  def load_system_prompt() -> str:
98
219
  """Load the system prompt from the markdown file."""
99
- 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):
100
236
  try:
101
237
  return path.read_text(encoding="utf-8")
102
238
  except (FileNotFoundError, AttributeError):
103
239
  continue
104
240
 
105
- 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]")
106
242
  raise typer.Exit(1)
107
243
 
108
244
 
@@ -112,6 +248,8 @@ def build_http_client_config(
112
248
  insecure: bool,
113
249
  native_tls: bool,
114
250
  ) -> github_copilot.HttpClientConfig:
251
+ if ca_bundle is not None:
252
+ ca_bundle = os.path.expanduser(ca_bundle)
115
253
  return github_copilot.HttpClientConfig(
116
254
  native_tls=native_tls,
117
255
  insecure=insecure,
@@ -119,17 +257,26 @@ def build_http_client_config(
119
257
  )
120
258
 
121
259
 
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."""
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
129
266
 
130
- # Refresh status after staging
131
- status = repo.get_status()
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)})")
132
276
 
277
+
278
+ def build_commit_message_prompt(status: GitStatus, context: str = "") -> str:
279
+ """Build the prompt used to generate a commit message."""
133
280
  if not status.has_staged_changes:
134
281
  console.print("[red]No staged changes to commit.[/red]")
135
282
  raise typer.Exit()
@@ -144,37 +291,91 @@ def generate_commit_message(
144
291
  if context.strip():
145
292
  prompt_parts.insert(0, f"User-provided context:\n\n{context.strip()}\n\n")
146
293
 
147
- prompt_parts.append("\nGenerate a conventional commit message:")
294
+ return "\n".join(prompt_parts)
148
295
 
149
- prompt = "\n".join(prompt_parts)
150
296
 
151
- if model is None:
152
- 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
153
302
 
154
- if model.startswith("github_copilot/"):
155
- model = model.replace("github_copilot/", "")
156
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."""
157
311
  return github_copilot.ask(
158
312
  f"""
159
313
  # System Prompt
160
314
 
161
- {load_system_prompt()}
315
+ {system_prompt}
162
316
 
163
317
  # Prompt
164
318
 
165
319
  {prompt}
166
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,
167
335
  model=model,
168
336
  http_client_config=http_client_config,
169
337
  )
170
338
 
171
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(),
364
+ model=model,
365
+ context=context,
366
+ http_client_config=http_client_config,
367
+ )
368
+
369
+
172
370
  def commit_with_retry_no_verify(
173
- 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,
174
375
  ) -> str:
175
376
  """Run commit and offer one retry with -n on failure."""
176
377
  try:
177
- return repo.commit(message, use_editor=use_editor)
378
+ return repo.commit(message, use_editor=use_editor, env=env)
178
379
  except GitError as e:
179
380
  console.print(f"[red]Commit failed: {e}[/red]")
180
381
  if not Confirm.ask(
@@ -184,15 +385,454 @@ def commit_with_retry_no_verify(
184
385
  raise typer.Exit(1)
185
386
 
186
387
  try:
187
- 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)
188
389
  except GitError as retry_error:
189
390
  console.print(f"[red]Commit with -n failed: {retry_error}[/red]")
190
391
  raise typer.Exit(1)
191
392
 
192
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
+
193
828
  @app.command("authenticate")
194
829
  @app.command("login", hidden=True)
195
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
+ ),
196
836
  force: bool = typer.Option(
197
837
  False, "--force", help="Replace cached GitHub Copilot credentials"
198
838
  ),
@@ -208,11 +848,63 @@ def authenticate(
208
848
  )
209
849
  try:
210
850
  github_copilot.login(
851
+ enterprise_domain=enterprise_domain,
211
852
  force=force,
212
853
  http_client_config=http_client_config,
213
854
  )
214
855
  except github_copilot.CopilotError as exc:
215
- console.print(f"[red]Authentication failed: {exc}[/red]")
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
+
897
+ try:
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)
906
+ except github_copilot.CopilotError as exc:
907
+ print_copilot_error("Could not load models", exc)
216
908
  raise typer.Exit(1)
217
909
 
218
910
 
@@ -221,8 +913,14 @@ def commit(
221
913
  all_files: bool = typer.Option(
222
914
  False, "--all", "-a", help="Stage all files before committing"
223
915
  ),
916
+ split: SplitOption = False,
917
+ split_count: SplitCountOption = None,
224
918
  model: str | None = typer.Option(
225
- 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",
226
924
  ),
227
925
  yes: bool = typer.Option(
228
926
  False, "--yes", "-y", help="Automatically accept the generated commit message"
@@ -246,31 +944,12 @@ def commit(
246
944
  console.print("[red]Error: Not in a git repository[/red]")
247
945
  raise typer.Exit(1)
248
946
 
249
- try:
250
- existing_credentials = github_copilot.load_credentials()
251
- except github_copilot.CopilotError:
252
- existing_credentials = None
253
-
254
947
  http_client_config = build_http_client_config(
255
948
  ca_bundle=ca_bundle,
256
949
  insecure=insecure,
257
950
  native_tls=native_tls,
258
951
  )
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
952
+ ensure_copilot_authentication(http_client_config)
274
953
 
275
954
  # Get initial status
276
955
  status = repo.get_status()
@@ -279,98 +958,47 @@ def commit(
279
958
  console.print("[yellow]No changes to commit.[/yellow]")
280
959
  raise typer.Exit()
281
960
 
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]")
961
+ status = stage_changes_for_commit(repo, status, all_files=all_files)
306
962
 
307
963
  if context:
308
964
  console.print(
309
965
  Panel(context.strip(), title="User Context", border_style="magenta")
310
966
  )
311
967
 
968
+ normalized_model = normalize_model_name(model)
312
969
  try:
313
- github_copilot.ensure_auth_ready(
314
- model=model,
970
+ selected_model = github_copilot.ensure_auth_ready(
971
+ model=normalized_model,
315
972
  http_client_config=http_client_config,
316
973
  )
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
974
  except github_copilot.CopilotError as exc:
329
- console.print(f"[red]Could not generate a commit message: {exc}[/red]")
975
+ print_copilot_error("Could not select a model", exc)
330
976
  raise typer.Exit(1)
331
977
 
332
- console.print("[yellow]Generated commit message.[/yellow]")
978
+ display_selected_model(selected_model)
979
+ model = selected_model.id
333
980
 
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,
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,
341
990
  )
342
- )
343
-
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()
991
+ return
354
992
 
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]")
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
+ )
373
1001
 
374
1002
 
375
1003
  if __name__ == "__main__":
376
- app()
1004
+ run()