git-copilot-commit 0.5.2__tar.gz → 0.5.4__tar.gz

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.
Files changed (27) hide show
  1. {git_copilot_commit-0.5.2 → git_copilot_commit-0.5.4}/PKG-INFO +1 -1
  2. {git_copilot_commit-0.5.2 → git_copilot_commit-0.5.4}/src/git_copilot_commit/cli.py +100 -13
  3. {git_copilot_commit-0.5.2 → git_copilot_commit-0.5.4}/src/git_copilot_commit/github_copilot.py +17 -0
  4. {git_copilot_commit-0.5.2 → git_copilot_commit-0.5.4}/src/git_copilot_commit/split_commits.py +9 -3
  5. {git_copilot_commit-0.5.2 → git_copilot_commit-0.5.4}/tests/test_cli.py +112 -0
  6. {git_copilot_commit-0.5.2 → git_copilot_commit-0.5.4}/tests/test_github_copilot_utils.py +31 -1
  7. {git_copilot_commit-0.5.2 → git_copilot_commit-0.5.4}/tests/test_split_commits.py +14 -0
  8. {git_copilot_commit-0.5.2 → git_copilot_commit-0.5.4}/.github/dependabot.yml +0 -0
  9. {git_copilot_commit-0.5.2 → git_copilot_commit-0.5.4}/.github/workflows/ci.yml +0 -0
  10. {git_copilot_commit-0.5.2 → git_copilot_commit-0.5.4}/.gitignore +0 -0
  11. {git_copilot_commit-0.5.2 → git_copilot_commit-0.5.4}/.justfile +0 -0
  12. {git_copilot_commit-0.5.2 → git_copilot_commit-0.5.4}/.python-version +0 -0
  13. {git_copilot_commit-0.5.2 → git_copilot_commit-0.5.4}/LICENSE +0 -0
  14. {git_copilot_commit-0.5.2 → git_copilot_commit-0.5.4}/README.md +0 -0
  15. {git_copilot_commit-0.5.2 → git_copilot_commit-0.5.4}/pyproject.toml +0 -0
  16. {git_copilot_commit-0.5.2 → git_copilot_commit-0.5.4}/src/git_copilot_commit/__init__.py +0 -0
  17. {git_copilot_commit-0.5.2 → git_copilot_commit-0.5.4}/src/git_copilot_commit/git.py +0 -0
  18. {git_copilot_commit-0.5.2 → git_copilot_commit-0.5.4}/src/git_copilot_commit/prompts/commit-message-generator-prompt.md +0 -0
  19. {git_copilot_commit-0.5.2 → git_copilot_commit-0.5.4}/src/git_copilot_commit/prompts/split-commit-planner-prompt.md +0 -0
  20. {git_copilot_commit-0.5.2 → git_copilot_commit-0.5.4}/src/git_copilot_commit/py.typed +0 -0
  21. {git_copilot_commit-0.5.2 → git_copilot_commit-0.5.4}/src/git_copilot_commit/settings.py +0 -0
  22. {git_copilot_commit-0.5.2 → git_copilot_commit-0.5.4}/src/git_copilot_commit/version.py +0 -0
  23. {git_copilot_commit-0.5.2 → git_copilot_commit-0.5.4}/tests/conftest.py +0 -0
  24. {git_copilot_commit-0.5.2 → git_copilot_commit-0.5.4}/tests/test_git.py +0 -0
  25. {git_copilot_commit-0.5.2 → git_copilot_commit-0.5.4}/tests/test_settings.py +0 -0
  26. {git_copilot_commit-0.5.2 → git_copilot_commit-0.5.4}/uv.lock +0 -0
  27. {git_copilot_commit-0.5.2 → git_copilot_commit-0.5.4}/vhs/demo.vhs +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: git-copilot-commit
3
- Version: 0.5.2
3
+ Version: 0.5.4
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
@@ -268,7 +268,12 @@ def display_selected_model(model: github_copilot.CopilotModel) -> None:
268
268
  console.print(f"[green]Using model:[/green] {model.id} ({', '.join(details)})")
269
269
 
270
270
 
271
- def build_commit_message_prompt(status: GitStatus, context: str = "") -> str:
271
+ def build_commit_message_prompt(
272
+ status: GitStatus,
273
+ context: str = "",
274
+ *,
275
+ include_diff: bool = True,
276
+ ) -> str:
272
277
  """Build the prompt used to generate a commit message."""
273
278
  if not status.has_staged_changes:
274
279
  console.print("[red]No staged changes to commit.[/red]")
@@ -277,10 +282,16 @@ def build_commit_message_prompt(status: GitStatus, context: str = "") -> str:
277
282
  prompt_parts = [
278
283
  "`git status`:\n",
279
284
  f"```\n{status.get_porcelain_output()}\n```",
280
- "\n\n`git diff --staged`:\n",
281
- f"```\n{status.staged_diff}\n```",
282
285
  ]
283
286
 
287
+ if include_diff:
288
+ prompt_parts.extend(
289
+ [
290
+ "\n\n`git diff --staged`:\n",
291
+ f"```\n{status.staged_diff}\n```",
292
+ ]
293
+ )
294
+
284
295
  if context.strip():
285
296
  prompt_parts.insert(0, f"User-provided context:\n\n{context.strip()}\n\n")
286
297
 
@@ -330,6 +341,30 @@ def generate_commit_message_for_prompt(
330
341
  )
331
342
 
332
343
 
