git-copilot-commit 0.5.4__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.4 → git_copilot_commit-0.5.5}/PKG-INFO +1 -1
  2. {git_copilot_commit-0.5.4 → git_copilot_commit-0.5.5}/src/git_copilot_commit/cli.py +46 -0
  3. {git_copilot_commit-0.5.4 → git_copilot_commit-0.5.5}/src/git_copilot_commit/prompts/split-commit-planner-prompt.md +5 -0
  4. {git_copilot_commit-0.5.4 → git_copilot_commit-0.5.5}/tests/test_cli.py +107 -0
  5. {git_copilot_commit-0.5.4 → git_copilot_commit-0.5.5}/.github/dependabot.yml +0 -0
  6. {git_copilot_commit-0.5.4 → git_copilot_commit-0.5.5}/.github/workflows/ci.yml +0 -0
  7. {git_copilot_commit-0.5.4 → git_copilot_commit-0.5.5}/.gitignore +0 -0
  8. {git_copilot_commit-0.5.4 → git_copilot_commit-0.5.5}/.justfile +0 -0
  9. {git_copilot_commit-0.5.4 → git_copilot_commit-0.5.5}/.python-version +0 -0
  10. {git_copilot_commit-0.5.4 → git_copilot_commit-0.5.5}/LICENSE +0 -0
  11. {git_copilot_commit-0.5.4 → git_copilot_commit-0.5.5}/README.md +0 -0
  12. {git_copilot_commit-0.5.4 → git_copilot_commit-0.5.5}/pyproject.toml +0 -0
  13. {git_copilot_commit-0.5.4 → git_copilot_commit-0.5.5}/src/git_copilot_commit/__init__.py +0 -0
  14. {git_copilot_commit-0.5.4 → git_copilot_commit-0.5.5}/src/git_copilot_commit/git.py +0 -0
  15. {git_copilot_commit-0.5.4 → git_copilot_commit-0.5.5}/src/git_copilot_commit/github_copilot.py +0 -0
  16. {git_copilot_commit-0.5.4 → git_copilot_commit-0.5.5}/src/git_copilot_commit/prompts/commit-message-generator-prompt.md +0 -0
  17. {git_copilot_commit-0.5.4 → git_copilot_commit-0.5.5}/src/git_copilot_commit/py.typed +0 -0
  18. {git_copilot_commit-0.5.4 → git_copilot_commit-0.5.5}/src/git_copilot_commit/settings.py +0 -0
  19. {git_copilot_commit-0.5.4 → git_copilot_commit-0.5.5}/src/git_copilot_commit/split_commits.py +0 -0
  20. {git_copilot_commit-0.5.4 → git_copilot_commit-0.5.5}/src/git_copilot_commit/version.py +0 -0
  21. {git_copilot_commit-0.5.4 → git_copilot_commit-0.5.5}/tests/conftest.py +0 -0
  22. {git_copilot_commit-0.5.4 → git_copilot_commit-0.5.5}/tests/test_git.py +0 -0
  23. {git_copilot_commit-0.5.4 → git_copilot_commit-0.5.5}/tests/test_github_copilot_utils.py +0 -0
  24. {git_copilot_commit-0.5.4 → git_copilot_commit-0.5.5}/tests/test_settings.py +0 -0
  25. {git_copilot_commit-0.5.4 → git_copilot_commit-0.5.5}/tests/test_split_commits.py +0 -0
  26. {git_copilot_commit-0.5.4 → git_copilot_commit-0.5.5}/uv.lock +0 -0
  27. {git_copilot_commit-0.5.4 → 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.4
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:]
@@ -862,6 +907,7 @@ def handle_split_commit_flow(
862
907
  context=context,
863
908
  http_client_config=http_client_config,
864
909
  )
910
+ prepared_commits = order_prepared_split_commits(prepared_commits)
865
911
 
866
912
  if len(prepared_commits) == 1:
867
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.
@@ -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,
@@ -369,6 +371,36 @@ def test_build_http_client_config_and_normalize_model_name(
369
371
  assert normalize_model_name(None) is None
370
372
 
371
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
+
372
404
  def test_preprocess_cli_args_rewrites_split_syntax() -> None:
373
405
  assert preprocess_cli_args(["commit", "--split=auto", "--yes"]) == [
374
406
  "commit",
@@ -930,6 +962,81 @@ def test_handle_split_commit_flow_split_limit_can_trigger_split_planning(
930
962
  execute_plan.assert_called_once_with(repo, prepared_commits, yes=False)
931
963
 
932
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
+
933
1040
  def test_handle_split_commit_flow_prompts_when_plan_exceeds_preference(
934
1041
  monkeypatch: pytest.MonkeyPatch,
935
1042
  ) -> None: