git-copilot-commit 0.5.5__tar.gz → 0.5.6__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.5 → git_copilot_commit-0.5.6}/PKG-INFO +1 -1
  2. {git_copilot_commit-0.5.5 → git_copilot_commit-0.5.6}/src/git_copilot_commit/git.py +21 -6
  3. {git_copilot_commit-0.5.5 → git_copilot_commit-0.5.6}/tests/conftest.py +3 -0
  4. {git_copilot_commit-0.5.5 → git_copilot_commit-0.5.6}/tests/test_cli.py +52 -0
  5. {git_copilot_commit-0.5.5 → git_copilot_commit-0.5.6}/tests/test_git.py +37 -0
  6. {git_copilot_commit-0.5.5 → git_copilot_commit-0.5.6}/.github/dependabot.yml +0 -0
  7. {git_copilot_commit-0.5.5 → git_copilot_commit-0.5.6}/.github/workflows/ci.yml +0 -0
  8. {git_copilot_commit-0.5.5 → git_copilot_commit-0.5.6}/.gitignore +0 -0
  9. {git_copilot_commit-0.5.5 → git_copilot_commit-0.5.6}/.justfile +0 -0
  10. {git_copilot_commit-0.5.5 → git_copilot_commit-0.5.6}/.python-version +0 -0
  11. {git_copilot_commit-0.5.5 → git_copilot_commit-0.5.6}/LICENSE +0 -0
  12. {git_copilot_commit-0.5.5 → git_copilot_commit-0.5.6}/README.md +0 -0
  13. {git_copilot_commit-0.5.5 → git_copilot_commit-0.5.6}/pyproject.toml +0 -0
  14. {git_copilot_commit-0.5.5 → git_copilot_commit-0.5.6}/src/git_copilot_commit/__init__.py +0 -0
  15. {git_copilot_commit-0.5.5 → git_copilot_commit-0.5.6}/src/git_copilot_commit/cli.py +0 -0
  16. {git_copilot_commit-0.5.5 → git_copilot_commit-0.5.6}/src/git_copilot_commit/github_copilot.py +0 -0
  17. {git_copilot_commit-0.5.5 → git_copilot_commit-0.5.6}/src/git_copilot_commit/prompts/commit-message-generator-prompt.md +0 -0
  18. {git_copilot_commit-0.5.5 → git_copilot_commit-0.5.6}/src/git_copilot_commit/prompts/split-commit-planner-prompt.md +0 -0
  19. {git_copilot_commit-0.5.5 → git_copilot_commit-0.5.6}/src/git_copilot_commit/py.typed +0 -0
  20. {git_copilot_commit-0.5.5 → git_copilot_commit-0.5.6}/src/git_copilot_commit/settings.py +0 -0
  21. {git_copilot_commit-0.5.5 → git_copilot_commit-0.5.6}/src/git_copilot_commit/split_commits.py +0 -0
  22. {git_copilot_commit-0.5.5 → git_copilot_commit-0.5.6}/src/git_copilot_commit/version.py +0 -0
  23. {git_copilot_commit-0.5.5 → git_copilot_commit-0.5.6}/tests/test_github_copilot_utils.py +0 -0
  24. {git_copilot_commit-0.5.5 → git_copilot_commit-0.5.6}/tests/test_settings.py +0 -0
  25. {git_copilot_commit-0.5.5 → git_copilot_commit-0.5.6}/tests/test_split_commits.py +0 -0
  26. {git_copilot_commit-0.5.5 → git_copilot_commit-0.5.6}/uv.lock +0 -0
  27. {git_copilot_commit-0.5.5 → git_copilot_commit-0.5.6}/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.5
3
+ Version: 0.5.6
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
@@ -139,7 +139,9 @@ class GitRepository:
139
139
  except subprocess.CalledProcessError:
140
140
  raise NotAGitRepositoryError(f"{self.cwd} is not a git repository")
141
141
  except subprocess.TimeoutExpired:
142
- raise GitCommandError("Git command timed out: git rev-parse --show-toplevel")
142
+ raise GitCommandError(
143
+ "Git command timed out: git rev-parse --show-toplevel"
144
+ )
143
145
 
144
146
  repo_root = result.stdout.strip()
145
147
  if not repo_root:
@@ -264,10 +266,18 @@ class GitRepository:
264
266
  result = self._run_git_command(["rev-parse", ref])
265
267
  return result.stdout.strip()
266
268
 
269
+ def has_commit(self, ref: str = "HEAD") -> bool:
270
+ """Return whether the provided ref resolves to a commit."""
271
+ result = self._run_git_command(
272
+ ["rev-parse", "--verify", "--quiet", f"{ref}^{{commit}}"],
273
+ check=False,
274
+ )
275
+ return result.returncode == 0
276
+
267
277
  def _parse_status_output(self, status_output: str) -> list[GitFile]:
268
278
  """Parse git status --porcelain output into GitFile objects."""
269
279
  files = []
270
- for line in status_output.strip().split("\n"):
280
+ for line in status_output.splitlines():
271
281
  if not line:
272
282
  continue
273
283
 
@@ -318,12 +328,13 @@ class GitRepository:
318
328
 
319
329
  def create_alternate_index(self, from_ref: str = "HEAD") -> AlternateGitIndex:
320
330
  """Create a temporary git index initialized from the provided ref."""
321
- fd, index_path = tempfile.mkstemp(
322
- prefix="git-copilot-commit-", suffix=".index"
323
- )
331
+ fd, index_path = tempfile.mkstemp(prefix="git-copilot-commit-", suffix=".index")
324
332
  os.close(fd)
325
333
  alternate_index = AlternateGitIndex(Path(index_path))
326
- self.read_tree(from_ref, index=alternate_index)
334
+ if from_ref == "HEAD" and not self.has_commit(from_ref):
335
+ self.read_empty_tree(index=alternate_index)
336
+ else:
337
+ self.read_tree(from_ref, index=alternate_index)
327
338
  return alternate_index
328
339
 
329
340
  @contextmanager
@@ -341,6 +352,10 @@ class GitRepository:
341
352
  """Populate an alternate index from the provided ref."""
342
353
  self._run_git_command(["read-tree", ref], env=index.env)
343
354
 