344
+ def should_retry_with_compact_prompt(exc: github_copilot.CopilotError) -> bool:
345
+ message_parts = [str(exc)]
346
+ if isinstance(exc, github_copilot.CopilotHttpError) and exc.detail:
347
+ message_parts.append(exc.detail)
348
+
349
+ haystack = " ".join(part.strip() for part in message_parts if part).lower()
350
+ indicators = (
351
+ "maximum context length",
352
+ "context_length_exceeded",
353
+ "context window",
354
+ "prompt is too long",
355
+ "input is too long",
356
+ "request is too large",
357
+ "too many tokens",
358
+ "token limit",
359
+ "max_prompt_tokens",
360
+ "max prompt tokens",
361
+ "input tokens",
362
+ "prompt tokens",
363
+ "prompt token count",
364
+ )
365
+ return any(indicator in haystack for indicator in indicators)
366
+
367
+
333
368
  def generate_commit_message_for_status(
334
369
  status: GitStatus,
335
370
  model: str | None = None,
@@ -337,9 +372,27 @@ def generate_commit_message_for_status(
337
372
  http_client_config: github_copilot.HttpClientConfig | None = None,
338
373
  ) -> str:
339
374
  """Generate a commit message for a staged status snapshot."""
340
- prompt = build_commit_message_prompt(status, context=context)
375
+ full_prompt = build_commit_message_prompt(status, context=context)
376
+ try:
377
+ return generate_commit_message_for_prompt(
378
+ full_prompt,
379
+ model=model,
380
+ http_client_config=http_client_config,
381
+ )
382
+ except github_copilot.CopilotError as exc:
383
+ if not should_retry_with_compact_prompt(exc):
384
+ raise
385
+
386
+ console.print(
387
+ "[yellow]Staged diff exceeded the model context window; retrying with [bold]`git status`[/] only.[/yellow]"
388
+ )
389
+ fallback_prompt = build_commit_message_prompt(
390
+ status,
391
+ context=context,
392
+ include_diff=False,
393
+ )
341
394
  return generate_commit_message_for_prompt(
342
- prompt,
395
+ fallback_prompt,
343
396
  model=model,
344
397
  http_client_config=http_client_config,
345
398
  )
@@ -455,31 +508,65 @@ def request_split_commit_plan(
455
508
  http_client_config: github_copilot.HttpClientConfig | None = None,
456
509
  ) -> SplitCommitPlan:
457
510
  """Request and validate a split-commit plan for the staged patch units."""
458
- try:
459
- planner_prompt = build_split_plan_prompt(
460
- status,
461
- patch_units,
462
- preferred_commits=preferred_commits,
463
- context=context,
464
- )
511
+ planner_system_prompt = load_named_prompt(SPLIT_COMMIT_PLANNER_PROMPT_FILENAME)
512
+ planner_prompt = build_split_plan_prompt(
513
+ status,
514
+ patch_units,
515
+ preferred_commits=preferred_commits,
516
+ context=context,
517
+ )
465
518
 
519
+ try:
466
520
  with console.status(
467
521
  "[yellow]Planning split commits from [bold]staged hunks[/] ...[/yellow]"
468
522
  ):
469
523
  response = ask_copilot_with_system_prompt(
470
- load_named_prompt(SPLIT_COMMIT_PLANNER_PROMPT_FILENAME),
524
+ planner_system_prompt,
471
525
  planner_prompt,
472
526
  model=model,
473
527
  http_client_config=http_client_config,
474
528
  )
529
+ except github_copilot.CopilotError as exc:
530
+ if not should_retry_with_compact_prompt(exc):
531
+ print_copilot_error("Could not generate a split commit plan", exc)
532
+ raise typer.Exit(1)
533
+
534
+ console.print(
535
+ "[yellow]Staged patch units exceeded the model context window; retrying split planning with summaries only.[/yellow]"
536
+ )
537
+ else:
475
538
  return parse_split_plan_response(
476
539
  response,
477
540
  patch_units,
478
541
  )
542
+
543
+ compact_planner_prompt = build_split_plan_prompt(
544
+ status,
545
+ patch_units,
546
+ preferred_commits=preferred_commits,
547
+ context=context,
548
+ include_patches=False,
549
+ )
550
+
551
+ try:
552
+ with console.status(
553
+ "[yellow]Planning split commits from [bold]patch summaries[/] ...[/yellow]"
554
+ ):
555
+ response = ask_copilot_with_system_prompt(
556
+ planner_system_prompt,
557
+ compact_planner_prompt,
558
+ model=model,
559
+ http_client_config=http_client_config,
560
+ )
479
561
  except github_copilot.CopilotError as exc:
480
562
  print_copilot_error("Could not generate a split commit plan", exc)
481
563
  raise typer.Exit(1)
482
564
 
565
+ return parse_split_plan_response(
566
+ response,
567
+ patch_units,
568
+ )
569
+
483
570
 
484
571
  def request_split_commit_messages(
485
572
  plan: SplitCommitPlan,
@@ -162,6 +162,7 @@ class CopilotModel:
162
162
  name: str
163
163
  vendor: str | None = None
164
164
  family: str | None = None
165
+ max_context_window_tokens: int | None = None
165
166
  supported_endpoints: tuple[str, ...] = ()
166
167
 
167
168
  @classmethod
@@ -173,11 +174,18 @@ class CopilotModel:
173
174
  supported_endpoints = payload.get("supported_endpoints")
174
175
 
175
176
  family: str | None = None
177
+ max_context_window_tokens: int | None = None
176
178
  if isinstance(capabilities, dict):
177
179
  raw_family = capabilities.get("family")
178
180
  if isinstance(raw_family, str) and raw_family:
179
181
  family = raw_family
180
182
 
183
+ limits = capabilities.get("limits")
184
+ if isinstance(limits, dict):
185
+ raw_context_window = limits.get("max_context_window_tokens")
186
+ if isinstance(raw_context_window, int) and raw_context_window > 0:
187
+ max_context_window_tokens = raw_context_window
188
+
181
189
  endpoints: list[str] = []
182
190
  if isinstance(supported_endpoints, list):
183
191
  for entry in supported_endpoints:
@@ -192,6 +200,7 @@ class CopilotModel:
192
200
  name=name if isinstance(name, str) and name else model_id,
193
201
  vendor=vendor if isinstance(vendor, str) and vendor else None,
194
202
  family=family,
203
+ max_context_window_tokens=max_context_window_tokens,
195
204
  supported_endpoints=tuple(endpoints),
196
205
  )
197
206
 
@@ -891,6 +900,12 @@ def format_supported_endpoints(model: CopilotModel) -> str:
891
900
  return "default"
892
901
 
893
902
 
903
+ def format_context_window(model: CopilotModel) -> str:
904
+ if model.max_context_window_tokens is None:
905
+ return "?"
906
+ return f"{model.max_context_window_tokens:,}"
907
+
908
+
894
909
  def normalize_vendor_filter(value: str | None) -> str | None:
895
910
  if value is None:
896
911
  return None
@@ -1226,6 +1241,7 @@ def print_model_table(models: list[CopilotModel]) -> None:
1226
1241
  table.add_column("#", justify="right", style="cyan")
1227
1242
  table.add_column("Model", style="green")
1228
1243
  table.add_column("Vendor", style="blue")
1244
+ table.add_column("Context", justify="right", style="bright_cyan")
1229
1245
  table.add_column("Route", style="yellow")
1230
1246
  table.add_column("Endpoints", style="magenta")
1231
1247
  for index, model in enumerate(models, start=1):
@@ -1233,6 +1249,7 @@ def print_model_table(models: list[CopilotModel]) -> None:
1233
1249
  str(index),
1234
1250
  model.id,
1235
1251
  model.vendor or "?",
1252
+ format_context_window(model),
1236
1253
  infer_api_surface(model),
1237
1254
  format_supported_endpoints(model),
1238
1255
  )
