git-copilot-commit 0.5.3__tar.gz → 0.5.5__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.3 → git_copilot_commit-0.5.5}/PKG-INFO +1 -1
  2. {git_copilot_commit-0.5.3 → git_copilot_commit-0.5.5}/src/git_copilot_commit/cli.py +91 -10
  3. {git_copilot_commit-0.5.3 → git_copilot_commit-0.5.5}/src/git_copilot_commit/prompts/split-commit-planner-prompt.md +5 -0
  4. {git_copilot_commit-0.5.3 → git_copilot_commit-0.5.5}/src/git_copilot_commit/split_commits.py +9 -3
  5. {git_copilot_commit-0.5.3 → git_copilot_commit-0.5.5}/tests/test_cli.py +171 -0
  6. {git_copilot_commit-0.5.3 → git_copilot_commit-0.5.5}/tests/test_split_commits.py +14 -0
  7. {git_copilot_commit-0.5.3 → git_copilot_commit-0.5.5}/.github/dependabot.yml +0 -0
  8. {git_copilot_commit-0.5.3 → git_copilot_commit-0.5.5}/.github/workflows/ci.yml +0 -0
  9. {git_copilot_commit-0.5.3 → git_copilot_commit-0.5.5}/.gitignore +0 -0
  10. {git_copilot_commit-0.5.3 → git_copilot_commit-0.5.5}/.justfile +0 -0
  11. {git_copilot_commit-0.5.3 → git_copilot_commit-0.5.5}/.python-version +0 -0
  12. {git_copilot_commit-0.5.3 → git_copilot_commit-0.5.5}/LICENSE +0 -0
  13. {git_copilot_commit-0.5.3 → git_copilot_commit-0.5.5}/README.md +0 -0
  14. {git_copilot_commit-0.5.3 → git_copilot_commit-0.5.5}/pyproject.toml +0 -0
  15. {git_copilot_commit-0.5.3 → git_copilot_commit-0.5.5}/src/git_copilot_commit/__init__.py +0 -0
  16. {git_copilot_commit-0.5.3 → git_copilot_commit-0.5.5}/src/git_copilot_commit/git.py +0 -0
  17. {git_copilot_commit-0.5.3 → git_copilot_commit-0.5.5}/src/git_copilot_commit/github_copilot.py +0 -0
  18. {git_copilot_commit-0.5.3 → git_copilot_commit-0.5.5}/src/git_copilot_commit/prompts/commit-message-generator-prompt.md +0 -0
  19. {git_copilot_commit-0.5.3 → git_copilot_commit-0.5.5}/src/git_copilot_commit/py.typed +0 -0
  20. {git_copilot_commit-0.5.3 → git_copilot_commit-0.5.5}/src/git_copilot_commit/settings.py +0 -0
  21. {git_copilot_commit-0.5.3 → git_copilot_commit-0.5.5}/src/git_copilot_commit/version.py +0 -0
  22. {git_copilot_commit-0.5.3 → git_copilot_commit-0.5.5}/tests/conftest.py +0 -0
  23. {git_copilot_commit-0.5.3 → git_copilot_commit-0.5.5}/tests/test_git.py +0 -0
  24. {git_copilot_commit-0.5.3 → git_copilot_commit-0.5.5}/tests/test_github_copilot_utils.py +0 -0
  25. {git_copilot_commit-0.5.3 → git_copilot_commit-0.5.5}/tests/test_settings.py +0 -0
  26. {git_copilot_commit-0.5.3 → git_copilot_commit-0.5.5}/uv.lock +0 -0
  27. {git_copilot_commit-0.5.3 → git_copilot_commit-0.5.5}/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.3
3
+ Version: 0.5.5
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
@@ -5,6 +5,7 @@ git-copilot-commit - AI-powered Git commit assistant
5
5
  from dataclasses import dataclass
6
6
  from pathlib import Path
7
7
  import os
8
+ import re
8
9
  import sys
9
10
  from typing import Annotated, Sequence
10
11
 
@@ -94,6 +95,19 @@ class PreparedSplitCommit:
94
95
  patch_units: tuple[PatchUnit, ...]
95
96
 
96
97
 
