Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGES
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ $ uv add libvcs --prerelease allow
_Notes on the upcoming release will go here._
<!-- END PLACEHOLDER - ADD NEW CHANGELOG ENTRIES BELOW THIS LINE -->

### Fixes

#### pytest 9.1 compatibility for the pytest plugin (#537)

libvcs's pytest plugin (see {ref}`pytest_plugin`) loads automatically whenever pytest runs, so any project with libvcs installed can now use pytest 9.1+ — the plugin no longer aborts the test session at startup. Its Git, Mercurial, and Subversion fixtures upgrade cleanly.

## libvcs 0.42.0 (2026-05-31)

libvcs 0.42.0 teaches {class}`~libvcs.sync.git.GitSync` to clone at an arbitrary shallow depth rather than only a single commit, so tools that synchronize many repositories can persist and apply a numeric `--depth N` instead of a boolean shallow flag. It also repairs a long-standing bug where the `git_shallow` and `tls_verify` constructor arguments were silently dropped and then raised `AttributeError` on the next {meth}`~libvcs.sync.git.GitSync.obtain`. Downstream tools such as vcspull are the primary beneficiaries.
Expand Down
18 changes: 10 additions & 8 deletions src/libvcs/cmd/git.py
Original file line number Diff line number Diff line change
Expand Up @@ -4094,17 +4094,19 @@ def set_head(

Examples
--------
>>> GitRemoteCmd(
>>> remote = GitRemoteCmd(
... path=example_git_repo.path,
... remote_name='origin'
... ).set_head(auto=True)
'origin/HEAD set to master'
... )

>>> GitRemoteCmd(
... path=example_git_repo.path,
... remote_name='origin'
... ).set_head('master')
''
The exact message wording varies across git versions (git 2.54+ reports
"is unchanged" when HEAD already points at the branch), so assert on the
stable parts rather than the literal string:

>>> 'master' in remote.set_head(auto=True)
True
>>> isinstance(remote.set_head('master'), str)
True
"""
local_flags: list[str] = [self.remote_name]

Expand Down
73 changes: 42 additions & 31 deletions src/libvcs/pytest_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,15 +34,33 @@ def __init__(self, attempts: int, *args: object) -> None:
reason="git is not available",
)
skip_if_svn_missing = pytest.mark.skipif(
not shutil.which("svn"),
reason="svn is not available",
not shutil.which("svn") or not shutil.which("svnadmin"),
reason="svn or svnadmin is not available",
)
skip_if_hg_missing = pytest.mark.skipif(
not shutil.which("hg"),
reason="hg is not available",
)


def _skip_if_git_missing() -> None:
"""Skip the calling fixture when the ``git`` binary is unavailable."""
if not shutil.which("git"):
pytest.skip(reason="git is not available")


def _skip_if_svn_missing() -> None:
"""Skip the calling fixture when ``svn`` or ``svnadmin`` is unavailable."""
if not shutil.which("svn") or not shutil.which("svnadmin"):
pytest.skip(reason="svn or svnadmin is not available")


def _skip_if_hg_missing() -> None:
"""Skip the calling fixture when the ``hg`` binary is unavailable."""
if not shutil.which("hg"):
pytest.skip(reason="hg is not available")


DEFAULT_VCS_NAME = "Test user"
DEFAULT_VCS_EMAIL = "test@example.com"

Expand Down Expand Up @@ -103,7 +121,7 @@ def __next__(self) -> str:

def pytest_ignore_collect(collection_path: pathlib.Path, config: pytest.Config) -> bool:
"""Skip tests if VCS binaries are missing."""
if not shutil.which("svn") and any(
if (not shutil.which("svn") or not shutil.which("svnadmin")) and any(
needle in str(collection_path) for needle in ["svn", "subversion"]
):
return True
Expand Down Expand Up @@ -145,7 +163,6 @@ def set_home(


@pytest.fixture(scope="session")
@skip_if_git_missing
def vcs_gitconfig(
user_path: pathlib.Path,
vcs_email: str,
Expand Down Expand Up @@ -173,7 +190,6 @@ def vcs_gitconfig(


@pytest.fixture
@skip_if_git_missing
def set_vcs_gitconfig(
monkeypatch: pytest.MonkeyPatch,
vcs_gitconfig: pathlib.Path,
Expand All @@ -185,7 +201,6 @@ def set_vcs_gitconfig(


@pytest.fixture(scope="session")
@skip_if_hg_missing
def vcs_hgconfig(
user_path: pathlib.Path,
vcs_user: str,
Expand All @@ -209,7 +224,6 @@ def vcs_hgconfig(


@pytest.fixture
@skip_if_hg_missing
def set_vcs_hgconfig(
monkeypatch: pytest.MonkeyPatch,
vcs_hgconfig: pathlib.Path,
Expand Down Expand Up @@ -338,11 +352,11 @@ def empty_git_bare_repo_path(libvcs_test_cache_path: pathlib.Path) -> pathlib.Pa


@pytest.fixture(scope="session")
@skip_if_git_missing
def empty_git_bare_repo(
empty_git_bare_repo_path: pathlib.Path,
) -> pathlib.Path:
"""Return factory to create git remote repo to for clone / push purposes."""
_skip_if_git_missing()
if (
empty_git_bare_repo_path.exists()
and (empty_git_bare_repo_path / ".git").exists()
Expand All @@ -357,11 +371,11 @@ def empty_git_bare_repo(


@pytest.fixture(scope="session")
@skip_if_git_missing
def empty_git_repo(
empty_git_repo_path: pathlib.Path,
) -> pathlib.Path:
"""Return factory to create git remote repo to for clone / push purposes."""
_skip_if_git_missing()
if empty_git_repo_path.exists() and (empty_git_repo_path / ".git").exists():
return empty_git_repo_path

Expand All @@ -373,12 +387,12 @@ def empty_git_repo(


@pytest.fixture(scope="session")
@skip_if_git_missing
def create_git_remote_bare_repo(
remote_repos_path: pathlib.Path,
empty_git_bare_repo: pathlib.Path,
) -> CreateRepoFn:
"""Return factory to create git remote repo to for clone / push purposes."""
_skip_if_git_missing()

def fn(
remote_repos_path: pathlib.Path = remote_repos_path,
Expand All @@ -402,12 +416,12 @@ def fn(


@pytest.fixture(scope="session")
@skip_if_git_missing
def create_git_remote_repo(
remote_repos_path: pathlib.Path,
empty_git_repo: pathlib.Path,
) -> CreateRepoFn:
"""Return factory to create git remote repo to for clone / push purposes."""
_skip_if_git_missing()

def fn(
remote_repos_path: pathlib.Path = remote_repos_path,
Expand Down Expand Up @@ -455,13 +469,13 @@ def git_remote_repo_single_commit_post_init(


@pytest.fixture(scope="session")
@skip_if_git_missing
def git_remote_repo(
create_git_remote_repo: CreateRepoFn,
vcs_gitconfig: pathlib.Path,
git_commit_envvars: GitCommitEnvVars,
) -> pathlib.Path:
"""Copy the session-scoped Git repository to a temporary directory."""
_skip_if_git_missing()
# TODO: Cache the effect of of this in a session-based repo
repo_path = create_git_remote_repo()
git_remote_repo_single_commit_post_init(
Expand Down Expand Up @@ -519,15 +533,11 @@ def empty_svn_repo_path(libvcs_test_cache_path: pathlib.Path) -> pathlib.Path:


@pytest.fixture(scope="session")
@skip_if_svn_missing
def empty_svn_repo(
empty_svn_repo_path: pathlib.Path,
) -> pathlib.Path:
"""Return factory to create svn remote repo to for clone / push purposes."""
if not shutil.which("svn") or not shutil.which("svnadmin"):
pytest.skip(
reason="svn is not available",
)
_skip_if_svn_missing()

if empty_svn_repo_path.exists() and (empty_svn_repo_path / "conf").exists():
return empty_svn_repo_path
Expand All @@ -540,12 +550,12 @@ def empty_svn_repo(


@pytest.fixture(scope="session")
@skip_if_svn_missing
def create_svn_remote_repo(
remote_repos_path: pathlib.Path,
empty_svn_repo: pathlib.Path,
) -> CreateRepoFn:
"""Pre-made svn repo, bare, used as a file:// remote to checkout and commit to."""
_skip_if_svn_missing()

def fn(
remote_repos_path: pathlib.Path = remote_repos_path,
Expand All @@ -572,20 +582,20 @@ def fn(


@pytest.fixture(scope="session")
@skip_if_svn_missing
def svn_remote_repo(
create_svn_remote_repo: CreateRepoFn,
) -> pathlib.Path:
"""Pre-made. Local file:// based SVN server."""
_skip_if_svn_missing()
return create_svn_remote_repo()


@pytest.fixture(scope="session")
@skip_if_svn_missing
def svn_remote_repo_with_files(
create_svn_remote_repo: CreateRepoFn,
) -> pathlib.Path:
"""Pre-made. Local file:// based SVN server."""
_skip_if_svn_missing()
repo_path = create_svn_remote_repo()
svn_remote_repo_single_commit_post_init(remote_repo_path=repo_path)
return repo_path
Expand Down Expand Up @@ -629,11 +639,11 @@ def empty_hg_repo_path(libvcs_test_cache_path: pathlib.Path) -> pathlib.Path:


@pytest.fixture(scope="session")
@skip_if_hg_missing
def empty_hg_repo(
empty_hg_repo_path: pathlib.Path,
) -> pathlib.Path:
"""Return factory to create hg remote repo to for clone / push purposes."""
_skip_if_hg_missing()
if empty_hg_repo_path.exists() and (empty_hg_repo_path / ".hg").exists():
return empty_hg_repo_path

Expand All @@ -645,13 +655,13 @@ def empty_hg_repo(


@pytest.fixture(scope="session")
@skip_if_hg_missing
def create_hg_remote_repo(
remote_repos_path: pathlib.Path,
empty_hg_repo: pathlib.Path,
vcs_hgconfig: pathlib.Path,
) -> CreateRepoFn:
"""Pre-made hg repo, bare, used as a file:// remote to checkout and commit to."""
_skip_if_hg_missing()

def fn(
remote_repos_path: pathlib.Path = remote_repos_path,
Expand Down Expand Up @@ -681,13 +691,13 @@ def fn(


@pytest.fixture(scope="session")
@skip_if_hg_missing
def hg_remote_repo(
remote_repos_path: pathlib.Path,
create_hg_remote_repo: CreateRepoFn,
vcs_hgconfig: pathlib.Path,
) -> pathlib.Path:
"""Pre-made, file-based repo for push and pull."""
_skip_if_hg_missing()
repo_path = create_hg_remote_repo()
hg_remote_repo_single_commit_post_init(
remote_repo_path=repo_path,
Expand Down Expand Up @@ -790,20 +800,18 @@ def add_doctest_fixtures(
doctest_namespace: dict[str, t.Any],
tmp_path: pathlib.Path,
set_home: pathlib.Path,
git_commit_envvars: GitCommitEnvVars,
vcs_hgconfig: pathlib.Path,
create_git_remote_repo: CreateRepoFn,
create_svn_remote_repo: CreateRepoFn,
create_hg_remote_repo: CreateRepoFn,
git_repo: pathlib.Path,
) -> None:
"""Harness pytest fixtures to pytest's doctest namespace."""
from _pytest.doctest import DoctestItem

if not isinstance(request._pyfuncitem, DoctestItem): # Only run on doctest items
return
doctest_namespace["tmp_path"] = tmp_path
# Request the per-VCS fixtures lazily so a missing binary only drops that
# VCS's doctest helpers -- it does not skip doctests for the others.
if shutil.which("git"):
git_commit_envvars = request.getfixturevalue("git_commit_envvars")
create_git_remote_repo = request.getfixturevalue("create_git_remote_repo")
doctest_namespace["create_git_remote_repo"] = functools.partial(
create_git_remote_repo,
remote_repo_post_init=functools.partial(
Expand All @@ -813,14 +821,17 @@ def add_doctest_fixtures(
init_cmd_args=None,
)
doctest_namespace["create_git_remote_repo_bare"] = create_git_remote_repo
doctest_namespace["example_git_repo"] = git_repo
if shutil.which("svn"):
doctest_namespace["example_git_repo"] = request.getfixturevalue("git_repo")
if shutil.which("svn") and shutil.which("svnadmin"):
create_svn_remote_repo = request.getfixturevalue("create_svn_remote_repo")
doctest_namespace["create_svn_remote_repo_bare"] = create_svn_remote_repo
doctest_namespace["create_svn_remote_repo"] = functools.partial(
create_svn_remote_repo,
remote_repo_post_init=svn_remote_repo_single_commit_post_init,
)
if shutil.which("hg"):
vcs_hgconfig = request.getfixturevalue("vcs_hgconfig")
create_hg_remote_repo = request.getfixturevalue("create_hg_remote_repo")
doctest_namespace["create_hg_remote_repo_bare"] = create_hg_remote_repo
doctest_namespace["create_hg_remote_repo"] = functools.partial(
create_hg_remote_repo,
Expand Down