@@ -173,6 +173,7 @@ def build_split_plan_prompt(
173
173
  *,
174
174
  preferred_commits: int | None = None,
175
175
  context: str = "",
176
+ include_patches: bool = True,
176
177
  ) -> str:
177
178
  """Build the user prompt used to request a split-commit plan."""
178
179
  if not patch_units:
@@ -193,7 +194,7 @@ def build_split_plan_prompt(
193
194
  "`git status`:",
194
195
  f"```\n{status.get_porcelain_output()}\n```",
195
196
  "",
196
- "Patch units:",
197
+ "Patch units:" if include_patches else "Patch units (summaries only):",
197
198
  ]
198
199
  )
199
200
 
@@ -204,10 +205,15 @@ def build_split_plan_prompt(
204
205
  f"Path: {unit.path}",
205
206
  f"Kind: {unit.kind}",
206
207
  f"Summary: {unit.summary}",
207
- "Patch:",
208
- f"```diff\n{unit.patch}\n```",
209
208
  ]
210
209
  )
210
+ if include_patches:
211
+ prompt_parts.extend(
212
+ [
213
+ "Patch:",
214
+ f"```diff\n{unit.patch}\n```",
215
+ ]
216
+ )
211
217
 
212
218
  return "\n".join(prompt_parts)
213
219
 
@@ -62,6 +62,19 @@ def test_build_commit_message_prompt_includes_context_status_and_diff() -> None:
62
62
  assert "+print('hi')" in prompt
63
63
 
64
64
 
65
+ def test_build_commit_message_prompt_can_omit_diff() -> None:
66
+ status = make_status(
67
+ staged_diff="diff --git a/src/example.py b/src/example.py\n+print('hi')\n"
68
+ )
69
+
70
+ prompt = build_commit_message_prompt(status, include_diff=False)
71
+
72
+ assert "`git status`" in prompt
73
+ assert "M src/example.py" in prompt
74
+ assert "`git diff --staged`" not in prompt
75
+ assert "+print('hi')" not in prompt
76
+
77
+
65
78
  def test_build_commit_message_prompt_requires_staged_changes() -> None:
66
79
  status = make_status(staged_diff=" \n")
67
80
 
