diff --git a/CHANGES b/CHANGES index 677cc75f..fbf7a191 100644 --- a/CHANGES +++ b/CHANGES @@ -20,6 +20,18 @@ $ uv add libvcs --prerelease allow _Notes on the upcoming release will go here._ +### What's new + +#### GitSync honors an arbitrary clone depth (#531) + +{class}`~libvcs.sync.git.GitSync` accepts a `depth` keyword argument that {meth}`~libvcs.sync.git.GitSync.obtain` forwards to `git clone --depth N`. When `depth` is unset the prior behavior is preserved: `git_shallow=True` clones at depth 1, and otherwise the clone is full. Downstream tools can now persist and apply a numeric shallow depth instead of only a boolean shallow flag. + +### Fixes + +#### GitSync honors `git_shallow` and `tls_verify` constructor arguments (#531) + +Passing `git_shallow=True` or `tls_verify=True` to {class}`~libvcs.sync.git.GitSync` left the matching attribute unset, so the next {meth}`~libvcs.sync.git.GitSync.obtain` raised `AttributeError`. Both are now accepted as keyword-only constructor arguments and applied when cloning. + ## libvcs 0.41.0 (2026-05-10) libvcs 0.41.0 is a pytest-plugin compatibility release. It renames libvcs's Git and Mercurial config fixtures so the plugin no longer occupies fixture names used by third-party pytest plugins, and it keeps the docs stack aligned with the current gp-sphinx theme pipeline. diff --git a/src/libvcs/_internal/shortcuts.py b/src/libvcs/_internal/shortcuts.py index a3e401d3..9cd8a94e 100644 --- a/src/libvcs/_internal/shortcuts.py +++ b/src/libvcs/_internal/shortcuts.py @@ -38,7 +38,7 @@ def create_project( path: StrPath, vcs: t.Literal["git"], progress_callback: ProgressCallbackProtocol | None = None, - **kwargs: dict[t.Any, t.Any], + **kwargs: t.Any, ) -> GitSync: ... @@ -49,7 +49,7 @@ def create_project( path: StrPath, vcs: t.Literal["svn"], progress_callback: ProgressCallbackProtocol | None = None, - **kwargs: dict[t.Any, t.Any], + **kwargs: t.Any, ) -> SvnSync: ... @@ -60,7 +60,7 @@ def create_project( path: StrPath, vcs: t.Literal["hg"], progress_callback: ProgressCallbackProtocol | None = ..., - **kwargs: dict[t.Any, t.Any], + **kwargs: t.Any, ) -> HgSync: ... @@ -71,7 +71,7 @@ def create_project( path: StrPath, vcs: None = None, progress_callback: ProgressCallbackProtocol | None = None, - **kwargs: dict[t.Any, t.Any], + **kwargs: t.Any, ) -> GitSync | HgSync | SvnSync: ... @@ -81,7 +81,7 @@ def create_project( path: StrPath, vcs: VCSLiteral | None = None, progress_callback: ProgressCallbackProtocol | None = None, - **kwargs: dict[t.Any, t.Any], + **kwargs: t.Any, ) -> GitSync | HgSync | SvnSync: r"""Return an object representation of a VCS repository. diff --git a/src/libvcs/sync/git.py b/src/libvcs/sync/git.py index 42d3f98f..416ec8f7 100644 --- a/src/libvcs/sync/git.py +++ b/src/libvcs/sync/git.py @@ -211,6 +211,9 @@ def __init__( url: str, path: StrPath, remotes: GitRemotesArgs = None, + git_shallow: bool = False, + tls_verify: bool = False, + depth: int | None = None, **kwargs: t.Any, ) -> None: """Local git repository. @@ -220,6 +223,15 @@ def __init__( url : str URL of repo + git_shallow : bool + Clone with history truncated to the latest commit (``--depth 1``, + default False) + + depth : int, optional + Clone with history truncated to ``depth`` commits + (``git clone --depth N``). Takes precedence over ``git_shallow``. + Default None (full clone). + tls_verify : bool Should certificate for https be checked (default False) @@ -258,10 +270,9 @@ def __init__( } ) """ - if "git_shallow" not in kwargs: - self.git_shallow = False - if "tls_verify" not in kwargs: - self.tls_verify = False + self.git_shallow = git_shallow + self.tls_verify = tls_verify + self.depth = depth self._remotes: GitSyncRemoteDict @@ -364,10 +375,19 @@ def obtain(self, *args: t.Any, **kwargs: t.Any) -> None: url = self.url self.log.info("Cloning.") + # An explicit depth wins; otherwise git_shallow keeps the depth-1 + # behavior, and neither means a full clone. + clone_depth: int | None + if self.depth is not None: + clone_depth = self.depth + elif self.git_shallow: + clone_depth = 1 + else: + clone_depth = None self.cmd.clone( url=url, progress=True, - depth=1 if self.git_shallow else None, + depth=clone_depth, config={"http.sslVerify": False} if self.tls_verify else None, log_in_real_time=True, ) @@ -392,6 +412,15 @@ def update_repo( ) -> SyncResult: """Pull latest changes from git remote. + .. todo:: + + Honor ``depth`` on update by deepening or unshallowing the existing + checkout when the requested depth differs from what is on disk. + Tracked in https://github.com/vcs-python/libvcs/issues/532. Edges to + handle: ``git fetch --depth N`` against a full checkout truncates it + to shallow, and ``git fetch --unshallow`` against a complete repo is + a fatal error (guard with ``git rev-parse --is-shallow-repository``). + Parameters ---------- set_remotes : bool diff --git a/tests/sync/test_git.py b/tests/sync/test_git.py index ecb253ce..7eb96502 100644 --- a/tests/sync/test_git.py +++ b/tests/sync/test_git.py @@ -3,6 +3,7 @@ from __future__ import annotations import datetime +import os import pathlib import random import shutil @@ -28,7 +29,7 @@ if t.TYPE_CHECKING: from pytest_mock import MockerFixture - from libvcs.pytest_plugin import CreateRepoFn + from libvcs.pytest_plugin import CreateRepoFn, GitCommitEnvVars if not shutil.which("git"): pytestmark = pytest.mark.skip(reason="git is not available") @@ -120,6 +121,116 @@ def test_repo_git_obtain_full( assert (tmp_path / "myrepo").exists() +def test_git_shallow_and_tls_verify_kwargs_are_honored( + tmp_path: pathlib.Path, + git_remote_repo: pathlib.Path, +) -> None: + """``git_shallow`` and ``tls_verify`` populate their attributes. + + Regression: each kwarg previously left its attribute unset, so the next + ``obtain()`` raised ``AttributeError``. + """ + # tls_verify reaches the attribute. Its clone-time ``config`` wiring is + # broken independently of this fix and tracked separately, so we don't + # drive a clone with it here. + assert ( + GitSync( + url=git_remote_repo.as_uri(), + path=tmp_path / "tls", + tls_verify=True, + ).tls_verify + is True + ) + + # git_shallow drives a depth-1 (shallow) clone in obtain(). + git_repo = GitSync( + url=git_remote_repo.as_uri(), + path=tmp_path / "myrepo", + git_shallow=True, + ) + assert git_repo.git_shallow is True + git_repo.obtain() + + is_shallow = run( + ["git", "rev-parse", "--is-shallow-repository"], + cwd=tmp_path / "myrepo", + ) + assert is_shallow == "true" + + +class DepthFixture(t.NamedTuple): + """Parameters for :func:`test_obtain_honors_clone_depth`.""" + + test_id: str + sync_kwargs: dict[str, t.Any] + expected_count: int + expected_shallow: bool + + +DEPTH_FIXTURES: list[DepthFixture] = [ + DepthFixture( + test_id="full-clone", + sync_kwargs={}, + expected_count=6, + expected_shallow=False, + ), + DepthFixture( + test_id="git_shallow-depth-1", + sync_kwargs={"git_shallow": True}, + expected_count=1, + expected_shallow=True, + ), + DepthFixture( + test_id="depth-3", + sync_kwargs={"depth": 3}, + expected_count=3, + expected_shallow=True, + ), + DepthFixture( + test_id="depth-overrides-git_shallow", + sync_kwargs={"git_shallow": True, "depth": 2}, + expected_count=2, + expected_shallow=True, + ), +] + + +@pytest.mark.parametrize( + list(DepthFixture._fields), + DEPTH_FIXTURES, + ids=[test.test_id for test in DEPTH_FIXTURES], +) +def test_obtain_honors_clone_depth( + tmp_path: pathlib.Path, + create_git_remote_repo: CreateRepoFn, + git_commit_envvars: GitCommitEnvVars, + test_id: str, + sync_kwargs: dict[str, t.Any], + expected_count: int, + expected_shallow: bool, +) -> None: + """obtain() clones at the requested depth; an explicit depth wins. + + The ``file://`` URL matters: git ignores ``--depth`` for local-path clones. + """ + remote_repo = create_git_remote_repo() + env = os.environ.copy() + env.update(git_commit_envvars) + for i in range(1, 7): + (remote_repo / "f.txt").write_text(str(i)) + run(["git", "add", "f.txt"], cwd=remote_repo, env=env) + run(["git", "commit", "-m", f"c{i}"], cwd=remote_repo, env=env) + + checkout = tmp_path / "checkout" + git_repo = GitSync(url=remote_repo.as_uri(), path=checkout, **sync_kwargs) + git_repo.obtain() + + commit_count = run(["git", "rev-list", "--count", "HEAD"], cwd=checkout) + is_shallow = run(["git", "rev-parse", "--is-shallow-repository"], cwd=checkout) + assert int(commit_count) == expected_count + assert is_shallow == ("true" if expected_shallow else "false") + + @pytest.mark.parametrize( # Postpone evaluation of options so fixture variables can interpolate ("constructor", "lazy_constructor_options"),