git-copilot-commit 0.5.6__tar.gz → 0.5.7__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.
- {git_copilot_commit-0.5.6 → git_copilot_commit-0.5.7}/PKG-INFO +1 -1
- {git_copilot_commit-0.5.6 → git_copilot_commit-0.5.7}/src/git_copilot_commit/cli.py +56 -25
- {git_copilot_commit-0.5.6 → git_copilot_commit-0.5.7}/src/git_copilot_commit/git.py +26 -0
- {git_copilot_commit-0.5.6 → git_copilot_commit-0.5.7}/tests/test_cli.py +119 -0
- {git_copilot_commit-0.5.6 → git_copilot_commit-0.5.7}/.github/dependabot.yml +0 -0
- {git_copilot_commit-0.5.6 → git_copilot_commit-0.5.7}/.github/workflows/ci.yml +0 -0
- {git_copilot_commit-0.5.6 → git_copilot_commit-0.5.7}/.gitignore +0 -0
- {git_copilot_commit-0.5.6 → git_copilot_commit-0.5.7}/.justfile +0 -0
- {git_copilot_commit-0.5.6 → git_copilot_commit-0.5.7}/.python-version +0 -0
- {git_copilot_commit-0.5.6 → git_copilot_commit-0.5.7}/LICENSE +0 -0
- {git_copilot_commit-0.5.6 → git_copilot_commit-0.5.7}/README.md +0 -0
- {git_copilot_commit-0.5.6 → git_copilot_commit-0.5.7}/pyproject.toml +0 -0
- {git_copilot_commit-0.5.6 → git_copilot_commit-0.5.7}/src/git_copilot_commit/__init__.py +0 -0
- {git_copilot_commit-0.5.6 → git_copilot_commit-0.5.7}/src/git_copilot_commit/github_copilot.py +0 -0
- {git_copilot_commit-0.5.6 → git_copilot_commit-0.5.7}/src/git_copilot_commit/prompts/commit-message-generator-prompt.md +0 -0
- {git_copilot_commit-0.5.6 → git_copilot_commit-0.5.7}/src/git_copilot_commit/prompts/split-commit-planner-prompt.md +0 -0
- {git_copilot_commit-0.5.6 → git_copilot_commit-0.5.7}/src/git_copilot_commit/py.typed +0 -0
- {git_copilot_commit-0.5.6 → git_copilot_commit-0.5.7}/src/git_copilot_commit/settings.py +0 -0
- {git_copilot_commit-0.5.6 → git_copilot_commit-0.5.7}/src/git_copilot_commit/split_commits.py +0 -0
- {git_copilot_commit-0.5.6 → git_copilot_commit-0.5.7}/src/git_copilot_commit/version.py +0 -0
- {git_copilot_commit-0.5.6 → git_copilot_commit-0.5.7}/tests/conftest.py +0 -0
- {git_copilot_commit-0.5.6 → git_copilot_commit-0.5.7}/tests/test_git.py +0 -0
- {git_copilot_commit-0.5.6 → git_copilot_commit-0.5.7}/tests/test_github_copilot_utils.py +0 -0
- {git_copilot_commit-0.5.6 → git_copilot_commit-0.5.7}/tests/test_settings.py +0 -0
- {git_copilot_commit-0.5.6 → git_copilot_commit-0.5.7}/tests/test_split_commits.py +0 -0
- {git_copilot_commit-0.5.6 → git_copilot_commit-0.5.7}/uv.lock +0 -0
- {git_copilot_commit-0.5.6 → git_copilot_commit-0.5.7}/vhs/demo.vhs +0 -0
|
@@ -95,6 +95,14 @@ class PreparedSplitCommit:
|
|
|
95
95
|
patch_units: tuple[PatchUnit, ...]
|
|
96
96
|
|
|
97
97
|
|
|
98
|
+
@dataclass(frozen=True, slots=True)
|
|
99
|
+
class SplitCommitExecutionState:
|
|
100
|
+
"""Original HEAD state used to roll back partial split-commit execution."""
|
|
101
|
+
|
|
102
|
+
original_head_sha: str | None
|
|
103
|
+
original_head_ref: str | None
|
|
104
|
+
|
|
105
|
+
|
|
98
106
|
CORE_CHANGE_COMMIT_TYPES = frozenset({"feat", "fix", "perf", "refactor", "revert"})
|
|
99
107
|
FOLLOW_UP_COMMIT_TYPE_PRIORITY = {
|
|
100
108
|
"test": 2,
|
|
@@ -757,39 +765,62 @@ def execute_split_commit_plan(
|
|
|
757
765
|
console.print("Invalid choice. Commit cancelled.")
|
|
758
766
|
raise typer.Exit()
|
|
759
767
|
|
|
768
|
+
execution_state = SplitCommitExecutionState(
|
|
769
|
+
original_head_sha=repo.get_head_sha() if repo.has_commit("HEAD") else None,
|
|
770
|
+
original_head_ref=repo.get_symbolic_head_ref(),
|
|
771
|
+
)
|
|
760
772
|
commit_shas: list[str] = []
|
|
761
773
|
total_commits = len(prepared_commits)
|
|
762
774
|
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
775
|
+
try:
|
|
776
|
+
for index, prepared_commit in enumerate(prepared_commits, start=1):
|
|
777
|
+
console.print(
|
|
778
|
+
f"[cyan]Creating commit {index}/{total_commits}:[/cyan] {prepared_commit.message}"
|
|
779
|
+
)
|
|
767
780
|
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
781
|
+
with repo.temporary_alternate_index() as alternate_index:
|
|
782
|
+
try:
|
|
783
|
+
for patch_unit in prepared_commit.patch_units:
|
|
784
|
+
repo.check_patch_for_alternate_index(
|
|
785
|
+
patch_unit.patch,
|
|
786
|
+
index=alternate_index,
|
|
787
|
+
)
|
|
788
|
+
repo.apply_patch_to_alternate_index(
|
|
789
|
+
patch_unit.patch,
|
|
790
|
+
index=alternate_index,
|
|
791
|
+
)
|
|
792
|
+
except GitError as exc:
|
|
793
|
+
console.print(
|
|
794
|
+
f"[red]Failed to apply the planned changes for commit {index}: {exc}[/red]"
|
|
774
795
|
)
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
796
|
+
raise typer.Exit(1)
|
|
797
|
+
|
|
798
|
+
commit_shas.append(
|
|
799
|
+
commit_with_retry_no_verify(
|
|
800
|
+
repo,
|
|
801
|
+
prepared_commit.message,
|
|
802
|
+
use_editor=use_editor,
|
|
803
|
+
env=alternate_index.env,
|
|
778
804
|
)
|
|
779
|
-
except GitError as exc:
|
|
780
|
-
console.print(
|
|
781
|
-
f"[red]Failed to apply the planned changes for commit {index}: {exc}[/red]"
|
|
782
|
-
)
|
|
783
|
-
raise typer.Exit(1)
|
|
784
|
-
|
|
785
|
-
commit_shas.append(
|
|
786
|
-
commit_with_retry_no_verify(
|
|
787
|
-
repo,
|
|
788
|
-
prepared_commit.message,
|
|
789
|
-
use_editor=use_editor,
|
|
790
|
-
env=alternate_index.env,
|
|
791
805
|
)
|
|
806
|
+
except BaseException:
|
|
807
|
+
try:
|
|
808
|
+
if execution_state.original_head_sha is not None:
|
|
809
|
+
repo.soft_reset(execution_state.original_head_sha)
|
|
810
|
+
elif execution_state.original_head_ref is not None and repo.has_commit(
|
|
811
|
+
"HEAD"
|
|
812
|
+
):
|
|
813
|
+
repo.delete_ref(execution_state.original_head_ref)
|
|
814
|
+
except GitError as exc:
|
|
815
|
+
console.print(
|
|
816
|
+
"[red]Failed to restore the original staged changes after split commit creation stopped early: "
|
|
817
|
+
f"{exc}[/red]"
|
|
818
|
+
)
|
|
819
|
+
else:
|
|
820
|
+
console.print(
|
|
821
|
+
"[yellow]Split commit creation did not complete; restored the original staged changes.[/yellow]"
|
|
792
822
|
)
|
|
823
|
+
raise
|
|
793
824
|
|
|
794
825
|
return commit_shas
|
|
795
826
|
|
|
@@ -274,6 +274,15 @@ class GitRepository:
|
|
|
274
274
|
)
|
|
275
275
|
return result.returncode == 0
|
|
276
276
|
|
|
277
|
+
def get_symbolic_head_ref(self) -> str | None:
|
|
278
|
+
"""Return the symbolic ref for HEAD when attached to a branch."""
|
|
279
|
+
result = self._run_git_command(["symbolic-ref", "-q", "HEAD"], check=False)
|
|
280
|
+
if result.returncode != 0:
|
|
281
|
+
return None
|
|
282
|
+
|
|
283
|
+
ref = result.stdout.strip()
|
|
284
|
+
return ref or None
|
|
285
|
+
|
|
277
286
|
def _parse_status_output(self, status_output: str) -> list[GitFile]:
|
|
278
287
|
"""Parse git status --porcelain output into GitFile objects."""
|
|
279
288
|
files = []
|
|
@@ -326,6 +335,23 @@ class GitRepository:
|
|
|
326
335
|
else:
|
|
327
336
|
self._run_git_command(["reset", "HEAD"] + self._normalize_paths(paths))
|
|
328
337
|
|
|
338
|
+
def soft_reset(self, ref: str) -> None:
|
|
339
|
+
"""Move HEAD to ref while preserving the working tree and index."""
|
|
340
|
+
self._run_git_command(["reset", "--soft", ref])
|
|
341
|
+
|
|
342
|
+
def delete_ref(self, ref: str, *, missing_ok: bool = False) -> None:
|
|
343
|
+
"""Delete a ref, optionally ignoring missing refs."""
|
|
344
|
+
result = self._run_git_command(["update-ref", "-d", ref], check=False)
|
|
345
|
+
if result.returncode == 0 or missing_ok:
|
|
346
|
+
return
|
|
347
|
+
|
|
348
|
+
error_output = result.stderr or result.stdout or ""
|
|
349
|
+
if error_output:
|
|
350
|
+
raise GitCommandError(
|
|
351
|
+
f"Git command failed: git update-ref -d {ref}\n{error_output}"
|
|
352
|
+
)
|
|
353
|
+
raise GitCommandError(f"Git command failed: git update-ref -d {ref}")
|
|
354
|
+
|
|
329
355
|
def create_alternate_index(self, from_ref: str = "HEAD") -> AlternateGitIndex:
|
|
330
356
|
"""Create a temporary git index initialized from the provided ref."""
|
|
331
357
|
fd, index_path = tempfile.mkstemp(prefix="git-copilot-commit-", suffix=".index")
|
|
@@ -672,6 +672,62 @@ def test_execute_split_commit_plan_creates_multiple_commits(
|
|
|
672
672
|
assert recent_messages == ["chore: update last line", "chore: update first line"]
|
|
673
673
|
|
|
674
674
|
|
|
675
|
+
def test_execute_split_commit_plan_rolls_back_partial_commits_on_interrupt(
|
|
676
|
+
git_repo,
|
|
677
|
+
git_repo_path,
|
|
678
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
679
|
+
) -> None:
|
|
680
|
+
monkeypatch.setattr(cli.Confirm, "ask", Mock(return_value=True))
|
|
681
|
+
file_path = git_repo_path / "file.txt"
|
|
682
|
+
file_path.write_text("a\nb\nc\nd\ne\nf\ng\nh\ni\nj\n", encoding="utf-8")
|
|
683
|
+
git_repo.stage_files(["file.txt"])
|
|
684
|
+
git_repo.commit("init", no_verify=True)
|
|
685
|
+
|
|
686
|
+
file_path.write_text("A\nb\nc\nd\ne\nf\ng\nh\ni\nJ\n", encoding="utf-8")
|
|
687
|
+
git_repo.stage_files(["file.txt"])
|
|
688
|
+
file_path.write_text("A\nbb\nc\nd\ne\nf\ng\nhh\ni\nJ\nextra\n", encoding="utf-8")
|
|
689
|
+
original_status = git_repo.get_status()
|
|
690
|
+
|
|
691
|
+
patch_units = tuple(
|
|
692
|
+
extract_patch_units(git_repo.get_staged_diff(extra_args=SPLIT_DIFF_ARGS))
|
|
693
|
+
)
|
|
694
|
+
prepared_commits = [
|
|
695
|
+
PreparedSplitCommit(
|
|
696
|
+
message="chore: update first line",
|
|
697
|
+
patch_units=(patch_units[0],),
|
|
698
|
+
),
|
|
699
|
+
PreparedSplitCommit(
|
|
700
|
+
message="chore: update last line",
|
|
701
|
+
patch_units=(patch_units[1],),
|
|
702
|
+
),
|
|
703
|
+
]
|
|
704
|
+
|
|
705
|
+
commit_attempts = 0
|
|
706
|
+
|
|
707
|
+
def interrupting_commit(repo, message, use_editor=False, env=None):
|
|
708
|
+
nonlocal commit_attempts
|
|
709
|
+
commit_attempts += 1
|
|
710
|
+
if commit_attempts == 2:
|
|
711
|
+
raise KeyboardInterrupt()
|
|
712
|
+
|
|
713
|
+
return commit_with_retry_no_verify(
|
|
714
|
+
repo,
|
|
715
|
+
message,
|
|
716
|
+
use_editor=use_editor,
|
|
717
|
+
env=env,
|
|
718
|
+
)
|
|
719
|
+
|
|
720
|
+
monkeypatch.setattr(cli, "commit_with_retry_no_verify", interrupting_commit)
|
|
721
|
+
|
|
722
|
+
with pytest.raises(KeyboardInterrupt):
|
|
723
|
+
execute_split_commit_plan(git_repo, prepared_commits, yes=True)
|
|
724
|
+
|
|
725
|
+
final_status = git_repo.get_status()
|
|
726
|
+
assert final_status.staged_diff == original_status.staged_diff
|
|
727
|
+
assert final_status.unstaged_diff == original_status.unstaged_diff
|
|
728
|
+
assert git_repo.get_recent_commits(limit=1)[0][1] == "init"
|
|
729
|
+
|
|
730
|
+
|
|
675
731
|
def test_execute_split_commit_plan_supports_initial_commit(
|
|
676
732
|
git_repo,
|
|
677
733
|
git_repo_path,
|
|
@@ -724,6 +780,69 @@ def test_execute_split_commit_plan_supports_initial_commit(
|
|
|
724
780
|
]
|
|
725
781
|
|
|
726
782
|
|
|
783
|
+
def test_execute_split_commit_plan_rolls_back_partial_initial_commits_on_interrupt(
|
|
784
|
+
git_repo,
|
|
785
|
+
git_repo_path,
|
|
786
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
787
|
+
) -> None:
|
|
788
|
+
monkeypatch.setattr(cli.Confirm, "ask", Mock(return_value=True))
|
|
789
|
+
src_dir = git_repo_path / "src"
|
|
790
|
+
src_dir.mkdir()
|
|
791
|
+
app_file = src_dir / "app.lua"
|
|
792
|
+
readme_file = git_repo_path / "README.md"
|
|
793
|
+
app_file.write_text("print('hello')\n", encoding="utf-8")
|
|
794
|
+
readme_file.write_text("# Project\n", encoding="utf-8")
|
|
795
|
+
git_repo.stage_files(["src/app.lua", "README.md"])
|
|
796
|
+
original_status = git_repo.get_status()
|
|
797
|
+
|
|
798
|
+
patch_units = tuple(
|
|
799
|
+
extract_patch_units(git_repo.get_staged_diff(extra_args=SPLIT_DIFF_ARGS))
|
|
800
|
+
)
|
|
801
|
+
prepared_commits = [
|
|
802
|
+
PreparedSplitCommit(
|
|
803
|
+
message=(
|
|
804
|
+
f"docs: add {patch_units[0].path}"
|
|
805
|
+
if patch_units[0].path.endswith(".md")
|
|
806
|
+
else f"feat: add {patch_units[0].path}"
|
|
807
|
+
),
|
|
808
|
+
patch_units=(patch_units[0],),
|
|
809
|
+
),
|
|
810
|
+
PreparedSplitCommit(
|
|
811
|
+
message=(
|
|
812
|
+
f"docs: add {patch_units[1].path}"
|
|
813
|
+
if patch_units[1].path.endswith(".md")
|
|
814
|
+
else f"feat: add {patch_units[1].path}"
|
|
815
|
+
),
|
|
816
|
+
patch_units=(patch_units[1],),
|
|
817
|
+
),
|
|
818
|
+
]
|
|
819
|
+
|
|
820
|
+
commit_attempts = 0
|
|
821
|
+
|
|
822
|
+
def interrupting_commit(repo, message, use_editor=False, env=None):
|
|
823
|
+
nonlocal commit_attempts
|
|
824
|
+
commit_attempts += 1
|
|
825
|
+
if commit_attempts == 2:
|
|
826
|
+
raise KeyboardInterrupt()
|
|
827
|
+
|
|
828
|
+
return commit_with_retry_no_verify(
|
|
829
|
+
repo,
|
|
830
|
+
message,
|
|
831
|
+
use_editor=use_editor,
|
|
832
|
+
env=env,
|
|
833
|
+
)
|
|
834
|
+
|
|
835
|
+
monkeypatch.setattr(cli, "commit_with_retry_no_verify", interrupting_commit)
|
|
836
|
+
|
|
837
|
+
with pytest.raises(KeyboardInterrupt):
|
|
838
|
+
execute_split_commit_plan(git_repo, prepared_commits, yes=True)
|
|
839
|
+
|
|
840
|
+
final_status = git_repo.get_status()
|
|
841
|
+
assert final_status.staged_diff == original_status.staged_diff
|
|
842
|
+
assert final_status.unstaged_diff == original_status.unstaged_diff
|
|
843
|
+
assert not git_repo.has_commit("HEAD")
|
|
844
|
+
|
|
845
|
+
|
|
727
846
|
def test_handle_split_commit_flow_falls_back_to_single_commit(
|
|
728
847
|
monkeypatch: pytest.MonkeyPatch,
|
|
729
848
|
) -> None:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{git_copilot_commit-0.5.6 → git_copilot_commit-0.5.7}/src/git_copilot_commit/github_copilot.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{git_copilot_commit-0.5.6 → git_copilot_commit-0.5.7}/src/git_copilot_commit/split_commits.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|