git-copilot-commit 0.6.0__py3-none-any.whl → 0.6.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
@@ -9,12 +9,10 @@ import re
9
9
  import sys
10
10
  from typing import Annotated, Sequence
11
11
 
12
- import rich
13
- import typer
12
+ import cyclopts
14
13
  from rich.console import Console
15
14
  from rich.panel import Panel
16
- from rich.prompt import Confirm
17
- from typer.main import get_command
15
+ from rich.prompt import Confirm, Prompt
18
16
 
19
17
  from .git import GitRepository, GitError, GitStatus, NotAGitRepositoryError
20
18
  from .split_commits import (
@@ -34,7 +32,7 @@ from .llms import core as llm
34
32
  from .llms import providers
35
33
 
36
34
  console = Console()
37
- app = typer.Typer(help=__doc__, add_completion=False)
35
+ app = cyclopts.App(help=__doc__, version=lambda: f"git-copilot-commit {__version__}")
38
36
 
39
37
  COMMIT_MESSAGE_PROMPT_FILENAME = "commit-message-generator-prompt.md"
40
38
  SPLIT_COMMIT_PLANNER_PROMPT_FILENAME = "split-commit-planner-prompt.md"
@@ -57,28 +55,31 @@ NATIVE_TLS_HELP = (
57
55
 
58
56
  CaBundleOption = Annotated[
59
57
  str | None,
60
- typer.Option("--ca-bundle", metavar="PATH", help=CA_BUNDLE_HELP),
58
+ cyclopts.Parameter(name="--ca-bundle", help=CA_BUNDLE_HELP),
61
59
  ]
62
60
  InsecureOption = Annotated[
63
61
  bool,
64
- typer.Option("--insecure", help="Disable SSL certificate verification."),
62
+ cyclopts.Parameter(name="--insecure", help="Disable SSL certificate verification."),
65
63
  ]
66
64
  NativeTlsOption = Annotated[
67
65
  bool,
68
- typer.Option("--native-tls/--no-native-tls", help=NATIVE_TLS_HELP),
66
+ cyclopts.Parameter(
67
+ name="--native-tls",
68
+ negative="--no-native-tls",
69
+ help=NATIVE_TLS_HELP,
70
+ ),
69
71
  ]
70
72
  ProviderOption = Annotated[
71
73
  str | None,
72
- typer.Option(
73
- "--provider",
74
+ cyclopts.Parameter(
75
+ name="--provider",
74
76
  help="LLM provider to use: copilot or openai.",
75
77
  ),
76
78
  ]
77
79
  BaseUrlOption = Annotated[
78
80
  str | None,
79
- typer.Option(
80
- "--base-url",
81
- metavar="URL",
81
+ cyclopts.Parameter(
82
+ name="--base-url",
82
83
  help=(
83
84
  "Base URL for an OpenAI-compatible provider, for example "
84
85
  "http://127.0.0.1:11434/v1."
@@ -87,8 +88,8 @@ BaseUrlOption = Annotated[
87
88
  ]
88
89
  ApiKeyOption = Annotated[
89
90
  str | None,
90
- typer.Option(
91
- "--api-key",
91
+ cyclopts.Parameter(
92
+ name="--api-key",
92
93
  help="API key for an OpenAI-compatible provider. Omit when the server does not require one.",
93
94
  ),
94
95
  ]
@@ -96,8 +97,8 @@ ApiKeyOption = Annotated[
96
97
 
97
98
  SplitOption = Annotated[
98
99
  bool,
99
- typer.Option(
100
- "--split",
100
+ cyclopts.Parameter(
101
+ name="--split",
101
102
  help=(
102
103
  "Split staged hunks into multiple commits automatically. Pass "
103
104
  "`--split=N` to express a preference for N commits."
@@ -106,10 +107,10 @@ SplitOption = Annotated[
106
107
  ]
107
108
  SplitCountOption = Annotated[
108
109
  int | None,
109
- typer.Option(
110
- "--split-count",
111
- hidden=True,
112
- min=1,
110
+ cyclopts.Parameter(
111
+ name="--split-count",
112
+ show=False,
113
+ validator=cyclopts.validators.Number(gte=1),
113
114
  ),
114
115
  ]
115
116
 
@@ -225,39 +226,13 @@ def order_prepared_split_commits(
225
226
  def run(args: Sequence[str] | None = None) -> None:
226
227
  """Run the CLI entrypoint with argument normalization."""
227
228
  raw_args = list(args) if args is not None else sys.argv[1:]
228
- command = get_command(app)
229
- command.main(
230
- args=preprocess_cli_args(raw_args),
231
- prog_name=Path(sys.argv[0]).name,
232
- )
233
-
234
-
235
- def version_callback(value: bool):
236
- if value:
237
- rich.print(f"git-copilot-commit [bold yellow]{__version__}[/]")
238
- raise typer.Exit()
229
+ app(preprocess_cli_args(raw_args))
239
230
 
240
231
 
241
- @app.callback(invoke_without_command=True)
242
- def main(
243
- ctx: typer.Context,
244
- _: bool = typer.Option(
245
- False, "--version", callback=version_callback, help="Show version and exit"
246
- ),
247
- ):
248
- """
249
- Automatically commit changes in the current git repository.
250
- """
251
- if ctx.invoked_subcommand is None:
252
- # Show help when no command is provided
253
- console.print(ctx.get_help())
254
- raise typer.Exit()
255
- else:
256
- # Don't show version for print command to avoid interfering with pipes
257
- if ctx.invoked_subcommand != "echo":
258
- console.print(
259
- f"[bold]{(__package__ or 'git_copilot_commit').replace('_', '-')}[/] - [bold green]v{__version__}[/]\n"
260
- )
232
+ def print_cli_banner() -> None:
233
+ """Render the CLI banner before command output."""
234
+ package_name = (__package__ or "git_copilot_commit").replace("_", "-")
235
+ console.print(f"[bold]{package_name}[/] - [bold green]v{__version__}[/]\n")
261
236
 
262
237
 
263
238
  def get_prompt_locations(filename: str):
@@ -280,7 +255,7 @@ def resolve_prompt_file() -> Path | None:
280
255
  console.print(
281
256
  f"[red]Configured default prompt file in {settings.config_file} is invalid.[/red]"
282
257
  )
283
- raise typer.Exit(1)
258
+ raise SystemExit(1)
284
259
 
285
260
  if configured_prompt_file is None:
286
261
  return None
@@ -298,7 +273,7 @@ def load_system_prompt() -> str:
298
273
  console.print(
299
274
  f"[red]Error reading prompt file {resolved_prompt_file}: {exc}[/red]"
300
275
  )
301
- raise typer.Exit(1)
276
+ raise SystemExit(1)
302
277
 
303
278
  return load_named_prompt(COMMIT_MESSAGE_PROMPT_FILENAME)
304
279
 
@@ -312,7 +287,7 @@ def load_named_prompt(filename: str) -> str:
312
287
  continue
313
288
 
314
289
  console.print(f"[red]Error: Prompt file {filename} not found in any location[/red]")
315
- raise typer.Exit(1)
290
+ raise SystemExit(1)
316
291
 
317
292
 
318
293
  def build_http_client_config(
@@ -357,7 +332,7 @@ def build_commit_message_prompt(
357
332
  """Build the prompt used to generate a commit message."""
358
333
  if not status.has_staged_changes:
359
334
  console.print("[red]No staged changes to commit.[/red]")
360
- raise typer.Exit()
335
+ raise SystemExit()
361
336
 
362
337
  prompt_parts = [
363
338
  "`git status`:\n",
@@ -383,7 +358,6 @@ def normalize_model_name(model: str | None) -> str | None:
383
358
  if model is not None:
384
359
  for prefix in (
385
360
  "copilot/",
386
- "openai/",
387
361
  "openai-compatible/",
388
362
  ):
389
363
  if model.startswith(prefix):
@@ -506,13 +480,13 @@ def commit_with_retry_no_verify(
506
480
  "Retry commit with [bold]`-n`[/] (skip hooks) using the same commit message?",
507
481
  default=True,
508
482
  ):
509
- raise typer.Exit(1)
483
+ raise SystemExit(1)
510
484
 
511
485
  try:
512
486
  return repo.commit(message, use_editor=use_editor, no_verify=True, env=env)
513
487
  except GitError as retry_error:
514
488
  console.print(f"[red]Commit with -n failed: {retry_error}[/red]")
515
- raise typer.Exit(1)
489
+ raise SystemExit(1)
516
490
 
517
491
 
518
492
  def ensure_copilot_authentication(
@@ -534,7 +508,7 @@ def ensure_copilot_authentication(
534
508
  )
535
509
  except copilot.LLMError as exc:
536
510
  print_llm_error("Authentication failed", exc)
537
- raise typer.Exit(1)
511
+ raise SystemExit(1)
538
512
 
539
513
 
540
514
  def stage_changes_for_commit(
@@ -590,7 +564,7 @@ def request_commit_message(
590
564
  )
591
565
  except llm.LLMError as exc:
592
566
  print_llm_error("Could not generate a commit message", exc)
593
- raise typer.Exit(1)
567
+ raise SystemExit(1)
594
568
 
595
569
 
596
570
  def request_split_commit_plan(
@@ -626,7 +600,7 @@ def request_split_commit_plan(
626
600
  except llm.LLMError as exc:
627
601
  if not should_retry_with_compact_prompt(exc):
628
602
  print_llm_error("Could not generate a split commit plan", exc)
629
- raise typer.Exit(1)
603
+ raise SystemExit(1)
630
604
 
631
605
  console.print(
632
606
  "[yellow]Staged patch units exceeded the model context window; retrying split planning with summaries only.[/yellow]"
@@ -658,7 +632,7 @@ def request_split_commit_plan(
658
632
  )
659
633
  except llm.LLMError as exc:
660
634
  print_llm_error("Could not generate a split commit plan", exc)
661
- raise typer.Exit(1)
635
+ raise SystemExit(1)
662
636
 
663
637
  return parse_split_plan_response(
664
638
  response,
@@ -700,7 +674,7 @@ def request_split_commit_messages(
700
674
  return prepared_commits
701
675
  except llm.LLMError as exc:
702
676
  print_llm_error("Could not generate split commit messages", exc)
703
- raise typer.Exit(1)
677
+ raise SystemExit(1)
704
678
 
705
679
 
706
680
  def confirm_split_commit_count(
@@ -730,7 +704,7 @@ def confirm_split_commit_count(
730
704
  return plan
731
705
 
732
706
  console.print("Split commit plan cancelled.")
733
- raise typer.Exit()
707
+ raise SystemExit()
734
708
 
735
709
 
736
710
  def display_commit_message(commit_message: str) -> None:
@@ -769,7 +743,7 @@ def execute_commit_action(
769
743
  if yes:
770
744
  return commit_with_retry_no_verify(repo, commit_message)
771
745
 
772
- choice = typer.prompt(
746
+ choice = Prompt.ask(
773
747
  "Choose action: (c)ommit, (e)dit message, (q)uit",
774
748
  default="c",
775
749
  show_default=True,
@@ -777,7 +751,7 @@ def execute_commit_action(
777
751
 
778
752
  if choice == "q":
779
753
  console.print("Commit cancelled.")
780
- raise typer.Exit()
754
+ raise SystemExit()
781
755
  if choice == "e":
782
756
  console.print("[cyan]Opening git editor...[/cyan]")
783
757
  return commit_with_retry_no_verify(repo, commit_message, use_editor=True)
@@ -785,7 +759,7 @@ def execute_commit_action(
785
759
  return commit_with_retry_no_verify(repo, commit_message)
786
760
 
787
761
  console.print("Invalid choice. Commit cancelled.")
788
- raise typer.Exit()
762
+ raise SystemExit()
789
763
 
790
764
 
791
765
  def execute_split_commit_plan(
@@ -797,7 +771,7 @@ def execute_split_commit_plan(
797
771
  """Run the split-commit plan against temporary alternate indexes."""
798
772
  use_editor = False
799
773
  if not yes:
800
- choice = typer.prompt(
774
+ choice = Prompt.ask(
801
775
  "Choose action: (c)ommit all, (e)dit each message, (q)uit",
802
776
  default="c",
803
777
  show_default=True,
@@ -805,12 +779,12 @@ def execute_split_commit_plan(
805
779
 
806
780
  if choice == "q":
807
781
  console.print("Commit cancelled.")
808
- raise typer.Exit()
782
+ raise SystemExit()
809
783
  if choice == "e":
810
784
  use_editor = True
811
785
  elif choice != "c":
812
786
  console.print("Invalid choice. Commit cancelled.")
813
- raise typer.Exit()
787
+ raise SystemExit()
814
788
 
815
789
  execution_state = SplitCommitExecutionState(
816
790
  original_head_sha=repo.get_head_sha() if repo.has_commit("HEAD") else None,
@@ -840,16 +814,19 @@ def execute_split_commit_plan(
840
814
  console.print(
841
815
  f"[red]Failed to apply the planned changes for commit {index}: {exc}[/red]"
842
816
  )
843
- raise typer.Exit(1)
844
-
845
- commit_shas.append(
846
- commit_with_retry_no_verify(
847
- repo,
848
- prepared_commit.message,
849
- use_editor=use_editor,
850
- env=alternate_index.env,
817
+ raise SystemExit(1)
818
+
819
+ try:
820
+ commit_shas.append(
821
+ repo.create_commit_from_index(
822
+ prepared_commit.message,
823
+ index=alternate_index,
824
+ use_editor=use_editor,
825
+ )
851
826
  )
852
- )
827
+ except GitError as exc:
828
+ console.print(f"[red]Failed to create commit {index}: {exc}[/red]")
829
+ raise SystemExit(1)
853
830
  except BaseException:
854
831
  try:
855
832
  if execution_state.original_head_sha is not None:
@@ -1012,22 +989,29 @@ def handle_split_commit_flow(
1012
989
  console.print(f"[green]{commit_sha[:8]}[/green] {prepared_commit.message}")
1013
990
 
1014
991
 
1015
- @app.command("authenticate")
1016
- @app.command("login", hidden=True)
992
+ @app.command(name="authenticate")
993
+ @app.command(name="login", show=False)
1017
994
  def authenticate(
1018
- enterprise_domain: str | None = typer.Option(
1019
- None,
1020
- "--enterprise-domain",
1021
- help="GitHub Enterprise hostname. Omit for github.com.",
1022
- ),
1023
- force: bool = typer.Option(
1024
- False, "--force", help="Replace cached GitHub Copilot credentials"
1025
- ),
995
+ enterprise_domain: Annotated[
996
+ str | None,
997
+ cyclopts.Parameter(
998
+ name="--enterprise-domain",
999
+ help="GitHub Enterprise hostname. Omit for github.com.",
1000
+ ),
1001
+ ] = None,
1002
+ force: Annotated[
1003
+ bool,
1004
+ cyclopts.Parameter(
1005
+ name="--force",
1006
+ help="Replace cached GitHub Copilot credentials",
1007
+ ),
1008
+ ] = False,
1026
1009
  ca_bundle: CaBundleOption = None,
1027
1010
  insecure: InsecureOption = False,
1028
1011
  native_tls: NativeTlsOption = False,
1029
1012
  ):
1030
1013
  """Authenticate with GitHub Copilot and cache credentials locally."""
1014
+ print_cli_banner()
1031
1015
  http_client_config = build_http_client_config(
1032
1016
  ca_bundle=ca_bundle,
1033
1017
  insecure=insecure,
@@ -1041,10 +1025,10 @@ def authenticate(
1041
1025
  )
1042
1026
  except copilot.LLMError as exc:
1043
1027
  print_llm_error("Authentication failed", exc)
1044
- raise typer.Exit(1)
1028
+ raise SystemExit(1)
1045
1029
 
1046
1030
 
1047
- @app.command("summary")
1031
+ @app.command(name="summary")
1048
1032
  def summary(
1049
1033
  provider: ProviderOption = None,
1050
1034
  base_url: BaseUrlOption = None,
@@ -1054,6 +1038,7 @@ def summary(
1054
1038
  native_tls: NativeTlsOption = False,
1055
1039
  ):
1056
1040
  """Show the configured LLM provider summary."""
1041
+ print_cli_banner()
1057
1042
  http_client_config = build_http_client_config(
1058
1043
  ca_bundle=ca_bundle,
1059
1044
  insecure=insecure,
@@ -1071,24 +1056,27 @@ def summary(
1071
1056
  )
1072
1057
  except llm.LLMError as exc:
1073
1058
  print_llm_error("Could not load provider summary", exc)
1074
- raise typer.Exit(1)
1059
+ raise SystemExit(1)
1075
1060
 
1076
1061
 
1077
- @app.command("models")
1062
+ @app.command(name="models")
1078
1063
  def models_command(
1079
1064
  provider: ProviderOption = None,
1080
1065
  base_url: BaseUrlOption = None,
1081
1066
  api_key: ApiKeyOption = None,
1082
- vendor: str | None = typer.Option(
1083
- None,
1084
- "--vendor",
1085
- help="Filter listed models by vendor: anthropic, gemini/google, or openai.",
1086
- ),
1067
+ vendor: Annotated[
1068
+ str | None,
1069
+ cyclopts.Parameter(
1070
+ name="--vendor",
1071
+ help="Filter listed models by vendor: anthropic, gemini/google, or openai.",
1072
+ ),
1073
+ ] = None,
1087
1074
  ca_bundle: CaBundleOption = None,
1088
1075
  insecure: InsecureOption = False,
1089
1076
  native_tls: NativeTlsOption = False,
1090
1077
  ):
1091
1078
  """List available models for the configured LLM provider."""
1079
+ print_cli_banner()
1092
1080
  http_client_config = build_http_client_config(
1093
1081
  ca_bundle=ca_bundle,
1094
1082
  insecure=insecure,
@@ -1116,32 +1104,41 @@ def models_command(
1116
1104
  )
1117
1105
  except llm.LLMError as exc:
1118
1106
  print_llm_error("Could not load models", exc)
1119
- raise typer.Exit(1)
1107
+ raise SystemExit(1)
1120
1108
 
1121
1109
 
1122
- @app.command()
1110
+ @app.command
1123
1111
  def commit(
1124
- all_files: bool = typer.Option(
1125
- False, "--all", "-a", help="Stage all files before committing"
1126
- ),
1112
+ all_files: Annotated[
1113
+ bool,
1114
+ cyclopts.Parameter(
1115
+ name=("--all", "-a"),
1116
+ help="Stage all files before committing",
1117
+ ),
1118
+ ] = False,
1127
1119
  split: SplitOption = False,
1128
1120
  split_count: SplitCountOption = None,
1129
- model: str | None = typer.Option(
1130
- None,
1131
- "--model",
1132
- "-m",
1133
- metavar="MODEL_ID",
1134
- help="Model to use for generating commit message",
1135
- ),
1136
- yes: bool = typer.Option(
1137
- False, "--yes", "-y", help="Automatically accept the generated commit message"
1138
- ),
1139
- context: str = typer.Option(
1140
- "",
1141
- "--context",
1142
- "-c",
1143
- help="Optional user-provided context to guide commit message",
1144
- ),
1121
+ model: Annotated[
1122
+ str | None,
1123
+ cyclopts.Parameter(
1124
+ name=("--model", "-m"),
1125
+ help="Model to use for generating commit message",
1126
+ ),
1127
+ ] = None,
1128
+ yes: Annotated[
1129
+ bool,
1130
+ cyclopts.Parameter(
1131
+ name=("--yes", "-y"),
1132
+ help="Automatically accept the generated commit message",
1133
+ ),
1134
+ ] = False,
1135
+ context: Annotated[
1136
+ str,
1137
+ cyclopts.Parameter(
1138
+ name=("--context", "-c"),
1139
+ help="Optional user-provided context to guide commit message",
1140
+ ),
1141
+ ] = "",
1145
1142
  provider: ProviderOption = None,
1146
1143
  base_url: BaseUrlOption = None,
1147
1144
  api_key: ApiKeyOption = None,
@@ -1152,11 +1149,12 @@ def commit(
1152
1149
  """
1153
1150
  Generate commit message based on changes in the current git repository and commit them.
1154
1151
  """
1152
+ print_cli_banner()
1155
1153
  try:
1156
1154
  repo = GitRepository()
1157
1155
  except NotAGitRepositoryError:
1158
1156
  console.print("[red]Error: Not in a git repository[/red]")
1159
- raise typer.Exit(1)
1157
+ raise SystemExit(1)
1160
1158
 
1161
1159
  http_client_config = build_http_client_config(
1162
1160
  ca_bundle=ca_bundle,
@@ -1171,7 +1169,7 @@ def commit(
1171
1169
  )
1172
1170
  except llm.LLMError as exc:
1173
1171
  print_llm_error("Could not resolve the LLM provider", exc)
1174
- raise typer.Exit(1)
1172
+ raise SystemExit(1)
1175
1173
 
1176
1174
  if provider_config.provider == "copilot":
1177
1175
  ensure_copilot_authentication(http_client_config)
@@ -1181,7 +1179,7 @@ def commit(
1181
1179
 
1182
1180
  if not status.files:
1183
1181
  console.print("[yellow]No changes to commit.[/yellow]")
1184
- raise typer.Exit()
1182
+ raise SystemExit()
1185
1183
 
1186
1184
  status = stage_changes_for_commit(repo, status, all_files=all_files)
1187
1185
 
@@ -1199,7 +1197,7 @@ def commit(
1199
1197
  )
1200
1198
  except llm.LLMError as exc:
1201
1199
  print_llm_error("Could not select a model", exc)
1202
- raise typer.Exit(1)
1200
+ raise SystemExit(1)
1203
1201
 
1204
1202
  display_selected_model(selected_model)
1205
1203
  model = selected_model.id
git_copilot_commit/git.py CHANGED
@@ -1,4 +1,5 @@
1
1
  import os
2
+ import shlex
2
3
  import subprocess
3
4
  import tempfile
4
5
  from contextlib import contextmanager
@@ -352,6 +353,15 @@ class GitRepository:
352
353
  )
353
354
  raise GitCommandError(f"Git command failed: git update-ref -d {ref}")
354
355
 
356
+ def update_ref(
357
+ self, ref: str, new_value: str, *, old_value: str | None = None
358
+ ) -> None:
359
+ """Update a ref to point at a new object id."""
360
+ args = ["update-ref", ref, new_value]
361
+ if old_value is not None:
362
+ args.append(old_value)
363
+ self._run_git_command(args)
364
+
355
365
  def create_alternate_index(self, from_ref: str = "HEAD") -> AlternateGitIndex:
356
366
  """Create a temporary git index initialized from the provided ref."""
357
367
  fd, index_path = tempfile.mkstemp(prefix="git-copilot-commit-", suffix=".index")
@@ -382,6 +392,15 @@ class GitRepository:
382
392
  """Initialize an alternate index with an empty tree."""
383
393
  self._run_git_command(["read-tree", "--empty"], env=index.env)
384
394
 
395
+ def write_tree(self, *, env: Mapping[str, str] | None = None) -> str:
396
+ """Write the current index as a tree object and return its SHA."""
397
+ result = self._run_git_command(["write-tree"], env=env)
398
+ tree_sha = result.stdout.strip()
399
+ if tree_sha:
400
+ return tree_sha
401
+
402
+ raise GitCommandError("Git command failed: git write-tree")
403
+
385
404
  def apply_patch(
386
405
  self,
387
406
  patch: str,
@@ -422,6 +441,110 @@ class GitRepository:
422
441
  """Validate whether a cached patch can be applied to an alternate index."""
423
442
  self.check_patch(patch, cached=True, env=index.env)
424
443
 
444
+ def edit_commit_message(
445
+ self,
446
+ message: str,
447
+ *,
448
+ env: Mapping[str, str] | None = None,
449
+ ) -> str:
450
+ """Open git's configured editor with a starting message."""
451
+ with tempfile.NamedTemporaryFile(
452
+ mode="w",
453
+ suffix=".txt",
454
+ delete=False,
455
+ encoding="utf-8",
456
+ ) as f:
457
+ f.write(message)
458
+ temp_path = Path(f.name)
459
+
460
+ try:
461
+ editor = self._run_git_command(
462
+ ["var", "GIT_EDITOR"], env=env
463
+ ).stdout.strip()
464
+ if not editor:
465
+ raise GitCommandError("Git command failed: git var GIT_EDITOR")
466
+
467
+ editor_command = f"{editor} {shlex.quote(str(temp_path))}"
468
+ try:
469
+ # Git editor configuration is shell syntax, so execute it the same way.
470
+ subprocess.run(
471
+ editor_command,
472
+ cwd=self.repo_path,
473
+ timeout=self.timeout,
474
+ check=True,
475
+ env=self._build_env(env),
476
+ shell=True,
477
+ )
478
+ except subprocess.CalledProcessError:
479
+ raise GitCommandError(f"Git editor failed: {editor_command}")
480
+ except subprocess.TimeoutExpired:
481
+ raise GitCommandError(f"Git editor timed out: {editor_command}")
482
+
483
+ edited_message = temp_path.read_text(encoding="utf-8").strip()
484
+ if edited_message:
485
+ return edited_message
486
+
487
+ raise GitCommandError("Commit message cannot be empty")
488
+ finally:
489
+ temp_path.unlink(missing_ok=True)
490
+
491
+ def create_commit_object(
492
+ self,
493
+ tree_sha: str,
494
+ *,
495
+ message: str | None = None,
496
+ message_file: Path | None = None,
497
+ parent_refs: tuple[str, ...] = (),
498
+ env: Mapping[str, str] | None = None,
499
+ ) -> str:
500
+ """Create a commit object without updating refs."""
501
+ if (message is None) == (message_file is None):
502
+ raise ValueError("Exactly one of message or message_file is required")
503
+
504
+ args = ["commit-tree", tree_sha]
505
+ for parent_ref in parent_refs:
506
+ args.extend(["-p", parent_ref])
507
+
508
+ if message_file is not None:
509
+ args.extend(["-F", str(message_file)])
510
+ result = self._run_git_command(args, env=env)
511
+ else:
512
+ result = self._run_git_command(args, env=env, input_text=message)
513
+
514
+ commit_sha = result.stdout.strip()
515
+ if commit_sha:
516
+ return commit_sha
517
+
518
+ raise GitCommandError(f"Git command failed: git {' '.join(args)}")
519
+
520
+ def advance_head_to_commit(self, commit_sha: str) -> None:
521
+ """Move HEAD or the current branch to a commit without touching the real index."""
522
+ ref = self.get_symbolic_head_ref() or "HEAD"
523
+ self.update_ref(ref, commit_sha)
524
+
525
+ def create_commit_from_index(
526
+ self,
527
+ message: str,
528
+ *,
529
+ index: AlternateGitIndex,
530
+ use_editor: bool = False,
531
+ ) -> str:
532
+ """Create a commit from an alternate index using plumbing commands only."""
533
+ commit_message = message
534
+ if use_editor:
535
+ commit_message = self.edit_commit_message(message, env=index.env)
536
+
537
+ parent_refs = ("HEAD",) if self.has_commit("HEAD") else ()
538
+ tree_sha = self.write_tree(env=index.env)
539
+ commit_sha = self.create_commit_object(
540
+ tree_sha,
541
+ message=commit_message,
542
+ parent_refs=parent_refs,
543
+ env=index.env,
544
+ )
545
+ self.advance_head_to_commit(commit_sha)
546
+ return commit_sha
547
+
425
548
  def commit(
426
549
  self,
427
550
  message: str | None = None,
@@ -1,15 +1,15 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: git-copilot-commit
3
- Version: 0.6.0
3
+ Version: 0.6.1
4
4
  Summary: Automatically generate and commit changes using GitHub Copilot or OpenAI-compatible LLMs
5
5
  Author-email: Dheepak Krishnamurthy <1813121+kdheepak@users.noreply.github.com>
6
6
  License-File: LICENSE
7
7
  Requires-Python: >=3.12
8
+ Requires-Dist: cyclopts>=4.11.0
8
9
  Requires-Dist: httpx>=0.28.0
9
10
  Requires-Dist: platformdirs>=4.0.0
10
11
  Requires-Dist: rich>=14.0.0
11
12
  Requires-Dist: truststore>=0.10.4
12
- Requires-Dist: typer>=0.16.0
13
13
  Description-Content-Type: text/markdown
14
14
 
15
15
  # `git-copilot-commit`
@@ -96,7 +96,11 @@ git-copilot-commit --help
96
96
 
97
97
  ### OpenAI-compatible provider
98
98
 
99
- 1. Point the CLI at your server:
99
+ 1. Point the CLI at your server.
100
+
101
+ The base URL can be either the provider root such as `http://127.0.0.1:11434/v1`
102
+ or the full chat completions endpoint such as
103
+ `http://127.0.0.1:11434/v1/chat/completions`.
100
104
 
101
105
  ```bash
102
106
  uvx git-copilot-commit models \
@@ -104,7 +108,7 @@ git-copilot-commit --help
104
108
  --base-url http://127.0.0.1:11434/v1
105
109
  ```
106
110
 
107
- 2. Generate and commit:
111
+ 2. Generate and commit.
108
112
 
109
113
  ```bash
110
114
  uvx git-copilot-commit commit \
@@ -115,6 +119,17 @@ git-copilot-commit --help
115
119
 
116
120
  If your server requires an API key, also pass `--api-key ...` or set `OPENAI_API_KEY`.
117
121
 
122
+ 3. Example: use a self-hosted GPT-OSS model:
123
+
124
+ ```bash
125
+ uvx git-copilot-commit commit \
126
+ --provider openai \
127
+ --base-url http://example.com:8001/v1/chat/completions \
128
+ --model openai/gpt-oss-120b
129
+ ```
130
+
131
+ Model ids with slashes such as `openai/gpt-oss-120b` are supported.
132
+
118
133
  ## Usage
119
134
 
120
135
  ### Commit changes
@@ -175,6 +190,15 @@ uvx git-copilot-commit commit \
175
190
  --model your-model-id
176
191
  ```
177
192
 
193
+ Use a self-hosted GPT-OSS endpoint:
194
+
195
+ ```bash
196
+ uvx git-copilot-commit commit \
197
+ --provider openai \
198
+ --base-url http://example.com:8001/v1/chat/completions \
199
+ --model openai/gpt-oss-120b
200
+ ```
201
+
178
202
  Split staged hunks into separate commits:
179
203
 
180
204
  ```bash
@@ -232,11 +256,21 @@ git ai-commit --all --yes --model claude-3.5-sonnet
232
256
  You can also set provider defaults with environment variables:
233
257
 
234
258
  ```bash
235
- export OPENAI_BASE_URL=http://127.0.0.1:11434/v1
259
+ export GIT_COPILOT_COMMIT_PROVIDER=openai
260
+ export GIT_COPILOT_COMMIT_BASE_URL=http://127.0.0.1:11434/v1
261
+ export GIT_COPILOT_COMMIT_API_KEY=...
236
262
  export OPENAI_API_KEY=...
237
263
  git ai-commit --provider openai --model your-model-id
238
264
  ```
239
265
 
266
+ For example:
267
+
268
+ ```bash
269
+ export GIT_COPILOT_COMMIT_PROVIDER=openai
270
+ export GIT_COPILOT_COMMIT_BASE_URL=http://example.com:8001/v1
271
+ git ai-commit --model openai/gpt-oss-120b
272
+ ```
273
+
240
274
  > [!TIP]
241
275
  >
242
276
  > Show more context in diffs by running the following command:
@@ -1,6 +1,6 @@
1
1
  git_copilot_commit/__init__.py,sha256=v3x5oBkxwKJEZLv62QqSmP3iqNKLtZgrWZfH8eFzlQg,60
2
- git_copilot_commit/cli.py,sha256=MtxamoK9t7R_q79cs-dz7txHZ9vEqn9MCTlW1ieL_TU,37995
3
- git_copilot_commit/git.py,sha256=dEsyazWfD2TIVCpDObwu6TDWRBkzxEjdIKHrQ59h7I0,16697
2
+ git_copilot_commit/cli.py,sha256=x5p_f71DhnYEED8-9rvJKqkjTbcwfXlN3zIvfxmYQfU,38011
3
+ git_copilot_commit/git.py,sha256=EbXiicWygSlMM-F6rY4LCkchwCvsFTziJcdUZM-1vnw,21059
4
4
  git_copilot_commit/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
5
  git_copilot_commit/settings.py,sha256=WrM10_J3F7QBfOVmPDWpNZrNHhmZSeN-9FqQZxgdWvQ,3730
6
6
  git_copilot_commit/split_commits.py,sha256=rHyuVJggjmYjbva7BVqsM3aZRxUgOKkuZtxxvFRcu6Q,15060
@@ -12,8 +12,8 @@ git_copilot_commit/llms/openai_api.py,sha256=wkadrdSDadbLRaLWEpOhsYYjrbtEYg14CFb
12
12
  git_copilot_commit/llms/providers.py,sha256=rA2mdCQR8pfVDhwV5mqpdHlT1nxkWtowQ1Smt0zXCa0,11565
13
13
  git_copilot_commit/prompts/commit-message-generator-prompt.md,sha256=3Dz8GCdumFNAtXOdTlpRtgBnmX0WyrPL6tdfMgNyYiE,2411
14
14
  git_copilot_commit/prompts/split-commit-planner-prompt.md,sha256=tDI0v1udOhkRQM31M892FMzcPMYHExnU0fjTGia1V2k,1510
15
- git_copilot_commit-0.6.0.dist-info/METADATA,sha256=C1cRXfdsxv-ulzjWnlLvhdHJbYGOkwjKPT9nxXNUWtI,8050
16
- git_copilot_commit-0.6.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
17
- git_copilot_commit-0.6.0.dist-info/entry_points.txt,sha256=-D4bQqiuSPwQJG2zx--vJbZD1iqB5coUfoJ_gmC3rSg,66
18
- git_copilot_commit-0.6.0.dist-info/licenses/LICENSE,sha256=14lNZAoKJPI1U7eGpletjN_PFm1JwP1vT_0jFKY6eWg,1065
19
- git_copilot_commit-0.6.0.dist-info/RECORD,,
15
+ git_copilot_commit-0.6.1.dist-info/METADATA,sha256=3pBOX2m_9i-UKx-dQUu5fwr2gA638RePMzEBe8rf4E4,8986
16
+ git_copilot_commit-0.6.1.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
17
+ git_copilot_commit-0.6.1.dist-info/entry_points.txt,sha256=-D4bQqiuSPwQJG2zx--vJbZD1iqB5coUfoJ_gmC3rSg,66
18
+ git_copilot_commit-0.6.1.dist-info/licenses/LICENSE,sha256=14lNZAoKJPI1U7eGpletjN_PFm1JwP1vT_0jFKY6eWg,1065
19
+ git_copilot_commit-0.6.1.dist-info/RECORD,,