98
+ CORE_CHANGE_COMMIT_TYPES = frozenset({"feat", "fix", "perf", "refactor", "revert"})
99
+ FOLLOW_UP_COMMIT_TYPE_PRIORITY = {
100
+ "test": 2,
101
+ "docs": 3,
102
+ "style": 4,
103
+ "chore": 4,
104
+ }
105
+ CONVENTIONAL_COMMIT_TYPE_PATTERN = re.compile(
106
+ r"^\s*([a-z]+)(?:\([^)\r\n]*\))?(?:!)?:",
107
+ re.IGNORECASE,
108
+ )
109
+
110
+
97
111
  def preprocess_cli_args(args: Sequence[str]) -> list[str]:
98
112
  """Normalize CLI arguments before Click parses them."""
99
113
  processed_args: list[str] = []
@@ -142,6 +156,37 @@ def preprocess_cli_args(args: Sequence[str]) -> list[str]:
142
156
  return processed_args
143
157
 
144
158
 
159
+ def extract_conventional_commit_type(message: str) -> str | None:
160
+ """Extract the Conventional Commit type from a generated title line."""
161
+ match = CONVENTIONAL_COMMIT_TYPE_PATTERN.match(message.strip())
162
+ if match is None:
163
+ return None
164
+
165
+ return match.group(1).lower()
166
+
167
+
168
+ def order_prepared_split_commits(
169
+ prepared_commits: Sequence[PreparedSplitCommit],
170
+ ) -> list[PreparedSplitCommit]:
171
+ """Order planned commits in a developer-friendly execution sequence."""
172
+
173
+ def sort_key(item: tuple[int, PreparedSplitCommit]) -> tuple[int, int]:
174
+ index, prepared_commit = item
175
+ commit_type = extract_conventional_commit_type(prepared_commit.message)
176
+
177
+ if commit_type in CORE_CHANGE_COMMIT_TYPES:
178
+ priority = 0
179
+ elif commit_type is None:
180
+ priority = 1
181
+ else:
182
+ priority = FOLLOW_UP_COMMIT_TYPE_PRIORITY.get(commit_type, 1)
183
+
184
+ return priority, index
185
+
186
+ ordered_items = sorted(enumerate(prepared_commits), key=sort_key)
187
+ return [prepared_commit for _, prepared_commit in ordered_items]
188
+
189
+
145
190
  def run(args: Sequence[str] | None = None) -> None:
146
191
  """Run the CLI entrypoint with argument normalization."""
147
192
  raw_args = list(args) if args is not None else sys.argv[1:]
@@ -341,7 +386,7 @@ def generate_commit_message_for_prompt(
341
386
  )
342
387
 
343
388
 
344
- def should_fallback_to_status_only(exc: github_copilot.CopilotError) -> bool:
389
+ def should_retry_with_compact_prompt(exc: github_copilot.CopilotError) -> bool:
345
390
  message_parts = [str(exc)]
346
391
  if isinstance(exc, github_copilot.CopilotHttpError) and exc.detail:
347
392
  message_parts.append(exc.detail)
@@ -360,6 +405,7 @@ def should_fallback_to_status_only(exc: github_copilot.CopilotError) -> bool:
360
405
  "max prompt tokens",
361
406
  "input tokens",
362
407
  "prompt tokens",
408
+ "prompt token count",
363
409
  )
364
410
  return any(indicator in haystack for indicator in indicators)
365
411
 
@@ -379,7 +425,7 @@ def generate_commit_message_for_status(
379
425
  http_client_config=http_client_config,
380
426
  )
381
427
  except github_copilot.CopilotError as exc:
382
- if not should_fallback_to_status_only(exc):
428
+ if not should_retry_with_compact_prompt(exc):
383
429
  raise
384
430
 
