git-copilot-commit 0.5.0__py3-none-any.whl → 0.5.2__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
@@ -19,11 +19,9 @@ from .git import GitRepository, GitError, GitStatus, NotAGitRepositoryError
19
19
  from .split_commits import (
20
20
  PatchUnit,
21
21
  SplitCommitPlan,
22
- SplitCommitLimitExceededError,
23
22
  SplitPlanningError,
24
23
  build_split_plan_prompt,
25
24
  build_status_for_patch_units,
26
- evaluate_auto_split,
27
25
  extract_patch_units,
28
26
  group_patch_units,
29
27
  parse_split_plan_response,
@@ -37,7 +35,6 @@ app = typer.Typer(help=__doc__, add_completion=False)
37
35
 
38
36
  COMMIT_MESSAGE_PROMPT_FILENAME = "commit-message-generator-prompt.md"
39
37
  SPLIT_COMMIT_PLANNER_PROMPT_FILENAME = "split-commit-planner-prompt.md"
40
- DEFAULT_AUTO_MAX_COMMITS = 10
41
38
  SPLIT_DIFF_ARGS = [
42
39
  "--binary",
43
40
  "--full-index",
@@ -75,7 +72,7 @@ SplitOption = Annotated[
75
72
  "--split",
76
73
  help=(
77
74
  "Split staged hunks into multiple commits automatically. Pass "
78
- "`--split=N` to prefer up to N commits."
75
+ "`--split=N` to express a preference for N commits."
79
76
  ),
80
77
  ),
81
78
  ]
@@ -128,11 +125,7 @@ def preprocess_cli_args(args: Sequence[str]) -> list[str]:
128
125
  index += 1
129
126
  continue
130
127
 
131
- if (
132
- in_commit_command
133
- and arg == "--split"
134
- and index + 1 < len(args)
135
- ):
128
+ if in_commit_command and arg == "--split" and index + 1 < len(args):
136
129
  split_value = args[index + 1].strip().lower()
137
130
  if split_value == "auto":
138
131
  processed_args.append("--split")
@@ -352,21 +345,6 @@ def generate_commit_message_for_status(
352
345
  )
353
346
 
354
347
 
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
-
370
348
  def commit_with_retry_no_verify(
371
349
  repo: GitRepository,
372
350
  message: str,
@@ -471,7 +449,6 @@ def request_split_commit_plan(
471
449
  status: GitStatus,
472
450
  patch_units: tuple[PatchUnit, ...],
473
451
  *,
474
- max_commits: int,
475
452
  preferred_commits: int | None = None,
476
453
  model: str | None = None,
477
454
  context: str = "",
@@ -482,7 +459,6 @@ def request_split_commit_plan(
482
459
  planner_prompt = build_split_plan_prompt(
483
460
  status,
484
461
  patch_units,
485
- max_commits=max_commits,
486
462
  preferred_commits=preferred_commits,
487
463
  context=context,
488
464
  )
@@ -499,7 +475,6 @@ def request_split_commit_plan(
499
475
  return parse_split_plan_response(
500
476
  response,
501
477
  patch_units,
502
- max_commits=max_commits,
503
478
  )
504
479
  except github_copilot.CopilotError as exc:
505
480
  print_copilot_error("Could not generate a split commit plan", exc)
@@ -541,25 +516,31 @@ def request_split_commit_messages(
541
516
  raise typer.Exit(1)
542
517
 
543
518
 
544
- def resolve_split_commit_limit(
545
- exc: SplitCommitLimitExceededError, *, yes: bool = False
519
+ def confirm_split_commit_count(
520
+ plan: SplitCommitPlan,
521
+ *,
522
+ preferred_commits: int,
523
+ yes: bool = False,
546
524
  ) -> SplitCommitPlan:
547
- """Ask whether to proceed when the planner exceeds the configured limit."""
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
+
548
530
  console.print(
549
- f"[yellow]Split planning produced {exc.actual_commits} commits, exceeding the automatic review limit of {exc.max_commits}.[/yellow]"
531
+ "[yellow]Split planning produced "
532
+ f"{actual_commits} commits, exceeding the preferred count of "
533
+ f"{preferred_commits}.[/yellow]"
550
534
  )
551
535
 
552
536
  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)
537
+ return plan
557
538
 
558
539
  if Confirm.ask(
559
- f"Proceed with [bold]{exc.actual_commits} commits[/] anyway?",
540
+ f"Proceed with [bold]{actual_commits} commits[/] anyway?",
560
541
  default=False,
561
542
  ):
562
- return exc.plan
543
+ return plan
563
544
 
564
545
  console.print("Split commit plan cancelled.")
565
546
  raise typer.Exit()
@@ -747,44 +728,24 @@ def handle_split_commit_flow(
747
728
  return
748
729
 
749
730
  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]")
731
+ console.print(
732
+ "[yellow]Planning split commits from the staged patch units.[/yellow]"
733
+ )
767
734
  else:
768
735
  console.print(
769
- f"[yellow]Planning up to {preferred_commits} commits from the staged patch units.[/yellow]"
736
+ "[yellow]Planning split commits with a preference for "
737
+ f"{preferred_commits} commits.[/yellow]"
770
738
  )
771
739
 
772
740
  try:
773
741
  split_plan = request_split_commit_plan(
774
742
  status,
775
743
  patch_units,
776
- max_commits=(
777
- DEFAULT_AUTO_MAX_COMMITS
778
- if preferred_commits is None
779
- else preferred_commits
780
- ),
781
744
  preferred_commits=preferred_commits,
782
745
  model=model,
783
746
  context=context,
784
747
  http_client_config=http_client_config,
785
748
  )
786
- except SplitCommitLimitExceededError as exc:
787
- split_plan = resolve_split_commit_limit(exc, yes=yes)
788
749
  except SplitPlanningError as exc:
789
750
  console.print(
790
751
  "[yellow]Split planning returned an invalid plan; falling back to a single commit.[/yellow]"
@@ -800,6 +761,13 @@ def handle_split_commit_flow(
800
761
  )
801
762
  return
802
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
+
803
771
  prepared_commits = request_split_commit_messages(
804
772
  split_plan,
805
773
  patch_units,
git_copilot_commit/git.py CHANGED
@@ -121,16 +121,31 @@ class GitRepository:
121
121
  Raises:
122
122
  NotAGitRepositoryError: If the path is not a git repository.
123
123
  """
124
- self.repo_path = repo_path or Path.cwd()
124
+ self.cwd = (repo_path or Path.cwd()).resolve()
125
125
  self.timeout = timeout
126
- self._validate_git_repo()
126
+ self.repo_path = self._resolve_repo_root()
127
127
 
128
- def _validate_git_repo(self) -> None:
129
- """Ensure we're in a git repository."""
128
+ def _resolve_repo_root(self) -> Path:
129
+ """Resolve and cache the repository top-level path."""
130
130
  try:
131
- self._run_git_command(["rev-parse", "--git-dir"])
132
- except GitCommandError:
133
- raise NotAGitRepositoryError(f"{self.repo_path} is not a git repository")
131
+ result = subprocess.run(
132
+ ["git", "rev-parse", "--show-toplevel"],
133
+ cwd=self.cwd,
134
+ capture_output=True,
135
+ text=True,
136
+ timeout=self.timeout,
137
+ check=True,
138
+ )
139
+ except subprocess.CalledProcessError:
140
+ raise NotAGitRepositoryError(f"{self.cwd} is not a git repository")
141
+ except subprocess.TimeoutExpired:
142
+ raise GitCommandError("Git command timed out: git rev-parse --show-toplevel")
143
+
144
+ repo_root = result.stdout.strip()
145
+ if not repo_root:
146
+ raise NotAGitRepositoryError(f"{self.cwd} is not a git repository")
147
+
148
+ return Path(repo_root)
134
149
 
135
150
  def _run_git_command(
136
151
  self,
@@ -188,6 +203,21 @@ class GitRepository:
188
203
  merged_env.update(env)
189
204
  return merged_env
190
205
 
206
+ def _normalize_paths(self, paths: list[str]) -> list[str]:
207
+ """Normalize user paths relative to the repository root."""
208
+ normalized_paths: list[str] = []
209
+ for path in paths:
210
+ path_obj = Path(path)
211
+ if path_obj.is_absolute():
212
+ normalized_paths.append(str(path_obj))
213
+ continue
214
+
215
+ normalized_paths.append(
216
+ os.path.relpath(self.cwd / path_obj, start=self.repo_path)
217
+ )
218
+
219
+ return normalized_paths
220
+
191
221
  def get_status(self, env: Mapping[str, str] | None = None) -> GitStatus:
192
222
  """
193
223
  Get comprehensive git status information.
@@ -263,16 +293,16 @@ class GitRepository:
263
293
  Stage files for commit.
264
294
 
265
295
  Args:
266
- paths: List of file paths to stage. If None, stages all files (git add .).
296
+ paths: List of file paths to stage. If None, stages all files.
267
297
  """
268
298
  if paths is None:
269
- self._run_git_command(["add", "."])
299
+ self._run_git_command(["add", "--all"])
270
300
  else:
271
- self._run_git_command(["add"] + paths)
301
+ self._run_git_command(["add"] + self._normalize_paths(paths))
272
302
 
273
303
  def stage_modified(self) -> None:
274
- """Stage all modified files (git add -u)."""
275
- self._run_git_command(["add", "-u"])
304
+ """Stage all modified tracked files."""
305
+ self._run_git_command(["add", "--update"])
276
306
 
277
307
  def unstage_files(self, paths: list[str] | None = None) -> None:
278
308
  """
@@ -284,7 +314,7 @@ class GitRepository:
284
314
  if paths is None:
285
315
  self._run_git_command(["reset", "HEAD"])
286
316
  else:
287
- self._run_git_command(["reset", "HEAD"] + paths)
317
+ self._run_git_command(["reset", "HEAD"] + self._normalize_paths(paths))
288
318
 
289
319
  def create_alternate_index(self, from_ref: str = "HEAD") -> AlternateGitIndex:
290
320
  """Create a temporary git index initialized from the provided ref."""
@@ -2,7 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import base64
4
4
  import json
5
- import os
5
+ import random
6
6
  import re
7
7
  import secrets
8
8
  import time
@@ -24,6 +24,7 @@ from .settings import Settings
24
24
  APP_NAME = "github-copilot-commit"
25
25
  CLI_AUTH_COMMAND = "git-copilot-commit authenticate"
26
26
  DEFAULT_GITHUB_DOMAIN = "github.com"
27
+ CREDENTIALS_FILENAME = "copilot-auth.json"
27
28
  USER_AGENT = "GitHubCopilotChat/0.35.0"
28
29
  EDITOR_VERSION = "vscode/1.107.0"
29
30
  EDITOR_PLUGIN_VERSION = "copilot-chat/0.35.0"
@@ -33,6 +34,11 @@ CLIENT_ID = base64.b64decode("SXYxLmI1MDdhMDhjODdlY2ZlOTg=").decode()
33
34
  INITIAL_POLL_INTERVAL_MULTIPLIER = 1.2
34
35
  SLOW_DOWN_POLL_INTERVAL_MULTIPLIER = 1.4
35
36
  DEFAULT_MODEL_ID = "gpt-5.3-codex"
37
+ HTTP_RETRY_ATTEMPTS = 3
38
+ HTTP_RETRY_BASE_DELAY_SECONDS = 0.5
39
+ HTTP_RETRY_MAX_DELAY_SECONDS = 4.0
40
+ HTTP_RETRY_MAX_JITTER_SECONDS = 0.25
41
+ HTTP_RETRYABLE_STATUS_CODES = {429, 500, 502, 503, 504}
36
42
  DEFAULT_MODEL_PREFERENCES = (
37
43
  "gpt-5.3-codex",
38
44
  "gpt-5.4",
@@ -267,15 +273,8 @@ class CopilotConfig:
267
273
  return cls(default_model=default_model)
268
274
 
269
275
 
270
- def xdg_data_home() -> Path:
271
- value = os.environ.get("XDG_DATA_HOME")
272
- if value:
273
- return Path(value).expanduser()
274
- return Path.home() / ".local" / "share"
275
-
276
-
277
276
  def credentials_path() -> Path:
278
- return xdg_data_home() / APP_NAME / "copilot-auth.json"
277
+ return Settings().state_dir / CREDENTIALS_FILENAME
279
278
 
280
279
 
281
280
  def config_path() -> Path:
@@ -365,7 +364,9 @@ def _maybe_enable_native_tls(native_tls: bool) -> None:
365
364
  _NATIVE_TLS_ENABLED = True
366
365
 
367
366
 
368
- def make_http_client(http_client_config: HttpClientConfig | None = None) -> httpx.Client:
367
+ def make_http_client(
368
+ http_client_config: HttpClientConfig | None = None,
369
+ ) -> httpx.Client:
369
370
  config = http_client_config or HttpClientConfig()
370
371
  _maybe_enable_native_tls(config.use_native_tls)
371
372
 
@@ -376,6 +377,34 @@ def make_http_client(http_client_config: HttpClientConfig | None = None) -> http
376
377
  )
377
378
 
378
379
 
380
+ def truncate_response_detail(detail: str) -> str:
381
+ detail = detail.strip()
382
+ if len(detail) > 400:
383
+ return f"{detail[:397]}..."
384
+ return detail
385
+
386
+
387
+ def should_retry_status_code(status_code: int) -> bool:
388
+ return status_code in HTTP_RETRYABLE_STATUS_CODES
389
+
390
+
391
+ def compute_retry_delay_seconds(attempt: int, retry_after: str | None = None) -> float:
392
+ if retry_after is not None:
393
+ try:
394
+ delay = float(retry_after.strip())
395
+ except ValueError:
396
+ delay = -1.0
397
+ else:
398
+ if delay >= 0:
399
+ return delay
400
+
401
+ backoff = min(
402
+ HTTP_RETRY_MAX_DELAY_SECONDS,
403
+ HTTP_RETRY_BASE_DELAY_SECONDS * (2**attempt),
404
+ )
405
+ return backoff + random.uniform(0.0, HTTP_RETRY_MAX_JITTER_SECONDS)
406
+
407
+
379
408
  def request_json(
380
409
  client: httpx.Client,
381
410
  method: str,
@@ -385,31 +414,48 @@ def request_json(
385
414
  data: dict[str, str] | None = None,
386
415
  json_body: Any | None = None,
387
416
  ) -> Any:
388
- try:
389
- response = client.request(
390
- method,
391
- url,
392
- headers=headers,
393
- data=data,
394
- json=json_body,
395
- )
396
- except httpx.HTTPError as exc:
397
- raise CopilotError(f"Request failed for {url}: {exc}") from exc
417
+ for attempt in range(HTTP_RETRY_ATTEMPTS):
418
+ try:
419
+ response = client.request(
420
+ method,
421
+ url,
422
+ headers=headers,
423
+ data=data,
424
+ json=json_body,
425
+ )
426
+ except httpx.TransportError as exc:
427
+ if attempt < HTTP_RETRY_ATTEMPTS - 1:
428
+ time.sleep(compute_retry_delay_seconds(attempt))
429
+ continue
430
+ raise CopilotError(f"Request failed for {url}: {exc}") from exc
431
+ except httpx.HTTPError as exc:
432
+ raise CopilotError(f"Request failed for {url}: {exc}") from exc
433
+
434
+ if response.is_error:
435
+ detail = truncate_response_detail(response.text)
436
+ error = CopilotHttpError(
437
+ response.status_code, response.reason_phrase, detail
438
+ )
439
+ if (
440
+ should_retry_status_code(response.status_code)
441
+ and attempt < HTTP_RETRY_ATTEMPTS - 1
442
+ ):
443
+ time.sleep(
444
+ compute_retry_delay_seconds(
445
+ attempt, response.headers.get("retry-after")
446
+ )
447
+ )
448
+ continue
449
+ raise error
398
450
 
399
- if response.is_error:
400
- detail = response.text.strip()
401
- if len(detail) > 400:
402
- detail = f"{detail[:397]}..."
403
- raise CopilotHttpError(response.status_code, response.reason_phrase, detail)
451
+ break
404
452
 
405
453
  content_type = response.headers.get("content-type", "")
406
454
  if "application/json" not in content_type:
407
455
  try:
408
456
  return response.json()
409
457
  except ValueError as exc:
410
- detail = response.text.strip()
411
- if len(detail) > 400:
412
- detail = f"{detail[:397]}..."
458
+ detail = truncate_response_detail(response.text)
413
459
  raise CopilotError(
414
460
  f"Expected JSON from {url}, got {content_type or 'unknown content type'}: {detail}"
415
461
  ) from exc
@@ -1020,118 +1066,141 @@ def responses_completion(
1020
1066
  "store": False,
1021
1067
  }
1022
1068
 
1023
- text_parts: list[str] = []
1024
- final_response: dict[str, Any] | None = None
1069
+ for attempt in range(HTTP_RETRY_ATTEMPTS):
1070
+ text_parts: list[str] = []
1071
+ final_response: dict[str, Any] | None = None
1025
1072
 
1026
- try:
1027
- with client.stream(
1028
- "POST",
1029
- url,
1030
- headers=copilot_request_headers(
1031
- credentials.copilot_token,
1032
- intent="conversation-edits",
1033
- accept="text/event-stream",
1034
- ),
1035
- json=request_body,
1036
- ) as response:
1037
- if response.is_error:
1038
- detail = response.read().decode("utf-8", errors="replace").strip()
1039
- if len(detail) > 400:
1040
- detail = f"{detail[:397]}..."
1041
- raise CopilotHttpError(
1042
- response.status_code, response.reason_phrase, detail
1043
- )
1073
+ try:
1074
+ with client.stream(
1075
+ "POST",
1076
+ url,
1077
+ headers=copilot_request_headers(
1078
+ credentials.copilot_token,
1079
+ intent="conversation-edits",
1080
+ accept="text/event-stream",
1081
+ ),
1082
+ json=request_body,
1083
+ ) as response:
1084
+ if response.is_error:
1085
+ detail = truncate_response_detail(
1086
+ response.read().decode("utf-8", errors="replace")
1087
+ )
1088
+ error = CopilotHttpError(
1089
+ response.status_code, response.reason_phrase, detail
1090
+ )
1091
+ if (
1092
+ should_retry_status_code(response.status_code)
1093
+ and attempt < HTTP_RETRY_ATTEMPTS - 1
1094
+ ):
1095
+ time.sleep(
1096
+ compute_retry_delay_seconds(
1097
+ attempt, response.headers.get("retry-after")
1098
+ )
1099
+ )
1100
+ continue
1101
+ raise error
1102
+
1103
+ content_type = response.headers.get("content-type", "")
1104
+ if "text/event-stream" not in content_type:
1105
+ body = response.read()
1106
+ try:
1107
+ payload = json.loads(body)
1108
+ except ValueError as exc:
1109
+ detail = truncate_response_detail(
1110
+ body.decode("utf-8", errors="replace")
1111
+ )
1112
+ raise CopilotError(
1113
+ f"Expected an SSE stream from {url}, got {content_type or 'unknown content type'}: {detail}"
1114
+ ) from exc
1115
+ return extract_response_text(payload)
1116
+
1117
+ for event in iter_sse_events(response, url):
1118
+ if not isinstance(event, dict):
1119
+ continue
1120
+
1121
+ event_type = event.get("type")
1122
+ if event_type == "response.output_text.delta":
1123
+ delta = event.get("delta")
1124
+ if isinstance(delta, str) and delta:
1125
+ text_parts.append(delta)
1126
+ continue
1127
+
1128
+ if event_type == "response.output_text.done" and not text_parts:
1129
+ text = event.get("text")
1130
+ if isinstance(text, str) and text.strip():
1131
+ text_parts.append(text)
1132
+ continue
1133
+
1134
+ if event_type == "error":
1135
+ error = event.get("error")
1136
+ if isinstance(error, dict):
1137
+ message = error.get("message")
1138
+ code = error.get("code")
1139
+ if isinstance(message, str) and message.strip():
1140
+ prefix = (
1141
+ f"{code}: "
1142
+ if isinstance(code, str) and code
1143
+ else ""
1144
+ )
1145
+ raise CopilotError(
1146
+ f"Responses stream error: {prefix}{message.strip()}"
1147
+ )
1148
+ raise CopilotError("Responses stream returned an error event.")
1149
+
1150
+ if event_type in {
1151
+ "response.completed",
1152
+ "response.failed",
1153
+ "response.incomplete",
1154
+ }:
1155
+ response_payload = event.get("response")
1156
+ if isinstance(response_payload, dict):
1157
+ final_response = response_payload
1158
+ except httpx.TransportError as exc:
1159
+ if attempt < HTTP_RETRY_ATTEMPTS - 1:
1160
+ time.sleep(compute_retry_delay_seconds(attempt))
1161
+ continue
1162
+ raise CopilotError(f"Request failed for {url}: {exc}") from exc
1163
+ except httpx.HTTPError as exc:
1164
+ raise CopilotError(f"Request failed for {url}: {exc}") from exc
1165
+
1166
+ text = "".join(text_parts).strip()
1167
+ if final_response is None:
1168
+ if text:
1169
+ return text
1170
+ raise CopilotError(
1171
+ "Responses stream ended without a terminal response event."
1172
+ )
1044
1173
 
1045
- content_type = response.headers.get("content-type", "")
1046
- if "text/event-stream" not in content_type:
1047
- body = response.read()
1048
- try:
1049
- payload = json.loads(body)
1050
- except ValueError as exc:
1051
- detail = body.decode("utf-8", errors="replace").strip()
1052
- if len(detail) > 400:
1053
- detail = f"{detail[:397]}..."
1174
+ status = final_response.get("status")
1175
+ if status == "failed":
1176
+ error = final_response.get("error")
1177
+ if isinstance(error, dict):
1178
+ message = error.get("message")
1179
+ code = error.get("code")
1180
+ if isinstance(message, str) and message.strip():
1181
+ prefix = f"{code}: " if isinstance(code, str) and code else ""
1054
1182
  raise CopilotError(
1055
- f"Expected an SSE stream from {url}, got {content_type or 'unknown content type'}: {detail}"
1056
- ) from exc
1057
- return extract_response_text(payload)
1058
-
1059
- for event in iter_sse_events(response, url):
1060
- if not isinstance(event, dict):
1061
- continue
1062
-
1063
- event_type = event.get("type")
1064
- if event_type == "response.output_text.delta":
1065
- delta = event.get("delta")
1066
- if isinstance(delta, str) and delta:
1067
- text_parts.append(delta)
1068
- continue
1069
-
1070
- if event_type == "response.output_text.done" and not text_parts:
1071
- text = event.get("text")
1072
- if isinstance(text, str) and text.strip():
1073
- text_parts.append(text)
1074
- continue
1075
-
1076
- if event_type == "error":
1077
- error = event.get("error")
1078
- if isinstance(error, dict):
1079
- message = error.get("message")
1080
- code = error.get("code")
1081
- if isinstance(message, str) and message.strip():
1082
- prefix = (
1083
- f"{code}: " if isinstance(code, str) and code else ""
1084
- )
1085
- raise CopilotError(
1086
- f"Responses stream error: {prefix}{message.strip()}"
1087
- )
1088
- raise CopilotError("Responses stream returned an error event.")
1089
-
1090
- if event_type in {
1091
- "response.completed",
1092
- "response.failed",
1093
- "response.incomplete",
1094
- }:
1095
- response_payload = event.get("response")
1096
- if isinstance(response_payload, dict):
1097
- final_response = response_payload
1098
- except httpx.HTTPError as exc:
1099
- raise CopilotError(f"Request failed for {url}: {exc}") from exc
1100
-
1101
- text = "".join(text_parts).strip()
1102
- if final_response is None:
1183
+ f"Responses API request failed: {prefix}{message.strip()}"
1184
+ )
1185
+ raise CopilotError("Responses API request failed.")
1186
+
1187
+ if status == "incomplete":
1188
+ details = final_response.get("incomplete_details")
1189
+ reason = "unknown"
1190
+ if isinstance(details, dict):
1191
+ raw_reason = details.get("reason")
1192
+ if isinstance(raw_reason, str) and raw_reason.strip():
1193
+ reason = raw_reason.strip()
1194
+ if text:
1195
+ return f"{text}\n\n[Response incomplete: {reason}]"
1196
+ raise CopilotError(f"Responses API response was incomplete: {reason}.")
1197
+
1103
1198
  if text:
1104
1199
  return text
1105
- raise CopilotError("Responses stream ended without a terminal response event.")
1106
-
1107
- status = final_response.get("status")
1108
- if status == "failed":
1109
- error = final_response.get("error")
1110
- if isinstance(error, dict):
1111
- message = error.get("message")
1112
- code = error.get("code")
1113
- if isinstance(message, str) and message.strip():
1114
- prefix = f"{code}: " if isinstance(code, str) and code else ""
1115
- raise CopilotError(
1116
- f"Responses API request failed: {prefix}{message.strip()}"
1117
- )
1118
- raise CopilotError("Responses API request failed.")
1119
-
1120
- if status == "incomplete":
1121
- details = final_response.get("incomplete_details")
1122
- reason = "unknown"
1123
- if isinstance(details, dict):
1124
- raw_reason = details.get("reason")
1125
- if isinstance(raw_reason, str) and raw_reason.strip():
1126
- reason = raw_reason.strip()
1127
- if text:
1128
- return f"{text}\n\n[Response incomplete: {reason}]"
1129
- raise CopilotError(f"Responses API response was incomplete: {reason}.")
1130
1200
 
1131
- if text:
1132
- return text
1201
+ return extract_response_text(final_response)
1133
1202
 
1134
- return extract_response_text(final_response)
1203
+ raise AssertionError("Responses completion exhausted retries unexpectedly.")
1135
1204
 
1136
1205
 
1137
1206
  def complete_text_prompt(
@@ -1375,7 +1444,9 @@ def login(
1375
1444
 
1376
1445
  if existing and not force:
1377
1446
  raise CopilotError(
1378
- f"Cached credentials already exist at {credentials_path()}. Re-run with --force to replace them."
1447
+ "Cached credentials already exist at "
1448
+ f"{credentials_path()}. "
1449
+ "Re-run with --force to replace them."
1379
1450
  )
1380
1451
 
1381
1452
  domain = normalized_domain or DEFAULT_GITHUB_DOMAIN
@@ -7,7 +7,6 @@ small number of coherent commits.
7
7
 
8
8
  - Produce commit groupings that keep related changes together.
9
9
  - Separate clearly unrelated changes when the staged units support it.
10
- - Prefer fewer commits when the relationship is ambiguous.
11
10
 
12
11
  ## Rules
13
12
 
@@ -3,6 +3,7 @@ Settings management using platformdirs for cross-platform directory paths.
3
3
  """
4
4
 
5
5
  import json
6
+ import warnings
6
7
  from typing import Any
7
8
 
8
9
  from platformdirs import (
@@ -50,10 +51,15 @@ class Settings:
50
51
  def _save_config(self) -> None:
51
52
  """Save configuration to file."""
52
53
  try:
53
- with open(self.config_file, "w") as f:
54
+ with open(self.config_file, "w", encoding="utf-8") as f:
54
55
  json.dump(self._config, f, indent=2)
55
- except IOError:
56
- pass # Silently fail if we can't write
56
+ f.write("\n")
57
+ except OSError as exc:
58
+ warnings.warn(
59
+ f"Could not save config to {self.config_file}: {exc}",
60
+ RuntimeWarning,
61
+ stacklevel=2,
62
+ )
57
63
 
58
64
  def get(self, key: str, default: Any = None) -> Any:
59
65
  """Get a configuration value."""
@@ -8,26 +8,12 @@ from typing import Any, Iterable, Sequence, cast
8
8
  from .git import GitFile, GitStatus
9
9
 
10
10
  DIFF_HEADER_PREFIX = "diff --git "
11
- AUTO_SPLIT_UNIT_THRESHOLD = 3
12
- AUTO_SPLIT_PATH_THRESHOLD = 4
13
11
 
14
12
 
15
13
  class SplitPlanningError(ValueError):
16
14
  """Raised when a split-commit plan cannot be built or validated."""
17
15
 
18
16
 
19
- class SplitCommitLimitExceededError(SplitPlanningError):
20
- """Raised when the planner returns a valid plan above the configured limit."""
21
-
22
- def __init__(self, plan: "SplitCommitPlan", max_commits: int) -> None:
23
- self.plan = plan
24
- self.max_commits = max_commits
25
- self.actual_commits = len(plan.commits)
26
- super().__init__(
27
- f"Planner returned {self.actual_commits} commits, exceeding the max of {max_commits}."
28
- )
29
-
30
-
31
17
  @dataclass(frozen=True, slots=True)
32
18
  class FilePatch:
33
19
  """Structured representation of a single file-level diff patch."""
@@ -69,33 +55,6 @@ class SplitCommitPlan:
69
55
  commits: tuple[SplitPlanCommit, ...]
70
56
 
71
57
 
72
- def evaluate_auto_split(patch_units: Sequence[PatchUnit]) -> tuple[bool, str]:
73
- """Return whether automatic split planning should run for the patch set."""
74
- if len(patch_units) < 2:
75
- return False, "fewer than two independent patch units were found"
76
-
77
- kinds = {unit.kind for unit in patch_units}
78
- staged_statuses = {unit.staged_status for unit in patch_units}
79
- paths = {unit.path for unit in patch_units}
80
-
81
- if len(patch_units) >= AUTO_SPLIT_UNIT_THRESHOLD:
82
- return True, f"found {len(patch_units)} independent patch units"
83
-
84
- if len(kinds) >= 2:
85
- return True, f"found mixed change kinds ({', '.join(sorted(kinds))})"
86
-
87
- if len(staged_statuses) >= 2:
88
- return True, (
89
- "found mixed staged change types "
90
- f"({', '.join(sorted(staged_statuses))})"
91
- )
92
-
93
- if len(paths) >= AUTO_SPLIT_PATH_THRESHOLD:
94
- return True, f"found changes across {len(paths)} files"
95
-
96
- return False, f"found only {len(patch_units)} similar patch units"
97
-
98
-
99
58
  def extract_patch_units(diff_text: str) -> list[PatchUnit]:
100
59
  """Convert a staged diff into patch units suitable for split planning."""
101
60
  units: list[PatchUnit] = []
@@ -212,13 +171,14 @@ def build_split_plan_prompt(
212
171
  status: GitStatus,
213
172
  patch_units: Sequence[PatchUnit],
214
173
  *,
215
- max_commits: int,
216
174
  preferred_commits: int | None = None,
217
175
  context: str = "",
218
176
  ) -> str:
219
177
  """Build the user prompt used to request a split-commit plan."""
220
178
  if not patch_units:
221
- raise SplitPlanningError("Cannot plan split commits without staged patch units.")
179
+ raise SplitPlanningError(
180
+ "Cannot plan split commits without staged patch units."
181
+ )
222
182
 
223
183
  prompt_parts = []
224
184
  if context.strip():
@@ -226,7 +186,6 @@ def build_split_plan_prompt(
226
186
 
227
187
  if preferred_commits is not None:
228
188
  prompt_parts.append(f"Preferred commits: {preferred_commits}")
229
- prompt_parts.append(f"Maximum commits: {max_commits}")
230
189
 
231
190
  prompt_parts.extend(
232
191
  [
@@ -256,8 +215,6 @@ def build_split_plan_prompt(
256
215
  def parse_split_plan_response(
257
216
  response_text: str,
258
217
  patch_units: Sequence[PatchUnit],
259
- *,
260
- max_commits: int,
261
218
  ) -> SplitCommitPlan:
262
219
  """Parse and validate the planner model's JSON response."""
263
220
  payload = parse_json_payload(response_text)
@@ -269,7 +226,9 @@ def parse_split_plan_response(
269
226
  commits_data = payload
270
227
 
271
228
  if not isinstance(commits_data, list) or not commits_data:
272
- raise SplitPlanningError("Planner response did not include a non-empty commits list.")
229
+ raise SplitPlanningError(
230
+ "Planner response did not include a non-empty commits list."
231
+ )
273
232
 
274
233
  units_by_id = {unit.id: unit for unit in patch_units}
275
234
  expected_ids = set(units_by_id)
@@ -305,7 +264,9 @@ def parse_split_plan_response(
305
264
  seen_in_commit.add(unit_id)
306
265
  unit_ids.append(unit_id)
307
266
 
308
- ordered_unit_ids = tuple(sorted(unit_ids, key=lambda unit_id: units_by_id[unit_id].order))
267
+ ordered_unit_ids = tuple(
268
+ sorted(unit_ids, key=lambda unit_id: units_by_id[unit_id].order)
269
+ )
309
270
  validated_commits.append(SplitPlanCommit(unit_ids=ordered_unit_ids))
310
271
  assigned_ids.extend(unit_ids)
311
272
 
@@ -326,8 +287,6 @@ def parse_split_plan_response(
326
287
  )
327
288
 
328
289
  plan = SplitCommitPlan(commits=tuple(validated_commits))
329
- if len(plan.commits) > max_commits:
330
- raise SplitCommitLimitExceededError(plan, max_commits)
331
290
 
332
291
  return plan
333
292
 
@@ -354,7 +313,9 @@ def build_status_for_patch_units(patch_units: Sequence[PatchUnit]) -> GitStatus:
354
313
  if file_key in seen_paths:
355
314
  continue
356
315
  seen_paths.add(file_key)
357
- files.append(GitFile(path=unit.path, status=" ", staged_status=unit.staged_status))
316
+ files.append(
317
+ GitFile(path=unit.path, status=" ", staged_status=unit.staged_status)
318
+ )
358
319
 
359
320
  return GitStatus(
360
321
  files=files,
@@ -397,7 +358,9 @@ def parse_diff_paths(diff_header_line: str) -> tuple[str, str]:
397
358
  try:
398
359
  parts = shlex.split(diff_header_line)
399
360
  except ValueError as exc:
400
- raise SplitPlanningError(f"Could not parse diff header: {diff_header_line}") from exc
361
+ raise SplitPlanningError(
362
+ f"Could not parse diff header: {diff_header_line}"
363
+ ) from exc
401
364
 
402
365
  if len(parts) < 4:
403
366
  raise SplitPlanningError(f"Unexpected diff header: {diff_header_line}")
@@ -419,10 +382,16 @@ def classify_file_patch(
419
382
  hunks: Sequence[str],
420
383
  ) -> tuple[str, str]:
421
384
  """Infer the staged status and planning kind for a file patch."""
422
- if any(line.startswith("GIT binary patch") or line.startswith("Binary files ") for line in lines):
385
+ if any(
386
+ line.startswith("GIT binary patch") or line.startswith("Binary files ")
387
+ for line in lines
388
+ ):
423
389
  return "M", "binary"
424
390
 
425
- if any(line.startswith("rename from ") or line.startswith("rename to ") for line in lines):
391
+ if any(
392
+ line.startswith("rename from ") or line.startswith("rename to ")
393
+ for line in lines
394
+ ):
426
395
  return "R", "rename"
427
396
 
428
397
  if any(line.startswith("new file mode ") for line in lines):
@@ -431,7 +400,9 @@ def classify_file_patch(
431
400
  if any(line.startswith("deleted file mode ") for line in lines):
432
401
  return "D", "deleted_file"
433
402
 
434
- if any(line.startswith("old mode ") or line.startswith("new mode ") for line in lines):
403
+ if any(
404
+ line.startswith("old mode ") or line.startswith("new mode ") for line in lines
405
+ ):
435
406
  return "M", "mode_change"
436
407
 
437
408
  if old_path == "/dev/null":
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: git-copilot-commit
3
- Version: 0.5.0
3
+ Version: 0.5.2
4
4
  Summary: Automatically generate and commit changes using GitHub Copilot
5
5
  Author-email: Dheepak Krishnamurthy <1813121+kdheepak@users.noreply.github.com>
6
6
  License-File: LICENSE
@@ -14,6 +14,10 @@ Description-Content-Type: text/markdown
14
14
 
15
15
  # `git-copilot-commit`
16
16
 
17
+ [![CI](https://img.shields.io/github/actions/workflow/status/kdheepak/git-copilot-commit/ci.yml?branch=main&label=CI)](https://github.com/kdheepak/git-copilot-commit/actions/workflows/ci.yml)
18
+ [![PyPI](https://img.shields.io/pypi/v/git-copilot-commit)](https://pypi.org/project/git-copilot-commit/)
19
+ [![License](https://img.shields.io/github/license/kdheepak/git-copilot-commit)](https://github.com/kdheepak/git-copilot-commit/blob/main/LICENSE)
20
+
17
21
  AI-powered Git commit assistant that generates conventional commit messages using GitHub Copilot.
18
22
 
19
23
  ![Screenshot of git-copilot-commit in action](https://github.com/user-attachments/assets/6a6d70a6-6060-44e6-8cf4-a6532e9e9142)
@@ -21,13 +25,13 @@ AI-powered Git commit assistant that generates conventional commit messages usin
21
25
  ## Features
22
26
 
23
27
  - Generates commit messages based on your staged changes
24
- - Supports multiple AI models: GPT-4, Claude, Gemini, and more
28
+ - Supports multiple LLM models: GPT-4, Claude, Gemini, and more
25
29
  - Allows editing of generated messages before committing
26
30
  - Follows the [Conventional Commits](https://www.conventionalcommits.org/) standard
27
31
 
28
32
  ## Installation
29
33
 
30
- ### Install the tool using [`uv`] (recommended)
34
+ ### Install the tool using [`uv`]
31
35
 
32
36
  Install [`uv`]:
33
37
 
@@ -39,14 +43,15 @@ curl -LsSf https://astral.sh/uv/install.sh | sh
39
43
  powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
40
44
  ```
41
45
 
42
- You can install and run the latest version of tool directly every time by invoking this one command:
46
+ You can run the latest version of tool directly every time by invoking this one command:
43
47
 
44
48
  ```bash
45
- # Install latest version into temporary environment and run --help
49
+ # Every invocation installs latest version into temporary environment and runs --help
46
50
  uvx git-copilot-commit --help
47
51
  ```
48
52
 
49
- Alternatively, you can install into a global isolated environment and run `git-copilot-commit`:
53
+ Alternatively, you can install the tool once into a global isolated environment
54
+ and run `git-copilot-commit` to invoke it:
50
55
 
51
56
  ```bash
52
57
  # Install into global isolated environment
@@ -58,14 +63,6 @@ git-copilot-commit --help
58
63
 
59
64
  [`uv`]: https://github.com/astral-sh/uv
60
65
 
61
- ### Install with `pipx`
62
-
63
- If you prefer to use `pipx`:
64
-
65
- ```bash
66
- pipx install git-copilot-commit
67
- ```
68
-
69
66
  ## Prerequisites
70
67
 
71
68
  - Active GitHub Copilot subscription
@@ -100,17 +97,26 @@ pipx install git-copilot-commit
100
97
 
101
98
  ```bash
102
99
  $ uvx git-copilot-commit commit --help
103
- Usage: git-copilot-commit commit [OPTIONS]
104
100
 
105
- Automatically commit changes in the current git repository.
106
-
107
- Options:
108
- -a, --all Stage all files before committing
109
- --split Split staged hunks into multiple commits. Use
110
- `--split=N` to prefer up to N commits.
111
- -m, --model TEXT Model to use for generating commit message
112
- -y, --yes Automatically accept the generated commit message
113
- --help Show this message and exit.
101
+ Usage: git-copilot-commit commit [OPTIONS]
102
+
103
+ Generate commit message based on changes in the current git repository and commit them.
104
+
105
+ ╭─ Options ────────────────────────────────────────────────────────────────────────────────────────────────╮
106
+ --all -a Stage all files before committing │
107
+ --split Split staged hunks into multiple commits automatically. │
108
+ │ Pass `--split=N` to express a preference for N commits. │
109
+ │ --model -m MODEL_ID Model to use for generating commit message │
110
+ │ --yes -y Automatically accept the generated commit message │
111
+ │ --context -c TEXT Optional user-provided context to guide commit message │
112
+ │ --ca-bundle PATH Path to a custom CA bundle (PEM) │
113
+ │ --insecure Disable SSL certificate verification. │
114
+ │ --native-tls --no-native-tls Use the OS's native certificate store via 'truststore' │
115
+ │ for httpx instead of the Python bundle. Ignored if │
116
+ │ --ca-bundle or --insecure is used. │
117
+ │ [default: no-native-tls] │
118
+ │ --help Show this message and exit. │
119
+ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯
114
120
  ```
115
121
 
116
122
  ## Examples
@@ -139,7 +145,7 @@ Split staged hunks into separate commits:
139
145
  uvx git-copilot-commit commit --split
140
146
  ```
141
147
 
142
- Prefer up to two commits:
148
+ Prefer two commits:
143
149
 
144
150
  ```bash
145
151
  uvx git-copilot-commit commit --split 2
@@ -194,3 +200,6 @@ git ai-commit --all --yes --model claude-3.5-sonnet
194
200
  > ```bash
195
201
  > git config --global diff.context 3
196
202
  > ```
203
+ >
204
+ > This may be useful because this tool sends the diffs with surrounding context
205
+ > to the LLM for generating a commit message
@@ -0,0 +1,15 @@
1
+ git_copilot_commit/__init__.py,sha256=v3x5oBkxwKJEZLv62QqSmP3iqNKLtZgrWZfH8eFzlQg,60
2
+ git_copilot_commit/cli.py,sha256=CyiGMEehjLc-J3OveVxXUK7mzPWpiwQgkSSB3IhSzoc,29910
3
+ git_copilot_commit/git.py,sha256=vNuh2j8TGmocLio4XgPpbXIktlgczNdEt2Fg1c40wuk,14962
4
+ git_copilot_commit/github_copilot.py,sha256=0CF8bnawxW6Bn5g7R7mbceFG4SCcMiueMOp4x8Vtqs8,49657
5
+ git_copilot_commit/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ git_copilot_commit/settings.py,sha256=WrM10_J3F7QBfOVmPDWpNZrNHhmZSeN-9FqQZxgdWvQ,3730
7
+ git_copilot_commit/split_commits.py,sha256=sGk5xfAvTeRcnW4XQvvyNVgYGsAFNntkk-JuVXb8Y6Y,14851
8
+ git_copilot_commit/version.py,sha256=AieHOUX52g6N67HL0iLWtDKrgOYyulxwHWViu26Jrd4,105
9
+ git_copilot_commit/prompts/commit-message-generator-prompt.md,sha256=EHAS6w15vLQ-kgT1N7nPG2nWqdeTmlHje_kN9yZIoZQ,2378
10
+ git_copilot_commit/prompts/split-commit-planner-prompt.md,sha256=ABKuVVyrkHsb3QV6qyS--W5yvKBoZhtq8xJEb3OZQvI,1088
11
+ git_copilot_commit-0.5.2.dist-info/METADATA,sha256=wc5CFa1ed8A4vbY3B01AsmsHia4jIoEU-5j2-OnaZEA,6649
12
+ git_copilot_commit-0.5.2.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
13
+ git_copilot_commit-0.5.2.dist-info/entry_points.txt,sha256=-D4bQqiuSPwQJG2zx--vJbZD1iqB5coUfoJ_gmC3rSg,66
14
+ git_copilot_commit-0.5.2.dist-info/licenses/LICENSE,sha256=14lNZAoKJPI1U7eGpletjN_PFm1JwP1vT_0jFKY6eWg,1065
15
+ git_copilot_commit-0.5.2.dist-info/RECORD,,
@@ -1,15 +0,0 @@
1
- git_copilot_commit/__init__.py,sha256=v3x5oBkxwKJEZLv62QqSmP3iqNKLtZgrWZfH8eFzlQg,60
2
- git_copilot_commit/cli.py,sha256=FlzcgE-FNO8Mu2v0z-BXAs8f2WC-y4uKSior0zPmhMs,31204
3
- git_copilot_commit/git.py,sha256=GtLkE177Dm3l-zvsslSrAyqshxPeeSEidniwhTmCbyw,13845
4
- git_copilot_commit/github_copilot.py,sha256=D6BWqjX-hLHqw7vbfIO2p3rSeWbI5peAzqDyfKB9l2g,46822
5
- git_copilot_commit/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
- git_copilot_commit/settings.py,sha256=hDesxrMptuceIVeuwGeLe74WwbWHmTLCKo0h4CDWY-I,3537
7
- git_copilot_commit/split_commits.py,sha256=tq--WYaOz6VVSP6y1r-HQ9q0pDLP7tRpNaxwqsGyvdQ,16434
8
- git_copilot_commit/version.py,sha256=AieHOUX52g6N67HL0iLWtDKrgOYyulxwHWViu26Jrd4,105
9
- git_copilot_commit/prompts/commit-message-generator-prompt.md,sha256=EHAS6w15vLQ-kgT1N7nPG2nWqdeTmlHje_kN9yZIoZQ,2378
10
- git_copilot_commit/prompts/split-commit-planner-prompt.md,sha256=YS9XgXoHs-utyGvvpHxnTAReCRAlOyRHkYxtpDw88pY,1147
11
- git_copilot_commit-0.5.0.dist-info/METADATA,sha256=6Pjb8mroHvY9HwzkgwmXa1HPBpGabUvunL3UOfxkBTk,4399
12
- git_copilot_commit-0.5.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
13
- git_copilot_commit-0.5.0.dist-info/entry_points.txt,sha256=-D4bQqiuSPwQJG2zx--vJbZD1iqB5coUfoJ_gmC3rSg,66
14
- git_copilot_commit-0.5.0.dist-info/licenses/LICENSE,sha256=14lNZAoKJPI1U7eGpletjN_PFm1JwP1vT_0jFKY6eWg,1065
15
- git_copilot_commit-0.5.0.dist-info/RECORD,,