@@ -94,6 +107,105 @@ def test_generate_commit_message_for_status_normalizes_model_prefix(
94
107
  assert "diff --git a/src/example.py b/src/example.py" in rendered_prompt
95
108
 
96
109
 
110
+ def test_generate_commit_message_for_status_retries_without_diff_on_context_overflow(
111
+ monkeypatch: pytest.MonkeyPatch,
112
+ ) -> None:
113
+ status = make_status(
114
+ staged_diff="diff --git a/src/example.py b/src/example.py\n+print('hi')\n"
115
+ )
116
+ mock_print = Mock()
117
+ mock_ask = Mock(
118
+ side_effect=[
119
+ github_copilot.CopilotHttpError(
120
+ 400,
121
+ "Bad Request",
122
+ "This model's maximum context length was exceeded.",
123
+ ),
124
+ "feat: add example",
125
+ ]
126
+ )
127
+ monkeypatch.setattr(cli.console, "print", mock_print)
128
+ monkeypatch.setattr(cli, "load_system_prompt", Mock(return_value="system prompt"))
129
+ monkeypatch.setattr(cli.github_copilot, "ask", mock_ask)
130
+
131
+ message = generate_commit_message_for_status(status)
132
+
133
+ assert message == "feat: add example"
134
+ assert mock_ask.call_count == 2
135
+ first_prompt = mock_ask.call_args_list[0].args[0]
136
+ second_prompt = mock_ask.call_args_list[1].args[0]
137
+ assert "`git diff --staged`" in first_prompt
138
+ assert "diff --git a/src/example.py b/src/example.py" in first_prompt
139
+ assert "`git diff --staged`" not in second_prompt
140
+ assert "diff --git a/src/example.py b/src/example.py" not in second_prompt
141
+ assert "M src/example.py" in second_prompt
142
+ mock_print.assert_called()
143
+
144
+
145
+ def test_request_split_commit_plan_retries_without_patches_on_context_overflow(
146
+ monkeypatch: pytest.MonkeyPatch,
147
+ ) -> None:
148
+ status = GitStatus(
149
+ files=[
150
+ GitFile(path="src/example.py", status=" ", staged_status="M"),
151
+ GitFile(path="README.md", status=" ", staged_status="A"),
152
+ ],
153
+ staged_diff=(
154
+ "diff --git a/src/example.py b/src/example.py\n+print('hi')\n"
155
+ "diff --git a/README.md b/README.md\n+# hi\n"
156
+ ),
157
+ unstaged_diff="",
158
+ )
159
+ patch_units = (
160
+ PatchUnit(
161
+ id="u1",
162
+ order=0,
163
+ path="src/example.py",
164
+ staged_status="M",
165
+ kind="hunk",
166
+ patch="diff --git a/src/example.py b/src/example.py\n+print('hi')\n",
167
+ summary="src/example.py hunk 1/1 @@ -1 +1 @@ (+1/-0)",
168
+ ),
169
+ PatchUnit(
170
+ id="u2",
171
+ order=1,
172
+ path="README.md",
173
+ staged_status="A",
174
+ kind="new_file",
175
+ patch="diff --git a/README.md b/README.md\n+# hi\n",
176
+ summary="add README.md (+1/-0)",
177
+ ),
178
+ )
179
+ mock_print = Mock()
180
+ mock_ask = Mock(
181
+ side_effect=[
182
+ github_copilot.CopilotHttpError(
183
+ 400,
184
+ "Bad Request",
185
+ (
186
+ '{"error":{"message":"prompt token count of 1719062 exceeds '
187
+ 'the limit of 128000","code":"model_max_prompt_tokens_exceeded"}}'
188
+ ),
189
+ ),
190
+ '{"commits":[{"unit_ids":["u1"]},{"unit_ids":["u2"]}]}',
191
+ ]
192
+ )
193
+ monkeypatch.setattr(cli.console, "print", mock_print)
194
+ monkeypatch.setattr(cli, "load_named_prompt", Mock(return_value="system prompt"))
195
+ monkeypatch.setattr(cli, "ask_copilot_with_system_prompt", mock_ask)
196
+
197
+ plan = cli.request_split_commit_plan(status, patch_units)
198
+
199
+ assert [commit.unit_ids for commit in plan.commits] == [("u1",), ("u2",)]
200
+ assert mock_ask.call_count == 2
201
+ first_prompt = mock_ask.call_args_list[0].args[1]
202
+ second_prompt = mock_ask.call_args_list[1].args[1]
203
+ assert "```diff" in first_prompt
204
+ assert "```diff" not in second_prompt
205
+ assert "Patch units (summaries only):" in second_prompt
206
+ mock_print.assert_called()
207
+
208
+
97
209
  def test_display_split_commit_plan_shows_files_not_hunk_summaries(
98
210
  monkeypatch: pytest.MonkeyPatch,
99
211
  ) -> None:
@@ -15,6 +15,7 @@ def make_model(
15
15
  *,
16
16
  vendor: str | None = None,
17
17
  family: str | None = None,
18
+ context_window_tokens: int | None = None,
18
19
  endpoints: tuple[str, ...] = (),
19
20
  ) -> github_copilot.CopilotModel:
20
21
  return github_copilot.CopilotModel(
@@ -22,6 +23,7 @@ def make_model(
22
23
  name=model_id,
23
24
  vendor=vendor,
24
25
  family=family,
26
+ max_context_window_tokens=context_window_tokens,
25
27
  supported_endpoints=endpoints,
26
28
  )
27
29
 
@@ -136,11 +138,15 @@ def test_credentials_and_payload_parsers(monkeypatch: pytest.MonkeyPatch) -> Non
136
138
  "id": "gpt-5.4",
137
139
  "name": "GPT-5.4",
138
140
  "vendor": "openai",
139
- "capabilities": {"family": "gpt-5"},
141
+ "capabilities": {
142
+ "family": "gpt-5",
143
+ "limits": {"max_context_window_tokens": 272000},
144
+ },
140
145
  "supported_endpoints": ["/responses", "", 123],
141
146
  }
142
147
  )
