mirror of
https://github.com/NickCrew/Claude-Cortex.git
synced 2026-09-14 20:17:11 +08:00
1c512579a2
The lock-retry path only fired after the commit step failed, but a stale .git/index.lock blocks the unstage step (the first index-touching operation in both git_commit and git_patch). The retry was unreachable. Extract _clear_stale_lock_from_error helper and extend _unstage_all to honor force_lock so 'cortex git commit --force' actually clears stale locks as advertised. Default behavior (force_lock=False) is unchanged — locks are preserved so callers who don't opt in can still investigate.
189 lines
7.0 KiB
Python
189 lines
7.0 KiB
Python
"""Unit tests for claude_ctx_py.git.commit."""
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
from claude_ctx_py.git.commit import git_commit
|
|
|
|
_MOD = "claude_ctx_py.git.commit"
|
|
|
|
|
|
def _side_effects(*returns):
|
|
"""Build a side_effect list for sequential run_git calls."""
|
|
return list(returns)
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestGitCommit:
|
|
@patch(f"{_MOD}.run_git")
|
|
@patch(f"{_MOD}.resolve_repo_root", return_value=(Path("/repo"), None))
|
|
def test_success(self, _root, mock_run, tmp_path):
|
|
f = tmp_path / "file.py"
|
|
f.write_text("x")
|
|
mock_run.side_effect = [
|
|
(0, "", ""), # restore --staged
|
|
(0, "", ""), # add -A
|
|
(1, "", ""), # diff --staged --quiet (1 = has changes)
|
|
(0, "", ""), # commit
|
|
]
|
|
code, msg = git_commit("fix: bug", [str(f)], cwd=tmp_path)
|
|
assert code == 0
|
|
assert "Committed" in msg
|
|
|
|
@patch(f"{_MOD}.resolve_repo_root", return_value=(Path("/repo"), None))
|
|
def test_empty_message(self, _root):
|
|
code, msg = git_commit("", ["file.py"])
|
|
assert code == 1
|
|
assert "empty" in msg.lower()
|
|
|
|
@patch(f"{_MOD}.resolve_repo_root", return_value=(Path("/repo"), None))
|
|
def test_no_files(self, _root):
|
|
code, msg = git_commit("fix: bug", [])
|
|
assert code == 1
|
|
|
|
@patch(f"{_MOD}.resolve_repo_root", return_value=(Path("/repo"), None))
|
|
def test_dot_rejected(self, _root):
|
|
code, msg = git_commit("fix: bug", ["."])
|
|
assert code == 1
|
|
assert '"."' in msg
|
|
|
|
@patch(f"{_MOD}.run_git")
|
|
@patch(f"{_MOD}.resolve_repo_root", return_value=(Path("/repo"), None))
|
|
def test_no_staged_changes(self, _root, mock_run, tmp_path):
|
|
f = tmp_path / "file.py"
|
|
f.write_text("x")
|
|
mock_run.side_effect = [
|
|
(0, "", ""), # restore --staged
|
|
(0, "", ""), # add -A
|
|
(0, "", ""), # diff --staged --quiet (0 = no changes)
|
|
]
|
|
code, msg = git_commit("fix: bug", [str(f)], cwd=tmp_path)
|
|
assert code == 1
|
|
assert "no staged" in msg.lower()
|
|
|
|
@patch(f"{_MOD}.run_git")
|
|
@patch(f"{_MOD}.resolve_repo_root", return_value=(Path("/repo"), None))
|
|
def test_commit_failure(self, _root, mock_run, tmp_path):
|
|
f = tmp_path / "file.py"
|
|
f.write_text("x")
|
|
mock_run.side_effect = [
|
|
(0, "", ""), # restore --staged
|
|
(0, "", ""), # add -A
|
|
(1, "", ""), # diff --staged --quiet
|
|
(1, "", "something went wrong"), # commit fails
|
|
]
|
|
code, msg = git_commit("fix: bug", [str(f)], cwd=tmp_path)
|
|
assert code == 1
|
|
|
|
@patch(f"{_MOD}.run_git")
|
|
@patch(f"{_MOD}.resolve_repo_root", return_value=(Path("/repo"), None))
|
|
def test_lock_retry(self, _root, mock_run, tmp_path):
|
|
f = tmp_path / "file.py"
|
|
f.write_text("x")
|
|
lock = tmp_path / ".git" / "index.lock"
|
|
lock.parent.mkdir(parents=True)
|
|
lock.write_text("")
|
|
|
|
mock_run.side_effect = [
|
|
(0, "", ""), # restore --staged
|
|
(0, "", ""), # add -A
|
|
(1, "", ""), # diff --staged --quiet
|
|
(1, "", f"Unable to create '{lock}'"), # commit fails with lock
|
|
(0, "", ""), # retry commit succeeds
|
|
]
|
|
code, msg = git_commit("fix: bug", [str(f)], force_lock=True, cwd=tmp_path)
|
|
assert code == 0
|
|
assert not lock.exists()
|
|
|
|
@patch(f"{_MOD}.run_git")
|
|
@patch(f"{_MOD}.resolve_repo_root", return_value=(Path("/repo"), None))
|
|
def test_lock_retry_at_unstage_with_force(self, _root, mock_run, tmp_path):
|
|
"""Stale lock blocking the unstage step should be cleared under force_lock."""
|
|
f = tmp_path / "file.py"
|
|
f.write_text("x")
|
|
lock = tmp_path / ".git" / "index.lock"
|
|
lock.parent.mkdir(parents=True)
|
|
lock.write_text("")
|
|
|
|
mock_run.side_effect = [
|
|
# 1st restore --staged fails because of stale lock
|
|
(1, "", f"fatal: Unable to create '{lock}': File exists."),
|
|
# retry restore --staged after lock is cleared
|
|
(0, "", ""),
|
|
(0, "", ""), # add -A
|
|
(1, "", ""), # diff --staged --quiet (1 = has changes)
|
|
(0, "", ""), # commit succeeds
|
|
]
|
|
code, msg = git_commit("fix: bug", [str(f)], force_lock=True, cwd=tmp_path)
|
|
assert code == 0
|
|
assert not lock.exists()
|
|
|
|
@patch(f"{_MOD}.run_git")
|
|
@patch(f"{_MOD}.resolve_repo_root", return_value=(Path("/repo"), None))
|
|
def test_lock_at_unstage_without_force_does_not_retry(
|
|
self, _root, mock_run, tmp_path
|
|
):
|
|
"""Without force_lock, the unstage step should NOT remove the lock."""
|
|
f = tmp_path / "file.py"
|
|
f.write_text("x")
|
|
lock = tmp_path / ".git" / "index.lock"
|
|
lock.parent.mkdir(parents=True)
|
|
lock.write_text("")
|
|
|
|
mock_run.side_effect = [
|
|
(1, "", f"fatal: Unable to create '{lock}': File exists."),
|
|
]
|
|
code, msg = git_commit("fix: bug", [str(f)], cwd=tmp_path)
|
|
assert code == 1
|
|
assert "Failed to unstage" in msg
|
|
# Lock must remain — we did not opt into clearing it.
|
|
assert lock.exists()
|
|
|
|
@patch(f"{_MOD}.resolve_repo_root", return_value=(None, "Not a git repository"))
|
|
def test_not_in_repo(self, _root):
|
|
code, msg = git_commit("fix: bug", ["file.py"])
|
|
assert code == 1
|
|
assert "git" in msg.lower() or "repository" in msg.lower()
|
|
|
|
@patch(f"{_MOD}.run_git")
|
|
@patch(f"{_MOD}.resolve_repo_root", return_value=(Path("/repo"), None))
|
|
def test_file_not_found(self, _root, mock_run, tmp_path):
|
|
# File doesn't exist on disk, not in index, not in HEAD
|
|
mock_run.side_effect = [
|
|
(1, "", ""), # ls-files --error-unmatch
|
|
(1, "", ""), # cat-file -e HEAD:file
|
|
]
|
|
code, msg = git_commit("fix: bug", ["nonexistent.py"], cwd=tmp_path)
|
|
assert code == 1
|
|
assert "not found" in msg.lower()
|
|
|
|
|
|
class TestClearStaleLockFromError:
|
|
"""Pure-logic tests for the lock-clearing helper."""
|
|
|
|
def test_match_and_clear(self, tmp_path: Path) -> None:
|
|
from claude_ctx_py.git.commit import _clear_stale_lock_from_error
|
|
|
|
lock = tmp_path / "index.lock"
|
|
lock.write_text("")
|
|
stderr = f"fatal: Unable to create '{lock}': File exists."
|
|
|
|
assert _clear_stale_lock_from_error(stderr) is True
|
|
assert not lock.exists()
|
|
|
|
def test_non_matching_error_returns_false(self, tmp_path: Path) -> None:
|
|
from claude_ctx_py.git.commit import _clear_stale_lock_from_error
|
|
|
|
assert _clear_stale_lock_from_error("some other error") is False
|
|
|
|
def test_lock_already_gone_returns_false(self, tmp_path: Path) -> None:
|
|
from claude_ctx_py.git.commit import _clear_stale_lock_from_error
|
|
|
|
lock = tmp_path / "index.lock"
|
|
# Reference a path that doesn't exist
|
|
stderr = f"fatal: Unable to create '{lock}': File exists."
|
|
assert _clear_stale_lock_from_error(stderr) is False
|