mirror of
https://github.com/NickCrew/Claude-Cortex.git
synced 2026-09-14 20:17:11 +08:00
209 lines
8.1 KiB
Python
209 lines
8.1 KiB
Python
"""Unit tests for claude_ctx_py.git.patch."""
|
|
from __future__ import annotations
|
|
|
|
import subprocess
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
from claude_ctx_py.git.patch import git_patch
|
|
|
|
_PATCH_MOD = "claude_ctx_py.git.patch"
|
|
_COMMIT_MOD = "claude_ctx_py.git.commit"
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestGitPatch:
|
|
@patch(f"{_COMMIT_MOD}.run_git")
|
|
@patch(f"{_PATCH_MOD}.run_git")
|
|
@patch(f"{_PATCH_MOD}.resolve_repo_root", return_value=(Path("/repo"), None))
|
|
def test_success(self, _root, mock_patch_run, mock_commit_run, tmp_path):
|
|
f = tmp_path / "file.py"
|
|
f.write_text("x")
|
|
# patch.py's run_git: apply, diff --cached --name-only, diff --staged --quiet
|
|
mock_patch_run.side_effect = [
|
|
(0, "", ""), # apply --cached
|
|
(0, str(f) + "\n", ""), # diff --cached --name-only
|
|
(1, "", ""), # diff --staged --quiet (1 = has changes)
|
|
]
|
|
# commit.py's run_git: restore --staged, then commit
|
|
mock_commit_run.side_effect = [
|
|
(0, "", ""), # restore --staged (via _unstage_all)
|
|
(0, "", ""), # git commit
|
|
]
|
|
code, msg = git_patch("fix: bug", [str(f)], "diff content", cwd=tmp_path)
|
|
assert code == 0
|
|
assert "Committed" in msg
|
|
|
|
@patch(f"{_PATCH_MOD}.resolve_repo_root", return_value=(Path("/repo"), None))
|
|
def test_empty_diff(self, _root, tmp_path):
|
|
f = tmp_path / "file.py"
|
|
f.write_text("x")
|
|
code, msg = git_patch("fix: bug", [str(f)], "", cwd=tmp_path)
|
|
assert code == 1
|
|
assert "empty" in msg.lower()
|
|
|
|
@patch(f"{_COMMIT_MOD}.run_git")
|
|
@patch(f"{_PATCH_MOD}.run_git")
|
|
@patch(f"{_PATCH_MOD}.resolve_repo_root", return_value=(Path("/repo"), None))
|
|
def test_apply_fails(self, _root, mock_patch_run, mock_commit_run, tmp_path):
|
|
f = tmp_path / "file.py"
|
|
f.write_text("x")
|
|
mock_commit_run.side_effect = [
|
|
(0, "", ""), # restore --staged
|
|
]
|
|
mock_patch_run.side_effect = [
|
|
(1, "", "patch does not apply"), # apply fails
|
|
]
|
|
code, msg = git_patch("fix: bug", [str(f)], "bad diff", cwd=tmp_path)
|
|
assert code == 1
|
|
assert "apply" in msg.lower()
|
|
|
|
@patch(f"{_COMMIT_MOD}.run_git")
|
|
@patch(f"{_PATCH_MOD}.run_git")
|
|
@patch(f"{_PATCH_MOD}.resolve_repo_root", return_value=(Path("/repo"), None))
|
|
def test_unexpected_file_staged(self, _root, mock_patch_run, mock_commit_run, tmp_path):
|
|
f = tmp_path / "file.py"
|
|
f.write_text("x")
|
|
mock_commit_run.side_effect = [
|
|
(0, "", ""), # restore --staged
|
|
]
|
|
mock_patch_run.side_effect = [
|
|
(0, "", ""), # apply --cached
|
|
(0, str(f) + "\nother.py\n", ""), # staged includes unexpected file
|
|
]
|
|
code, msg = git_patch("fix: bug", [str(f)], "diff", cwd=tmp_path)
|
|
assert code == 1
|
|
assert "unexpected" in msg.lower()
|
|
|
|
@patch(f"{_COMMIT_MOD}.run_git")
|
|
@patch(f"{_PATCH_MOD}.run_git")
|
|
@patch(f"{_PATCH_MOD}.resolve_repo_root", return_value=(Path("/repo"), None))
|
|
def test_no_staged_after_apply(self, _root, mock_patch_run, mock_commit_run, tmp_path):
|
|
f = tmp_path / "file.py"
|
|
f.write_text("x")
|
|
mock_commit_run.side_effect = [
|
|
(0, "", ""), # restore --staged
|
|
]
|
|
mock_patch_run.side_effect = [
|
|
(0, "", ""), # apply --cached
|
|
(0, "", ""), # diff --cached --name-only (empty)
|
|
]
|
|
code, msg = git_patch("fix: bug", [str(f)], "diff", cwd=tmp_path)
|
|
assert code == 1
|
|
assert "no staged" in msg.lower()
|
|
|
|
@patch(f"{_PATCH_MOD}.resolve_repo_root", return_value=(None, "Not a git repository"))
|
|
def test_not_in_repo(self, _root):
|
|
code, msg = git_patch("fix: bug", ["f.py"], "diff")
|
|
assert code == 1
|
|
|
|
@patch(f"{_COMMIT_MOD}.run_git")
|
|
@patch(f"{_PATCH_MOD}.run_git")
|
|
@patch(f"{_PATCH_MOD}.resolve_repo_root", return_value=(Path("/repo"), None))
|
|
def test_commit_invocation_has_no_pathspec(
|
|
self, _root, mock_patch_run, mock_commit_run, tmp_path
|
|
):
|
|
"""``git commit`` must not receive pathspec args from ``git_patch``.
|
|
|
|
Pathspec on commit triggers ``--only`` mode, which re-stages
|
|
working-tree content and silently destroys the hunks staged by
|
|
``git apply --cached``. Asserting argv shape here freezes the
|
|
contract so a future refactor can't reintroduce the bug.
|
|
"""
|
|
f = tmp_path / "file.py"
|
|
f.write_text("x")
|
|
mock_patch_run.side_effect = [
|
|
(0, "", ""), # apply --cached
|
|
(0, str(f) + "\n", ""), # diff --cached --name-only
|
|
(1, "", ""), # diff --staged --quiet
|
|
]
|
|
mock_commit_run.side_effect = [
|
|
(0, "", ""), # restore --staged
|
|
(0, "", ""), # git commit
|
|
]
|
|
code, _msg = git_patch("fix: bug", [str(f)], "diff content", cwd=tmp_path)
|
|
assert code == 0
|
|
# The commit call is the second invocation on commit.py's run_git
|
|
# (the first is ``restore --staged`` from _unstage_all).
|
|
commit_call = mock_commit_run.call_args_list[1]
|
|
argv = commit_call.args[0]
|
|
assert argv[:3] == ["commit", "-m", "fix: bug"]
|
|
assert "--" not in argv, (
|
|
f"git_patch must not pass pathspec to git commit; "
|
|
f"got argv={argv}"
|
|
)
|
|
assert str(f) not in argv
|
|
|
|
|
|
@pytest.mark.integration
|
|
class TestGitPatchIntegration:
|
|
"""Real-git integration tests for ``git_patch``.
|
|
|
|
Mocked unit tests can pass even when the commit argv silently destroys
|
|
the staged index (the original bug). These tests run real ``git``
|
|
against a ``tmp_path`` repo to verify what actually lands.
|
|
"""
|
|
|
|
@staticmethod
|
|
def _git(*args: str, cwd: Path) -> str:
|
|
return subprocess.run(
|
|
["git", *args],
|
|
cwd=str(cwd),
|
|
check=True,
|
|
capture_output=True,
|
|
text=True,
|
|
).stdout
|
|
|
|
def test_partial_diff_commits_only_named_hunks(self, tmp_path):
|
|
"""Hand-crafted diff with one hunk must commit only that hunk.
|
|
|
|
Reproduces the original bug: working tree has two unrelated hunks
|
|
(top + bottom), patch input contains only the top hunk. After
|
|
``git_patch``, HEAD must contain only the top hunk; the bottom
|
|
hunk must remain in the working tree, unstaged.
|
|
"""
|
|
repo = tmp_path
|
|
self._git("init", "-q", cwd=repo)
|
|
self._git("config", "user.email", "t@t", cwd=repo)
|
|
self._git("config", "user.name", "t", cwd=repo)
|
|
|
|
f = repo / "f.txt"
|
|
f.write_text("line1\nline2\nline3\nline4\nline5\n")
|
|
self._git("add", "f.txt", cwd=repo)
|
|
self._git("commit", "-qm", "init", cwd=repo)
|
|
|
|
# Working tree gets two unrelated hunks.
|
|
f.write_text("LINE1-changed\nline2\nline3\nline4\nLINE5-changed\n")
|
|
|
|
# Hand-crafted patch containing ONLY the top hunk.
|
|
top_hunk_only = (
|
|
"diff --git a/f.txt b/f.txt\n"
|
|
"--- a/f.txt\n"
|
|
"+++ b/f.txt\n"
|
|
"@@ -1,3 +1,3 @@\n"
|
|
"-line1\n"
|
|
"+LINE1-changed\n"
|
|
" line2\n"
|
|
" line3\n"
|
|
)
|
|
|
|
code, msg = git_patch(
|
|
"fix: rename line1", ["f.txt"], top_hunk_only, cwd=repo
|
|
)
|
|
assert code == 0, msg
|
|
|
|
# HEAD must contain LINE1 change but NOT LINE5 change.
|
|
committed_diff = self._git("show", "HEAD", "--", "f.txt", cwd=repo)
|
|
assert "+LINE1-changed" in committed_diff
|
|
assert "+LINE5-changed" not in committed_diff, (
|
|
"Bug regression: git_patch committed a hunk that was not in the "
|
|
"patch input — pathspec on git commit triggered --only mode and "
|
|
"re-staged working-tree content."
|
|
)
|
|
|
|
# The unrelated hunk must still be in the working tree, unstaged.
|
|
worktree_diff = self._git("diff", "--", "f.txt", cwd=repo)
|
|
assert "+LINE5-changed" in worktree_diff
|