355
+ def read_empty_tree(self, *, index: AlternateGitIndex) -> None:
356
+ """Initialize an alternate index with an empty tree."""
357
+ self._run_git_command(["read-tree", "--empty"], env=index.env)
358
+
344
359
  def apply_patch(
345
360
  self,
346
361
  patch: str,
@@ -23,6 +23,9 @@ def git_repo_path(tmp_path: Path) -> Path:
23
23
  run_git(tmp_path, "init", "-q")
24
24
  run_git(tmp_path, "config", "user.name", "Test User")
25
25
  run_git(tmp_path, "config", "user.email", "test@example.com")
26
+ hooks_dir = tmp_path / ".githooks"
27
+ hooks_dir.mkdir()
28
+ run_git(tmp_path, "config", "core.hooksPath", str(hooks_dir))
26
29
  return tmp_path
27
30
 
28
31
 
@@ -672,6 +672,58 @@ 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_supports_initial_commit(
676
+ git_repo,
677
+ git_repo_path,
678
+ monkeypatch: pytest.MonkeyPatch,
679
+ ) -> None:
680
+ monkeypatch.setattr(cli.Confirm, "ask", Mock(return_value=True))
681
+ src_dir = git_repo_path / "src"
682
+ src_dir.mkdir()
683
+ app_file = src_dir / "app.lua"
684
+ readme_file = git_repo_path / "README.md"
685
+ app_file.write_text("print('hello')\n", encoding="utf-8")
686
+ readme_file.write_text("# Project\n", encoding="utf-8")
687
+ git_repo.stage_files(["src/app.lua", "README.md"])
688
+
689
+ patch_units = tuple(
690
+ extract_patch_units(git_repo.get_staged_diff(extra_args=SPLIT_DIFF_ARGS))
691
+ )
692
+ assert len(patch_units) == 2
693
+
694
+ prepared_commits = [
695
+ PreparedSplitCommit(
696
+ message=(
697
+ f"docs: add {patch_units[0].path}"
698
+ if patch_units[0].path.endswith(".md")
699
+ else f"feat: add {patch_units[0].path}"
700
+ ),
701
+ patch_units=(patch_units[0],),
702
+ ),
703
+ PreparedSplitCommit(
704
+ message=(
705
+ f"docs: add {patch_units[1].path}"
706
+ if patch_units[1].path.endswith(".md")
707
+ else f"feat: add {patch_units[1].path}"
708
+ ),
709
+ patch_units=(patch_units[1],),
710
+ ),
711
+ ]
712
+
713
+ commit_shas = execute_split_commit_plan(git_repo, prepared_commits, yes=True)
714
+
715
+ assert len(commit_shas) == 2
716
+ final_status = git_repo.get_status()
717
+ assert not final_status.has_staged_changes
718
+ assert not final_status.has_unstaged_changes
719
+
720
+ recent_messages = [message for _, message in git_repo.get_recent_commits(limit=2)]
721
+ assert recent_messages == [
722
+ prepared_commits[1].message,
723
+ prepared_commits[0].message,
724
+ ]
725
+
726
+
675
727
  def test_handle_split_commit_flow_falls_back_to_single_commit(
676
728
  monkeypatch: pytest.MonkeyPatch,
677
729
  ) -> None:
@@ -99,6 +99,34 @@ def test_alternate_index_commit_preserves_real_index_and_unstaged_changes(
99
99
  assert recent_messages == ["part 2", "part 1"]
100
100
 
101
101
 
102
+ def test_alternate_index_supports_unborn_head(git_repo, git_repo_path) -> None:
103
+ file_path = git_repo_path / "README.md"
104
+ file_path.write_text("# Title\n", encoding="utf-8")
105
+ git_repo.stage_files(["README.md"])
106
+
107
+ staged_diff = git_repo.get_staged_diff(
108
+ extra_args=["--src-prefix=a/", "--dst-prefix=b/"]
109
+ )
110
+ assert "new file mode" in staged_diff
111
+
112
+ with git_repo.temporary_alternate_index() as alternate_index:
113
+ git_repo.check_patch_for_alternate_index(staged_diff, index=alternate_index)
114
+ git_repo.apply_patch_to_alternate_index(staged_diff, index=alternate_index)
115
+ commit_sha = git_repo.commit(
116
+ "docs: add readme",
117
+ env=alternate_index.env,
118
+ no_verify=True,
119
+ )
120
+
121
+ assert len(commit_sha) == 40
122
+ assert not alternate_index.path.exists()
123
+
124
+ final_status = git_repo.get_status()
125
+ assert not final_status.has_staged_changes
126
+ assert not final_status.has_unstaged_changes
127
+ assert git_repo.get_recent_commits(limit=1)[0][1] == "docs: add readme"
128
+
129
+
102
130
  def test_git_file_and_status_helper_properties() -> None:
103
131
  staged = GitFile(path="staged.py", status=" ", staged_status="M")
104
132
  unstaged = GitFile(path="unstaged.py", status="M", staged_status=" ")
@@ -201,3 +229,12 @@ def test_parse_status_output_build_env_and_commit_validation(git_repo) -> None:
201
229
 
202
230
  with pytest.raises(ValueError):
203
231
  git_repo.commit(None)
232
+
233
+
234
+ def test_parse_status_output_preserves_leading_space_on_first_line(git_repo) -> None:
235
+ parsed = git_repo._parse_status_output(" M backend/service.py\nM frontend.py\n")
236
+
237
+ assert [(file.staged_status, file.status, file.path) for file in parsed] == [
238
+ (" ", "M", "backend/service.py"),
239
+ ("M", " ", "frontend.py"),
240
+ ]