| # 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 |