385
431
  console.print(
@@ -507,31 +553,65 @@ def request_split_commit_plan(
507
553
  http_client_config: github_copilot.HttpClientConfig | None = None,
508
554
  ) -> SplitCommitPlan:
509
555
  """Request and validate a split-commit plan for the staged patch units."""
510
- try:
511
- planner_prompt = build_split_plan_prompt(
512
- status,
513
- patch_units,
514
- preferred_commits=preferred_commits,
515
- context=context,
516
- )
556
+ planner_system_prompt = load_named_prompt(SPLIT_COMMIT_PLANNER_PROMPT_FILENAME)
557
+ planner_prompt = build_split_plan_prompt(
558
+ status,
559
+ patch_units,
560
+ preferred_commits=preferred_commits,
561
+ context=context,
562
+ )
517
563
 
564
+ try:
518
565
  with console.status(
519
566
  "[yellow]Planning split commits from [bold]staged hunks[/] ...[/yellow]"
520
567
  ):
521
568
  response = ask_copilot_with_system_prompt(
522
- load_named_prompt(SPLIT_COMMIT_PLANNER_PROMPT_FILENAME),
569
+ planner_system_prompt,
523
570
  planner_prompt,
524
571
  model=model,
525
572
  http_client_config=http_client_config,
526
573
  )
574
+ except github_copilot.CopilotError as exc:
575
+ if not should_retry_with_compact_prompt(exc):
576
+ print_copilot_error("Could not generate a split commit plan", exc)
577
+ raise typer.Exit(1)
578
+
579
+ console.print(
580
+ "[yellow]Staged patch units exceeded the model context window; retrying split planning with summaries only.[/yellow]"
581
+ )
582
+ else:
527
583
  return parse_split_plan_response(
528
584
  response,
529
585
  patch_units,
530
586
  )
587
+
588
+ compact_planner_prompt = build_split_plan_prompt(
589
+ status,
590
+ patch_units,
591
+ preferred_commits=preferred_commits,
592
+ context=context,
593
+ include_patches=False,
594
+ )
595
+
596
+ try:
597
+ with console.status(
598
+ "[yellow]Planning split commits from [bold]patch summaries[/] ...[/yellow]"
599
+ ):
600
+ response = ask_copilot_with_system_prompt(
601
+ planner_system_prompt,
602
+ compact_planner_prompt,
603
+ model=model,
604
+ http_client_config=http_client_config,
605
+ )
531
606
  except github_copilot.CopilotError as exc:
532
607
  print_copilot_error("Could not generate a split commit plan", exc)
533
608
  raise typer.Exit(1)
534
609
 
610
+ return parse_split_plan_response(
611
+ response,
612
+ patch_units,
613
+ )
614
+
535
615
 
536
616
  def request_split_commit_messages(
537
617
  plan: SplitCommitPlan,
@@ -827,6 +907,7 @@ def handle_split_commit_flow(
827
907
  context=context,
828
908
  http_client_config=http_client_config,
829
909
  )
910
+ prepared_commits = order_prepared_split_commits(prepared_commits)
830
911
 
831
912
  if len(prepared_commits) == 1:
832
913
  console.print(
@@ -15,6 +15,11 @@ small number of coherent commits.
15
15
  - Do not split a patch unit further.
16
16
  - Preserve the natural order of work. If unit `u1` appears before `u4` in the
17
17
  diff, prefer to keep that order within a commit.
18
+ - Order commits the way experienced software developers usually land them:
19
+ foundational product code first, then tests that validate that code, then
20
+ docs/examples, then style/chore follow-ups.
21
+ - If a docs or test unit depends on a feat/fix/refactor/perf unit, place the
22
+ docs or test commit after the underlying code change.
18
23
  - If multiple units belong to the same logical change, keep them together.
19
24
  - If the staged changes are best represented as a single coherent commit, return
20
25
  one commit.
@@ -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
 
@@ -15,12 +15,14 @@ from git_copilot_commit.cli import (
15
15
  commit_with_retry_no_verify,
16
16
  display_selected_model,
17
17
  display_split_commit_plan,
18
+ extract_conventional_commit_type,
18
19
  execute_split_commit_plan,
19
20
  generate_commit_message_for_status,
20
21
  handle_split_commit_flow,
21
22
  load_named_prompt,
22
23
  load_system_prompt,
23
24
  normalize_model_name,
25
+ order_prepared_split_commits,
24
26
  print_copilot_error,
25
27
  preprocess_cli_args,
26
28
  resolve_prompt_file,
@@ -142,6 +144,70 @@ def test_generate_commit_message_for_status_retries_without_diff_on_context_over
142
144
  mock_print.assert_called()
143
145
 
144
146
 
147
+ def test_request_split_commit_plan_retries_without_patches_on_context_overflow(
148
+ monkeypatch: pytest.MonkeyPatch,
149
+ ) -> None:
150
+ status = GitStatus(
151
+ files=[
152
+ GitFile(path="src/example.py", status=" ", staged_status="M"),
153
+ GitFile(path="README.md", status=" ", staged_status="A"),
154
+ ],
155
+ staged_diff=(
156
+ "diff --git a/src/example.py b/src/example.py\n+print('hi')\n"
157
+ "diff --git a/README.md b/README.md\n+# hi\n"
158
+ ),
159
+ unstaged_diff="",
160
+ )
161
+ patch_units = (
162
+ PatchUnit(
163
+ id="u1",
164
+ order=0,
165
+ path="src/example.py",
166
+ staged_status="M",
167
+ kind="hunk",
168
+ patch="diff --git a/src/example.py b/src/example.py\n+print('hi')\n",
169
+ summary="src/example.py hunk 1/1 @@ -1 +1 @@ (+1/-0)",
170
+ ),
171
+ PatchUnit(
172
+ id="u2",
173
+ order=1,
174
+ path="README.md",
175
+ staged_status="A",
176
+ kind="new_file",
177
+ patch="diff --git a/README.md b/README.md\n+# hi\n",
178
+ summary="add README.md (+1/-0)",
179
+ ),
180
+ )
181
+ mock_print = Mock()
182
+ mock_ask = Mock(
183
+ side_effect=[
184
+ github_copilot.CopilotHttpError(
185
+ 400,
186
+ "Bad Request",
187
+ (
188
+ '{"error":{"message":"prompt token count of 1719062 exceeds '
189
+ 'the limit of 128000","code":"model_max_prompt_tokens_exceeded"}}'
190
+ ),
191
+ ),
192
+ '{"commits":[{"unit_ids":["u1"]},{"unit_ids":["u2"]}]}',
193
+ ]
194
+ )
195
+ monkeypatch.setattr(cli.console, "print", mock_print)
196
+ monkeypatch.setattr(cli, "load_named_prompt", Mock(return_value="system prompt"))
197
+ monkeypatch.setattr(cli, "ask_copilot_with_system_prompt", mock_ask)
198
+
199
+ plan = cli.request_split_commit_plan(status, patch_units)
200
+
201
+ assert [commit.unit_ids for commit in plan.commits] == [("u1",), ("u2",)]
202
+ assert mock_ask.call_count == 2
203
+ first_prompt = mock_ask.call_args_list[0].args[1]
204
+ second_prompt = mock_ask.call_args_list[1].args[1]
205
+ assert "```diff" in first_prompt
206
+ assert "```diff" not in second_prompt
207
+ assert "Patch units (summaries only):" in second_prompt
208
+ mock_print.assert_called()
209
+
210
+
145
211
  def test_display_split_commit_plan_shows_files_not_hunk_summaries(
146
212
  monkeypatch: pytest.MonkeyPatch,
147
213
  ) -> None:
@@ -305,6 +371,36 @@ def test_build_http_client_config_and_normalize_model_name(
305
371
  assert normalize_model_name(None) is None
306
372
 
307
373
 
374
+ def test_extract_conventional_commit_type_supports_scope_and_breaking_change() -> None:
375
+ assert extract_conventional_commit_type("feat(api): add endpoint") == "feat"
376
+ assert extract_conventional_commit_type("refactor!: simplify planner") == (
377
+ "refactor"
378
+ )
379
+ assert extract_conventional_commit_type("not a conventional commit") is None
380
+
381
+
382
+ def test_order_prepared_split_commits_moves_follow_up_commits_after_core_changes() -> (
383
+ None
384
+ ):
385
+ prepared_commits = [
386
+ PreparedSplitCommit(message="docs: update README", patch_units=()),
387
+ PreparedSplitCommit(message="feat: add split planning", patch_units=()),
388
+ PreparedSplitCommit(message="test: cover split planning", patch_units=()),
389
+ PreparedSplitCommit(message="chore: update fixtures", patch_units=()),
390
+ PreparedSplitCommit(message="fix(parser): preserve ordering", patch_units=()),
391
+ ]
392
+
393
+ ordered = order_prepared_split_commits(prepared_commits)
394
+
395
+ assert [prepared_commit.message for prepared_commit in ordered] == [
396
+ "feat: add split planning",
397
+ "fix(parser): preserve ordering",
398
+ "test: cover split planning",
399
+ "docs: update README",
400
+ "chore: update fixtures",
401
+ ]
402
+
403
+
308
404
  def test_preprocess_cli_args_rewrites_split_syntax() -> None:
309
405
  assert preprocess_cli_args(["commit", "--split=auto", "--yes"]) == [
310
406
  "commit",
@@ -866,6 +962,81 @@ def test_handle_split_commit_flow_split_limit_can_trigger_split_planning(
866
962
  execute_plan.assert_called_once_with(repo, prepared_commits, yes=False)
867
963
 
868
964
 
965
+ def test_handle_split_commit_flow_reorders_prepared_commits_before_execution(
966
+ monkeypatch: pytest.MonkeyPatch,
967
+ ) -> None:
968
+ status = make_status(
969
+ staged_diff="diff --git a/src/example.py b/src/example.py\n+print('hi')\n"
970
+ )
971
+ repo = Mock()
972
+ repo.get_staged_diff.return_value = "diff"
973
+ patch_units = (
974
+ PatchUnit(
975
+ id="u1",
976
+ order=0,
977
+ path="README.md",
978
+ staged_status="A",
979
+ kind="new_file",
980
+ patch="patch 1",
981
+ summary="summary 1",
982
+ ),
983
+ PatchUnit(
984
+ id="u2",
985
+ order=1,
986
+ path="src/app.py",
987
+ staged_status="M",
988
+ kind="hunk",
989
+ patch="patch 2",
990
+ summary="summary 2",
991
+ ),
992
+ PatchUnit(
993
+ id="u3",
994
+ order=2,
995
+ path="tests/test_app.py",
996
+ staged_status="M",
997
+ kind="hunk",
998
+ patch="patch 3",
999
+ summary="summary 3",
1000
+ ),
1001
+ )
1002
+ split_plan = SplitCommitPlan(
1003
+ commits=(
1004
+ SplitPlanCommit(("u1",)),
1005
+ SplitPlanCommit(("u2",)),
1006
+ SplitPlanCommit(("u3",)),
1007
+ )
1008
+ )
1009
+ unordered_commits = [
1010
+ PreparedSplitCommit(message="docs: readme", patch_units=(patch_units[0],)),
1011
+ PreparedSplitCommit(message="feat: app", patch_units=(patch_units[1],)),
1012
+ PreparedSplitCommit(message="test: app", patch_units=(patch_units[2],)),
1013
+ ]
1014
+ expected_order = [
1015
+ unordered_commits[1],
1016
+ unordered_commits[2],
1017
+ unordered_commits[0],
1018
+ ]
1019
+ monkeypatch.setattr(cli, "extract_patch_units", lambda _diff: patch_units)
1020
+ monkeypatch.setattr(cli, "request_split_commit_plan", Mock(return_value=split_plan))
1021
+ monkeypatch.setattr(
1022
+ cli, "request_split_commit_messages", Mock(return_value=unordered_commits)
1023
+ )
1024
+ display_plan = Mock()
1025
+ execute_plan = Mock(return_value=["aaaabbbb", "ccccdddd", "eeeeffff"])
1026
+ monkeypatch.setattr(cli, "display_split_commit_plan", display_plan)
1027
+ monkeypatch.setattr(cli, "execute_split_commit_plan", execute_plan)
1028
+ monkeypatch.setattr(cli, "handle_single_commit_flow", Mock())
1029
+
1030
+ handle_split_commit_flow(
1031
+ repo,
1032
+ status,
1033
+ model="gpt-5.4",
1034
+ )
1035
+
1036
+ display_plan.assert_called_once_with(expected_order)
1037
+ execute_plan.assert_called_once_with(repo, expected_order, yes=False)
1038
+
1039
+
869
1040
  def test_handle_split_commit_flow_prompts_when_plan_exceeds_preference(
870
1041
  monkeypatch: pytest.MonkeyPatch,
871
1042
  ) -> None:
@@ -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