From d6bc634da6f2518ac615295f10530cc84e8ec4ef Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 20 Jun 2026 09:13:40 -0500 Subject: [PATCH 1/7] pytest_plugin(fix[fixtures]): Import cleanly under pytest 9.1 why: pytest 9.1 rejects marks applied to fixture functions and raises the error at plugin import, before collection. Because libvcs ships this plugin as an installed pytest11 entry point, the error aborted the entire test session for any downstream project (e.g. vcspull) running pytest 9.1+. The skipif marks stacked on the fixtures were no-ops in every prior pytest version; the real gating must run inside each fixture so a missing binary skips instead of erroring. what: - Add private _skip_if_git_missing / _skip_if_svn_missing / _skip_if_hg_missing helpers that call pytest.skip() when the binary is absent; the svn helper requires both svn and svnadmin - Remove the @skip_if_*_missing decorators from the affected fixtures and call the matching helper as the first body statement - Fold empty_svn_repo's existing inline svn/svnadmin guard into the shared helper - Keep the public skip_if_*_missing marks: they remain valid for decorating test functions and importable by downstream suites --- src/libvcs/pytest_plugin.py | 54 +++++++++++++++++++++++-------------- 1 file changed, 34 insertions(+), 20 deletions(-) diff --git a/src/libvcs/pytest_plugin.py b/src/libvcs/pytest_plugin.py index d6963c42..40c0f181 100644 --- a/src/libvcs/pytest_plugin.py +++ b/src/libvcs/pytest_plugin.py @@ -43,6 +43,24 @@ def __init__(self, attempts: int, *args: object) -> None: ) +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 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" @@ -145,13 +163,13 @@ def set_home( @pytest.fixture(scope="session") -@skip_if_git_missing def vcs_gitconfig( user_path: pathlib.Path, vcs_email: str, vcs_name: str, ) -> pathlib.Path: """Return git configuration, pytest fixture.""" + _skip_if_git_missing() gitconfig = user_path / ".gitconfig" gitconfig.write_text( @@ -173,24 +191,24 @@ def vcs_gitconfig( @pytest.fixture -@skip_if_git_missing def set_vcs_gitconfig( monkeypatch: pytest.MonkeyPatch, vcs_gitconfig: pathlib.Path, ) -> pathlib.Path: """Set git configuration.""" + _skip_if_git_missing() monkeypatch.setenv("GIT_CONFIG", str(vcs_gitconfig)) monkeypatch.setenv("GIT_CONFIG_GLOBAL", str(vcs_gitconfig)) # For child processes return vcs_gitconfig @pytest.fixture(scope="session") -@skip_if_hg_missing def vcs_hgconfig( user_path: pathlib.Path, vcs_user: str, ) -> pathlib.Path: """Return Mercurial configuration.""" + _skip_if_hg_missing() hgrc = user_path / ".hgrc" hgrc.write_text( textwrap.dedent( @@ -209,12 +227,12 @@ def vcs_hgconfig( @pytest.fixture -@skip_if_hg_missing def set_vcs_hgconfig( monkeypatch: pytest.MonkeyPatch, vcs_hgconfig: pathlib.Path, ) -> pathlib.Path: """Set Mercurial configuration.""" + _skip_if_hg_missing() monkeypatch.setenv("HGRCPATH", str(vcs_hgconfig)) return vcs_hgconfig @@ -338,11 +356,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() @@ -357,11 +375,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 @@ -373,12 +391,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, @@ -402,12 +420,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, @@ -455,13 +473,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( @@ -519,15 +537,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 @@ -540,12 +554,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, @@ -572,20 +586,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 @@ -629,11 +643,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 @@ -645,13 +659,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, @@ -681,13 +695,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, From 7dda739e766e90e24a9ac561b559cb023a1a28a9 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 20 Jun 2026 09:15:05 -0500 Subject: [PATCH 2/7] docs(CHANGES): pytest 9.1 compatibility for the pytest plugin why: Tell downstream users that the plugin now loads on pytest 9.1+, so they can upgrade pytest without the session aborting before collection. what: - Add a Fixes entry under the 0.43.x unreleased block --- CHANGES | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGES b/CHANGES index 50e76414..8e9ca71b 100644 --- a/CHANGES +++ b/CHANGES @@ -20,6 +20,12 @@ $ uv add libvcs --prerelease allow _Notes on the upcoming release will go here._ +### Fixes + +#### pytest 9.1 compatibility for the pytest plugin (#537) + +libvcs's pytest plugin (see {ref}`pytest_plugin`) now imports cleanly under pytest 9.1, which began rejecting marks applied to fixture functions. The binary-availability checks that decide whether to skip Git, Mercurial, or Subversion fixtures now run inside each fixture, so projects that use libvcs's fixtures can upgrade to pytest 9.1+ without the plugin aborting before test collection. + ## 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. From 6c3377ea8e6dad0a7dd4f0f587dc033521d484d0 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 20 Jun 2026 09:38:28 -0500 Subject: [PATCH 3/7] docs(CHANGES): Describe pytest 9.1 fix in user terms why: The entry narrated implementation mechanism (how the checks moved, that pytest 9.1 rejects marks on fixtures) -- diff narration in a shipped artifact per AGENTS.md AI Slop Prevention -- and scoped the impact to fixture users. The plugin loads at pytest startup for any project with libvcs installed, so the abort reaches more than fixture users. what: - Drop the mechanism clauses; keep the user-visible outcome - Broaden the audience to any project with libvcs installed --- CHANGES | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGES b/CHANGES index 8e9ca71b..4adad80a 100644 --- a/CHANGES +++ b/CHANGES @@ -24,7 +24,7 @@ _Notes on the upcoming release will go here._ #### pytest 9.1 compatibility for the pytest plugin (#537) -libvcs's pytest plugin (see {ref}`pytest_plugin`) now imports cleanly under pytest 9.1, which began rejecting marks applied to fixture functions. The binary-availability checks that decide whether to skip Git, Mercurial, or Subversion fixtures now run inside each fixture, so projects that use libvcs's fixtures can upgrade to pytest 9.1+ without the plugin aborting before test collection. +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) From f7a29f016df8bc46c7299fcffdd21042e4e61784 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 20 Jun 2026 09:40:19 -0500 Subject: [PATCH 4/7] pytest_plugin(fix[fixtures]): Don't gate config fixtures on the VCS binary why: vcs_gitconfig, set_vcs_gitconfig, vcs_hgconfig and set_vcs_hgconfig only write a .gitconfig / .hgrc or set env vars -- they never invoke git or hg. Guarding them on the binary made them skip when it was absent. Because conftest.py's autouse `setup` fixture depends on vcs_gitconfig, a host without git would skip the ENTIRE suite, including svn- and hg-only tests that don't need git at all. The binary-invoking fixtures (empty_git_repo, git_remote_repo, ...) keep their guards, so tests that actually run a binary still skip correctly. what: - Remove _skip_if_git_missing() from vcs_gitconfig and set_vcs_gitconfig - Remove _skip_if_hg_missing() from vcs_hgconfig and set_vcs_hgconfig --- src/libvcs/pytest_plugin.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/libvcs/pytest_plugin.py b/src/libvcs/pytest_plugin.py index 40c0f181..eb8613b0 100644 --- a/src/libvcs/pytest_plugin.py +++ b/src/libvcs/pytest_plugin.py @@ -169,7 +169,6 @@ def vcs_gitconfig( vcs_name: str, ) -> pathlib.Path: """Return git configuration, pytest fixture.""" - _skip_if_git_missing() gitconfig = user_path / ".gitconfig" gitconfig.write_text( @@ -196,7 +195,6 @@ def set_vcs_gitconfig( vcs_gitconfig: pathlib.Path, ) -> pathlib.Path: """Set git configuration.""" - _skip_if_git_missing() monkeypatch.setenv("GIT_CONFIG", str(vcs_gitconfig)) monkeypatch.setenv("GIT_CONFIG_GLOBAL", str(vcs_gitconfig)) # For child processes return vcs_gitconfig @@ -208,7 +206,6 @@ def vcs_hgconfig( vcs_user: str, ) -> pathlib.Path: """Return Mercurial configuration.""" - _skip_if_hg_missing() hgrc = user_path / ".hgrc" hgrc.write_text( textwrap.dedent( @@ -232,7 +229,6 @@ def set_vcs_hgconfig( vcs_hgconfig: pathlib.Path, ) -> pathlib.Path: """Set Mercurial configuration.""" - _skip_if_hg_missing() monkeypatch.setenv("HGRCPATH", str(vcs_hgconfig)) return vcs_hgconfig From c83eb532f4af62b709362d5e2d5a0468ee2c368c Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 20 Jun 2026 09:43:09 -0500 Subject: [PATCH 5/7] pytest_plugin(fix[add_doctest_fixtures]): Request VCS fixtures lazily why: add_doctest_fixtures declared the per-VCS repo factories (create_git_remote_repo, create_svn_remote_repo, create_hg_remote_repo, git_repo) as parameters, so pytest instantiated all of them before the body's `if shutil.which(...)` guards ran. Each factory pulls in a binary-invoking fixture, so a single missing binary (e.g. hg) skipped the whole fixture and therefore every doctest -- including doctests for the binaries that were present. what: - Drop the per-VCS fixtures from the signature; request them via request.getfixturevalue() inside each `if shutil.which(...)` block - A missing binary now drops only that VCS's doctest helpers; doctests for the present binaries still run --- src/libvcs/pytest_plugin.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/libvcs/pytest_plugin.py b/src/libvcs/pytest_plugin.py index eb8613b0..9dc538cc 100644 --- a/src/libvcs/pytest_plugin.py +++ b/src/libvcs/pytest_plugin.py @@ -800,12 +800,6 @@ 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 @@ -813,7 +807,11 @@ def add_doctest_fixtures( 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( @@ -823,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, From 9c22eeb25d2f9271e247e3a74fe7de127ddf255e Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 20 Jun 2026 09:44:02 -0500 Subject: [PATCH 6/7] pytest_plugin(fix[skip_if_svn_missing]): Require svnadmin too why: The svn fixtures build repositories with `svnadmin`, and _skip_if_svn_missing() treats svn as available only when both the svn client and svnadmin are present. The public skip_if_svn_missing mark and the pytest_ignore_collect hook checked the svn client alone, so on a host with svn but not svnadmin they disagreed with the fixtures: a mark-decorated test would run, and svn doctest modules would be collected while their create_svn_remote_repo namespace helper -- which also requires svnadmin -- was withheld, erroring with NameError instead of skipping. Make every svn-availability check require svnadmin. what: - Add the svnadmin check to the skip_if_svn_missing mark condition - Require svnadmin in pytest_ignore_collect so svn modules are ignored (not collected) when only the svn client is present --- src/libvcs/pytest_plugin.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/libvcs/pytest_plugin.py b/src/libvcs/pytest_plugin.py index 9dc538cc..fe85b968 100644 --- a/src/libvcs/pytest_plugin.py +++ b/src/libvcs/pytest_plugin.py @@ -34,8 +34,8 @@ 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"), @@ -52,7 +52,7 @@ def _skip_if_git_missing() -> None: 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 is not available") + pytest.skip(reason="svn or svnadmin is not available") def _skip_if_hg_missing() -> None: @@ -121,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 From 190dcbbd540777cdc8be7e52bfd5b19b9b727827 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 20 Jun 2026 09:59:42 -0500 Subject: [PATCH 7/7] cmd/git(fix[set_head]): Make set_head doctest git-version-agnostic why: git 2.54 changed `git remote set-head` output: when origin/HEAD already points at the branch it prints "'origin/HEAD' is unchanged and points to 'master'" instead of "origin/HEAD set to master". The doctest asserted the older literal string, so it failed under the git on CI. The failure is independent of the pytest 9.1 fixture changes -- it was latent until the plugin could import and the suite actually ran. what: - Assert version-stable invariants instead of the literal message: `'master' in set_head(auto=True)` and that `set_head('master')` returns a str --- src/libvcs/cmd/git.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/libvcs/cmd/git.py b/src/libvcs/cmd/git.py index 1bdb0890..fbad217e 100644 --- a/src/libvcs/cmd/git.py +++ b/src/libvcs/cmd/git.py @@ -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]