rewrite phase 1
This commit is contained in:
@@ -0,0 +1,247 @@
|
||||
import asyncio
|
||||
from collections.abc import Sequence
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from agentci.git import Git, GitError
|
||||
|
||||
|
||||
class FakeProcess:
|
||||
def __init__(
|
||||
self,
|
||||
stdout: bytes = b"",
|
||||
stderr: bytes = b"",
|
||||
returncode: int = 0,
|
||||
) -> None:
|
||||
self.stdout = stdout
|
||||
self.stderr = stderr
|
||||
self.returncode = returncode
|
||||
|
||||
async def communicate(self) -> tuple[bytes, bytes]:
|
||||
return self.stdout, self.stderr
|
||||
|
||||
|
||||
class SubprocessRecorder:
|
||||
def __init__(self, outcomes: Sequence[FakeProcess | OSError]) -> None:
|
||||
self.outcomes = list(outcomes)
|
||||
self.calls: list[tuple[tuple[Any, ...], dict[str, Any]]] = []
|
||||
|
||||
async def __call__(self, *args: Any, **kwargs: Any) -> FakeProcess:
|
||||
self.calls.append((args, kwargs))
|
||||
outcome = self.outcomes.pop(0)
|
||||
if isinstance(outcome, OSError):
|
||||
raise outcome
|
||||
return outcome
|
||||
|
||||
@property
|
||||
def commands(self) -> list[tuple[Any, ...]]:
|
||||
return [args for args, _ in self.calls]
|
||||
|
||||
|
||||
def git_client(tmp_path: Path) -> Git:
|
||||
return Git(
|
||||
gitea_url="https://git.example.test/",
|
||||
username="agent-user",
|
||||
token="secret-token",
|
||||
askpass_path=tmp_path / "askpass.sh",
|
||||
commit_name="Agent CI",
|
||||
commit_email="agent@example.test",
|
||||
)
|
||||
|
||||
|
||||
def install_recorder(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
outcomes: Sequence[FakeProcess | OSError],
|
||||
) -> SubprocessRecorder:
|
||||
recorder = SubprocessRecorder(outcomes)
|
||||
monkeypatch.setattr(asyncio, "create_subprocess_exec", recorder)
|
||||
return recorder
|
||||
|
||||
|
||||
async def test_clone_uses_authenticated_remote_and_returns_head(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
for name in (
|
||||
"GIT_ASKPASS",
|
||||
"GIT_TERMINAL_PROMPT",
|
||||
"AGENTCI_GIT_USERNAME",
|
||||
"AGENTCI_GIT_PASSWORD",
|
||||
):
|
||||
monkeypatch.delenv(name, raising=False)
|
||||
recorder = install_recorder(
|
||||
monkeypatch,
|
||||
[FakeProcess(), FakeProcess(stdout=b"abc123\n")],
|
||||
)
|
||||
destination = tmp_path / "workspaces" / "repo"
|
||||
|
||||
sha = await git_client(tmp_path).clone("org", "repo", "main", destination)
|
||||
|
||||
assert sha == "abc123"
|
||||
assert destination.parent.is_dir()
|
||||
assert recorder.commands == [
|
||||
(
|
||||
"git",
|
||||
"clone",
|
||||
"--branch",
|
||||
"main",
|
||||
"--single-branch",
|
||||
"https://git.example.test/org/repo.git",
|
||||
str(destination),
|
||||
),
|
||||
("git", "rev-parse", "HEAD"),
|
||||
]
|
||||
clone_kwargs = recorder.calls[0][1]
|
||||
assert clone_kwargs["cwd"] == destination.parent
|
||||
assert clone_kwargs["stdout"] is asyncio.subprocess.PIPE
|
||||
assert clone_kwargs["stderr"] is asyncio.subprocess.PIPE
|
||||
assert (
|
||||
clone_kwargs["env"]
|
||||
| {
|
||||
"GIT_ASKPASS": str(tmp_path / "askpass.sh"),
|
||||
"GIT_TERMINAL_PROMPT": "0",
|
||||
"AGENTCI_GIT_USERNAME": "agent-user",
|
||||
"AGENTCI_GIT_PASSWORD": "secret-token",
|
||||
}
|
||||
== clone_kwargs["env"]
|
||||
)
|
||||
current_sha_environment = recorder.calls[1][1]["env"]
|
||||
assert "AGENTCI_GIT_PASSWORD" not in current_sha_environment
|
||||
|
||||
|
||||
async def test_sync_branch_resets_and_cleans_before_returning_sha(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
recorder = install_recorder(
|
||||
monkeypatch,
|
||||
[FakeProcess(), FakeProcess(), FakeProcess(), FakeProcess(stdout=b"new-sha\n")],
|
||||
)
|
||||
workspace = tmp_path / "repo"
|
||||
|
||||
sha = await git_client(tmp_path).sync_branch(workspace, "feature")
|
||||
|
||||
assert sha == "new-sha"
|
||||
assert recorder.commands == [
|
||||
(
|
||||
"git",
|
||||
"fetch",
|
||||
"origin",
|
||||
"refs/heads/feature:refs/remotes/origin/feature",
|
||||
),
|
||||
("git", "reset", "--hard", "origin/feature"),
|
||||
("git", "clean", "-fd"),
|
||||
("git", "rev-parse", "HEAD"),
|
||||
]
|
||||
assert all(call[1]["cwd"] == workspace for call in recorder.calls)
|
||||
assert recorder.calls[0][1]["env"]["AGENTCI_GIT_PASSWORD"] == "secret-token"
|
||||
|
||||
|
||||
async def test_branch_status_and_diff_commands(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
recorder = install_recorder(
|
||||
monkeypatch,
|
||||
[FakeProcess(), FakeProcess(stdout=b" M src/app.py\n"), FakeProcess()],
|
||||
)
|
||||
workspace = tmp_path / "repo"
|
||||
git = git_client(tmp_path)
|
||||
|
||||
await git.create_branch(workspace, "agent/issue-1")
|
||||
changed = await git.has_changes(workspace)
|
||||
await git.diff_check(workspace)
|
||||
|
||||
assert changed is True
|
||||
assert recorder.commands == [
|
||||
("git", "switch", "-c", "agent/issue-1"),
|
||||
("git", "status", "--porcelain"),
|
||||
("git", "diff", "--check"),
|
||||
]
|
||||
|
||||
|
||||
async def test_has_changes_is_false_for_clean_status(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
install_recorder(monkeypatch, [FakeProcess(stdout=b"\n")])
|
||||
|
||||
assert await git_client(tmp_path).has_changes(tmp_path / "repo") is False
|
||||
|
||||
|
||||
async def test_commit_stages_all_changes_sets_identity_and_returns_sha(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
recorder = install_recorder(
|
||||
monkeypatch,
|
||||
[FakeProcess(), FakeProcess(), FakeProcess(stdout=b"commit-sha\n")],
|
||||
)
|
||||
workspace = tmp_path / "repo"
|
||||
|
||||
sha = await git_client(tmp_path).commit(workspace, "agent: Fix widget")
|
||||
|
||||
assert sha == "commit-sha"
|
||||
assert recorder.commands == [
|
||||
("git", "add", "-A"),
|
||||
(
|
||||
"git",
|
||||
"-c",
|
||||
"user.name=Agent CI",
|
||||
"-c",
|
||||
"user.email=agent@example.test",
|
||||
"commit",
|
||||
"-m",
|
||||
"agent: Fix widget",
|
||||
),
|
||||
("git", "rev-parse", "HEAD"),
|
||||
]
|
||||
|
||||
|
||||
async def test_push_command_supports_initial_and_existing_branches(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
recorder = install_recorder(monkeypatch, [FakeProcess(), FakeProcess()])
|
||||
workspace = tmp_path / "repo"
|
||||
git = git_client(tmp_path)
|
||||
|
||||
await git.push(workspace, "agent/new", set_upstream=True)
|
||||
await git.push(workspace, "agent/existing")
|
||||
|
||||
assert recorder.commands == [
|
||||
("git", "push", "--set-upstream", "origin", "agent/new"),
|
||||
("git", "push", "origin", "HEAD:agent/existing"),
|
||||
]
|
||||
assert all(call[1]["env"]["GIT_TERMINAL_PROMPT"] == "0" for call in recorder.calls)
|
||||
|
||||
|
||||
async def test_git_translates_process_start_failure(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
install_recorder(monkeypatch, [OSError("git executable missing")])
|
||||
|
||||
with pytest.raises(GitError, match="Could not run git rev-parse") as error:
|
||||
await git_client(tmp_path).current_sha(tmp_path / "repo")
|
||||
|
||||
assert isinstance(error.value.__cause__, OSError)
|
||||
|
||||
|
||||
async def test_git_translates_nonzero_exit_and_stderr(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
install_recorder(
|
||||
monkeypatch,
|
||||
[FakeProcess(stderr=b"fatal: invalid diff\n", returncode=2)],
|
||||
)
|
||||
|
||||
with pytest.raises(GitError, match="git diff failed: fatal: invalid diff"):
|
||||
await git_client(tmp_path).diff_check(tmp_path / "repo")
|
||||
|
||||
|
||||
async def test_commit_failure_identifies_commit_operation(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
install_recorder(
|
||||
monkeypatch,
|
||||
[FakeProcess(), FakeProcess(stderr=b"nothing to commit\n", returncode=1)],
|
||||
)
|
||||
|
||||
with pytest.raises(GitError, match="git commit failed: nothing to commit"):
|
||||
await git_client(tmp_path).commit(tmp_path / "repo", "agent: change")
|
||||
Reference in New Issue
Block a user