blob: 96eac8c169c068d0a80f2d3fa334b3f288bce4d0 [file]
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
import os
import subprocess
from pathlib import Path
import pytest
from magpie_vcs import (
BACKENDS,
FossilBackend,
GitBackend,
MercurialBackend,
SubversionBackend,
VCSError,
detect_backend,
get_backend,
main,
)
# Tests shell out to `git` in temp repos. If the process inherits
# location-redirecting git env vars (e.g. when the suite runs inside a git
# pre-commit hook), git would operate on the outer repo instead. Strip them
# for the whole module so the fixture's own `git init` is unaffected; the tool
# itself does the same scrub at runtime (see _clean_env).
for _var in ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_COMMON_DIR", "GIT_PREFIX"):
os.environ.pop(_var, None)
git_required = pytest.mark.skipif(not GitBackend.is_available(), reason="git not installed")
hg_required = pytest.mark.skipif(not MercurialBackend.is_available(), reason="hg not installed")
def _git(repo: Path, *args: str) -> None:
subprocess.run(["git", *args], cwd=repo, check=True, capture_output=True)
def _hg(repo: Path, *args: str) -> None:
subprocess.run(["hg", *args], cwd=repo, check=True, capture_output=True)
@pytest.fixture
def git_repo(tmp_path: Path) -> Path:
repo = tmp_path / "repo"
repo.mkdir()
_git(repo, "init", "-q", "-b", "main")
_git(repo, "config", "user.email", "t@example.com")
_git(repo, "config", "user.name", "Tester")
_git(repo, "config", "commit.gpgsign", "false")
(repo / "file.txt").write_text("hello\n")
_git(repo, "add", "file.txt")
_git(repo, "commit", "-q", "-m", "initial commit")
return repo
@pytest.fixture
def hg_repo(tmp_path: Path) -> Path:
repo = tmp_path / "repo_hg"
repo.mkdir()
_hg(repo, "init")
with open(repo / ".hg" / "hgrc", "w") as f:
f.write("[ui]\nusername = Tester <t@example.com>\n")
(repo / "file.txt").write_text("hello\n")
_hg(repo, "add", "file.txt")
_hg(repo, "commit", "-m", "initial commit")
return repo
# -- detection -------------------------------------------------------------
def test_detect_git(git_repo: Path) -> None:
backend = detect_backend(git_repo)
assert backend is not None
assert backend.name == "git"
assert backend.root == git_repo
def test_detect_marks_hg_svn_fossil(tmp_path: Path) -> None:
(tmp_path / ".hg").mkdir()
backend = detect_backend(tmp_path)
assert isinstance(backend, MercurialBackend)
svn = tmp_path / "svn_wc"
svn.mkdir()
(svn / ".svn").mkdir()
assert isinstance(detect_backend(svn), SubversionBackend)
fossil_dir = tmp_path / "fossil_wc"
fossil_dir.mkdir()
(fossil_dir / ".fslckg").touch()
assert isinstance(detect_backend(fossil_dir), FossilBackend)
fossil_dir2 = tmp_path / "fossil_wc_win"
fossil_dir2.mkdir()
(fossil_dir2 / "_FOSSIL_").touch()
assert isinstance(detect_backend(fossil_dir2), FossilBackend)
def test_detect_none(tmp_path: Path) -> None:
assert detect_backend(tmp_path) is None
def test_nested_innermost_wins(git_repo: Path) -> None:
inner = git_repo / "inner"
inner.mkdir()
(inner / ".hg").mkdir()
backend = detect_backend(inner)
assert isinstance(backend, MercurialBackend)
assert backend.root == inner
def test_get_backend_override(git_repo: Path) -> None:
assert get_backend(git_repo, override="git").name == "git"
with pytest.raises(VCSError, match="unknown backend"):
get_backend(git_repo, override="bzr")
def test_get_backend_env(git_repo: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("MAGPIE_VCS", "hg")
assert get_backend(git_repo).name == "hg"
def test_get_backend_no_repo(tmp_path: Path) -> None:
with pytest.raises(VCSError, match="no supported VCS"):
get_backend(tmp_path)
# -- git backend operations ------------------------------------------------
@git_required
def test_git_clean_then_dirty(git_repo: Path) -> None:
backend = GitBackend(git_repo)
assert backend.is_clean()
(git_repo / "file.txt").write_text("changed\n")
assert not backend.is_clean()
assert "file.txt" in backend.status()
@git_required
def test_git_branch_and_commit(git_repo: Path) -> None:
backend = GitBackend(git_repo)
assert backend.current_branch() == "main"
backend.create_branch("fix/thing")
assert backend.current_branch() == "fix/thing"
(git_repo / "new.txt").write_text("x\n")
backend.stage(["new.txt"])
assert "new.txt" in backend.diff(cached=True)
backend.commit("add new.txt")
assert backend.is_clean()
assert "add new.txt" in backend.log(max_count=1)
@git_required
def test_git_ignores_inherited_git_dir(git_repo: Path, monkeypatch: pytest.MonkeyPatch) -> None:
# Simulate running inside an outer git context (e.g. a pre-commit hook):
# the tool must resolve the repo from its cwd, not from $GIT_DIR.
monkeypatch.setenv("GIT_DIR", str(git_repo.parent / "elsewhere" / ".git"))
monkeypatch.setenv("GIT_INDEX_FILE", str(git_repo.parent / "elsewhere" / "index"))
backend = GitBackend(git_repo)
assert backend.current_branch() == "main"
assert backend.is_clean()
@git_required
def test_git_log_grep(git_repo: Path) -> None:
backend = GitBackend(git_repo)
assert "initial commit" in backend.log(grep="initial")
assert backend.log(grep="nonexistent-token").strip() == ""
@git_required
def test_git_stage_nothing_rejected(git_repo: Path) -> None:
with pytest.raises(VCSError, match="refusing to stage nothing"):
GitBackend(git_repo).stage([])
@git_required
def test_git_reset_worktree(git_repo: Path) -> None:
backend = GitBackend(git_repo)
(git_repo / "file.txt").write_text("dirty\n")
(git_repo / "untracked.txt").write_text("junk\n")
backend.reset_worktree()
assert backend.is_clean()
assert not (git_repo / "untracked.txt").exists()
assert (git_repo / "file.txt").read_text() == "hello\n"
@git_required
def test_git_cat(git_repo: Path) -> None:
backend = GitBackend(git_repo)
assert backend.cat("HEAD", "file.txt") == "hello\n"
# -- unimplemented backends ------------------------------------------------
def test_unimplemented_raise_with_issue(tmp_path: Path) -> None:
svn = SubversionBackend(tmp_path)
with pytest.raises(VCSError, match=r"apache/magpie#602"):
svn.commit("x")
assert svn.distributed is False # centralized model flagged
# -- hg backend operations -------------------------------------------------
@hg_required
def test_hg_clean_then_dirty(hg_repo: Path) -> None:
backend = MercurialBackend(hg_repo)
assert backend.is_clean()
(hg_repo / "file.txt").write_text("changed\n")
assert not backend.is_clean()
assert "file.txt" in backend.status()
@hg_required
def test_hg_bookmark_and_commit(hg_repo: Path) -> None:
backend = MercurialBackend(hg_repo)
assert backend.current_branch() == "default"
backend.create_branch("fix-bookmark")
assert backend.current_branch() == "fix-bookmark"
(hg_repo / "new.txt").write_text("x\n")
backend.stage(["new.txt"])
assert "new.txt" in backend.diff()
backend.commit("add new.txt")
assert backend.is_clean()
assert "add new.txt" in backend.log(max_count=1)
@hg_required
def test_hg_cached_diff_raises(hg_repo: Path) -> None:
backend = MercurialBackend(hg_repo)
with pytest.raises(VCSError, match="does not support staging area"):
backend.diff(cached=True)
@hg_required
def test_hg_reset_worktree(hg_repo: Path) -> None:
backend = MercurialBackend(hg_repo)
(hg_repo / ".hgignore").write_text("ignored.txt\n")
backend.stage([".hgignore"])
backend.commit("add hgignore")
(hg_repo / "file.txt").write_text("dirty\n")
(hg_repo / "untracked.txt").write_text("junk\n")
(hg_repo / "ignored.txt").write_text("ignored\n")
backend.reset_worktree()
assert backend.is_clean()
assert not (hg_repo / "untracked.txt").exists()
assert (hg_repo / "file.txt").read_text() == "hello\n"
assert (hg_repo / "ignored.txt").exists() # ignored files should be preserved
def test_registry_unique_names() -> None:
names = [b.name for b in BACKENDS]
assert names == sorted(set(names), key=names.index)
assert "git" in names
# -- CLI -------------------------------------------------------------------
@git_required
def test_cli_detect_and_status(git_repo: Path, capsys: pytest.CaptureFixture[str]) -> None:
assert main(["-C", str(git_repo), "detect"]) == 0
assert capsys.readouterr().out.strip() == "git"
(git_repo / "file.txt").write_text("z\n")
assert main(["-C", str(git_repo), "clean"]) == 1
main(["-C", str(git_repo), "reset-worktree"])
assert main(["-C", str(git_repo), "clean"]) == 0
def test_cli_backends_lists_all(capsys: pytest.CaptureFixture[str]) -> None:
assert main(["backends"]) == 0
out = capsys.readouterr().out
for name in ("git", "hg", "svn", "fossil"):
assert name in out
def test_cli_unknown_backend_errors(git_repo: Path, capsys: pytest.CaptureFixture[str]) -> None:
assert main(["-C", str(git_repo), "--backend", "bzr", "status"]) == 2
assert "unknown backend" in capsys.readouterr().err
def test_cli_unimplemented_backend_errors(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
(tmp_path / ".svn").mkdir()
assert main(["-C", str(tmp_path), "status"]) == 2
assert "apache/magpie#602" in capsys.readouterr().err
@git_required
def test_cli_cat(git_repo: Path, capsys: pytest.CaptureFixture[str]) -> None:
assert main(["-C", str(git_repo), "cat", "HEAD", "file.txt"]) == 0
assert capsys.readouterr().out == "hello\n"
def test_fossil_parser_status(monkeypatch: pytest.MonkeyPatch) -> None:
def mock_run(args: list[str], cwd: str | None = None, **kwargs: object) -> str:
if "changes" in args:
return "EDITED file1.txt\nADDED file2.txt\nDELETED file3.txt\n"
if "extras" in args:
return "untracked1.txt\nuntracked2.txt\n"
return ""
monkeypatch.setattr("magpie_vcs._run", mock_run)
backend = FossilBackend(Path("/tmp/repo"))
status_out = backend.status()
assert "M file1.txt" in status_out
assert "A file2.txt" in status_out
assert "D file3.txt" in status_out
assert "? untracked1.txt" in status_out
assert "? untracked2.txt" in status_out
def test_fossil_parser_status_error(monkeypatch: pytest.MonkeyPatch) -> None:
def mock_run(args: list[str], cwd: str | None = None, **kwargs: object) -> str:
raise VCSError("command failed")
monkeypatch.setattr("magpie_vcs._run", mock_run)
backend = FossilBackend(Path("/tmp/repo"))
assert backend.status() == ""
def test_fossil_parser_log(monkeypatch: pytest.MonkeyPatch) -> None:
sample_timeline = """
=== 2026-07-08 ===
17:00:00 [a1b2c3d4e5f6] Initial commit (user: arnav tags: trunk)
16:30:00 [f6e5d4c3b2a1] Fix a bug in forum post rendering (user: jsmith tags: trunk)
16:00:00 [1234567890ab] Add a new feature [options] (user: alice tags: trunk)
"""
def mock_run(args: list[str], cwd: str | None = None, **kwargs: object) -> str:
if "timeline" in args:
return sample_timeline
return ""
monkeypatch.setattr("magpie_vcs._run", mock_run)
backend = FossilBackend(Path("/tmp/repo"))
# Test basic log
log_out = backend.log()
assert "a1b2c3d4e5f6 Initial commit" in log_out
assert "f6e5d4c3b2a1 Fix a bug in forum post rendering" in log_out
assert "1234567890ab Add a new feature [options]" in log_out
# Test grep search
grep_out = backend.log(grep="bug")
assert "f6e5d4c3b2a1 Fix a bug in forum post rendering" in grep_out
assert "a1b2c3d4e5f6" not in grep_out