Files
Nicholas Ferguson 1c512579a2 fix(git): clear stale index.lock at unstage step under --force
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.
2026-04-30 19:23:10 -04:00

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