143
148
  assert model.family == "gpt-5"
149
+ assert model.max_context_window_tokens == 272000
144
150
  assert model.supported_endpoints == ("/responses",)
145
151
 
146
152
  with pytest.raises(github_copilot.CopilotError):
@@ -233,6 +239,7 @@ def test_infer_api_surface_and_vendor_filtering() -> None:
233
239
  assert github_copilot.infer_api_surface(google_model) == "chat_completions"
234
240
  assert github_copilot.format_supported_endpoints(chat_model) == "/chat/completions"
235
241
  assert github_copilot.format_supported_endpoints(google_model) == "default"
242
+ assert github_copilot.format_context_window(gpt5_model) == "?"
236
243
 
237
244
  assert github_copilot.normalize_vendor_filter(" Gemini ") == "google"
238
245
  assert github_copilot.normalize_vendor_filter("claude") == "anthropic"
@@ -483,3 +490,26 @@ def test_render_model_selection_error_and_time_formatting(
483
490
  assert github_copilot.format_relative_duration(-59) == "59s ago"
484
491
  assert github_copilot.format_unix_timestamp(1_700_000_061).endswith("(in 1m 1s)")
485
492
  assert github_copilot.format_unix_timestamp(10**20) == str(10**20)
493
+
494
+
495
+ def test_print_model_table_shows_context_window(
496
+ monkeypatch: pytest.MonkeyPatch,
497
+ ) -> None:
498
+ table_console = Console(record=True, width=140)
499
+ monkeypatch.setattr(github_copilot, "console", table_console)
500
+
501
+ github_copilot.print_model_table(
502
+ [
503
+ make_model(
504
+ "gpt-5.4",
505
+ vendor="openai",
506
+ context_window_tokens=272000,
507
+ endpoints=("/responses",),
508
+ )
509
+ ]
510
+ )
511
+
512
+ rendered = table_console.export_text()
513
+
514
+ assert "Context" in rendered
515
+ assert "272,000" in rendered
@@ -99,6 +99,20 @@ def test_build_split_plan_prompt_includes_unit_details() -> None:
99
99
  assert "Kind: new_file" in prompt
100
100
 
101
101
 
102
+ def test_build_split_plan_prompt_can_omit_raw_patches() -> None:
103
+ units = extract_patch_units(MULTI_FILE_DIFF)
104
+
105
+ prompt = build_split_plan_prompt(
106
+ make_status(),
107
+ units,
108
+ include_patches=False,
109
+ )
110
+
111
+ assert "Patch units (summaries only):" in prompt
112
+ assert "Summary: src/app.py hunk 1/2" in prompt
113
+ assert "```diff" not in prompt
114
+
115
+
102
116
  def test_build_split_plan_prompt_supports_preferred_commit_count() -> None:
103
117
  units = extract_patch_units(MULTI_FILE_DIFF)
104
118