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.
Files changed (27) hide show
  1. {git_copilot_commit-0.5.6 → git_copilot_commit-0.5.7}/PKG-INFO +1 -1
  2. {git_copilot_commit-0.5.6 → git_copilot_commit-0.5.7}/src/git_copilot_commit/cli.py +56 -25
  3. {git_copilot_commit-0.5.6 → git_copilot_commit-0.5.7}/src/git_copilot_commit/git.py +26 -0
  4. {git_copilot_commit-0.5.6 → git_copilot_commit-0.5.7}/tests/test_cli.py +119 -0
  5. {git_copilot_commit-0.5.6 → git_copilot_commit-0.5.7}/.github/dependabot.yml +0 -0
  6. {git_copilot_commit-0.5.6 → git_copilot_commit-0.5.7}/.github/workflows/ci.yml +0 -0
  7. {git_copilot_commit-0.5.6 → git_copilot_commit-0.5.7}/.gitignore +0 -0
  8. {git_copilot_commit-0.5.6 → git_copilot_commit-0.5.7}/.justfile +0 -0
  9. {git_copilot_commit-0.5.6 → git_copilot_commit-0.5.7}/.python-version +0 -0
  10. {git_copilot_commit-0.5.6 → git_copilot_commit-0.5.7}/LICENSE +0 -0
  11. {git_copilot_commit-0.5.6 → git_copilot_commit-0.5.7}/README.md +0 -0
  12. {git_copilot_commit-0.5.6 → git_copilot_commit-0.5.7}/pyproject.toml +0 -0
  13. {git_copilot_commit-0.5.6 → git_copilot_commit-0.5.7}/src/git_copilot_commit/__init__.py +0 -0
  14. {git_copilot_commit-0.5.6 → git_copilot_commit-0.5.7}/src/git_copilot_commit/github_copilot.py +0 -0
  15. {git_copilot_commit-0.5.6 → git_copilot_commit-0.5.7}/src/git_copilot_commit/prompts/commit-message-generator-prompt.md +0 -0
  16. {git_copilot_commit-0.5.6 → git_copilot_commit-0.5.7}/src/git_copilot_commit/prompts/split-commit-planner-prompt.md +0 -0
  17. {git_copilot_commit-0.5.6 → git_copilot_commit-0.5.7}/src/git_copilot_commit/py.typed +0 -0
  18. {git_copilot_commit-0.5.6 → git_copilot_commit-0.5.7}/src/git_copilot_commit/settings.py +0 -0
  19. {git_copilot_commit-0.5.6 → git_copilot_commit-0.5.7}/src/git_copilot_commit/split_commits.py +0 -0
  20. {git_copilot_commit-0.5.6 → git_copilot_commit-0.5.7}/src/git_copilot_commit/version.py +0 -0
  21. {git_copilot_commit-0.5.6 → git_copilot_commit-0.5.7}/tests/conftest.py +0 -0
  22. {git_copilot_commit-0.5.6 → git_copilot_commit-0.5.7}/tests/test_git.py +0 -0
  23. {git_copilot_commit-0.5.6 → git_copilot_commit-0.5.7}/tests/test_github_copilot_utils.py +0 -0
  24. {git_copilot_commit-0.5.6 → git_copilot_commit-0.5.7}/tests/test_settings.py +0 -0
  25. {git_copilot_commit-0.5.6 → git_copilot_commit-0.5.7}/tests/test_split_commits.py +0 -0
  26. {git_copilot_commit-0.5.6 → git_copilot_commit-0.5.7}/uv.lock +0 -0
  27. {git_copilot_commit-0.5.6 → git_copilot_commit-0.5.7}/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.6
3
+ Version: 0.5.7
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
@@ -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
- for index, prepared_commit in enumerate(prepared_commits, start=1):
764
- console.print(
765
- f"[cyan]Creating commit {index}/{total_commits}:[/cyan] {prepared_commit.message}"
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
- with repo.temporary_alternate_index() as alternate_index:
769
- try:
770
- for patch_unit in prepared_commit.patch_units:
771
- repo.check_patch_for_alternate_index(
772
- patch_unit.patch,
773
- index=alternate_index,
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
- repo.apply_patch_to_alternate_index(
776
- patch_unit.patch,
777
- index=alternate_index,
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: