diff --git a/src/scmrepo/git/__init__.py b/src/scmrepo/git/__init__.py index f33fede8..f7390a1c 100644 --- a/src/scmrepo/git/__init__.py +++ b/src/scmrepo/git/__init__.py @@ -135,11 +135,14 @@ def clone( url: str, to_path: str, rev: Optional[str] = None, + bare: bool = False, + mirror: bool = False, **kwargs, ): + assert not (rev and bare), "rev checkout is unsupported in bare repos" for _, backend in GitBackends.DEFAULT.items(): try: - backend.clone(url, to_path, **kwargs) + backend.clone(url, to_path, bare=bare, mirror=mirror, **kwargs) repo = cls(to_path) if rev: repo.checkout(rev) diff --git a/src/scmrepo/git/backend/base.py b/src/scmrepo/git/backend/base.py index 71b06f5d..98637071 100644 --- a/src/scmrepo/git/backend/base.py +++ b/src/scmrepo/git/backend/base.py @@ -50,6 +50,8 @@ def clone( to_path: str, shallow_branch: Optional[str] = None, progress: Callable[["GitProgressEvent"], None] = None, + bare: bool = False, + mirror: bool = False, ): pass diff --git a/src/scmrepo/git/backend/dulwich/__init__.py b/src/scmrepo/git/backend/dulwich/__init__.py index 6a8fa1ca..e150de71 100644 --- a/src/scmrepo/git/backend/dulwich/__init__.py +++ b/src/scmrepo/git/backend/dulwich/__init__.py @@ -192,12 +192,16 @@ def clone( to_path: str, shallow_branch: Optional[str] = None, progress: Callable[["GitProgressEvent"], None] = None, + bare: bool = False, + mirror: bool = False, ): from urllib.parse import urlparse from dulwich.porcelain import NoneStream from dulwich.porcelain import clone as git_clone + if mirror: + bare = True parsed = urlparse(url) try: clone_from = partial( @@ -207,6 +211,7 @@ def clone( errstream=( DulwichProgressReporter(progress) if progress else NoneStream() ), + bare=bare, ) if shallow_branch: # NOTE: dulwich only supports shallow/depth for non-local @@ -221,7 +226,10 @@ def clone( repo = clone_from() with closing(repo): - cls._set_default_tracking_branch(repo) + if mirror: + cls._set_mirror(repo, progress=progress) + else: + cls._set_default_tracking_branch(repo) except Exception as exc: raise CloneError(url, to_path) from exc @@ -240,6 +248,27 @@ def _set_default_tracking_branch(repo: "Repo"): config.set(section, b"remote", b"origin") config.set(section, b"merge", ref) + @staticmethod + def _set_mirror( + repo: "Repo", progress: Callable[["GitProgressEvent"], None] = None + ): + from dulwich.porcelain import NoneStream, fetch + + config = repo.get_config() + section = config[(b"remote", b"origin")] + try: + del section[b"fetch"] + except KeyError: + pass + section[b"fetch"] = b"+refs/*:refs/*" + section[b"mirror"] = b"true" + config.write_to_path() + fetch( + repo, + remote_location=b"origin", + errstream=(DulwichProgressReporter(progress) if progress else NoneStream()), + ) + @staticmethod def init(path: str, bare: bool = False) -> None: from dulwich.porcelain import init diff --git a/src/scmrepo/git/backend/gitpython.py b/src/scmrepo/git/backend/gitpython.py index 9b9cc5ed..12df3781 100644 --- a/src/scmrepo/git/backend/gitpython.py +++ b/src/scmrepo/git/backend/gitpython.py @@ -146,6 +146,8 @@ def clone( to_path: str, shallow_branch: Optional[str] = None, progress: Callable[["GitProgressEvent"], None] = None, + bare: bool = False, + mirror: bool = False, ): from git import Repo from git.exc import GitCommandError @@ -178,6 +180,8 @@ def clone( env=env, # needed before we can fix it in __init__ no_single_branch=True, progress=GitProgressReporter.wrap_fn(progress) if progress else None, + bare=bare, + mirror=mirror, ) if shallow_branch is None: tmp_repo = clone_from() diff --git a/src/scmrepo/git/backend/pygit2/__init__.py b/src/scmrepo/git/backend/pygit2/__init__.py index af76256c..d68361e2 100644 --- a/src/scmrepo/git/backend/pygit2/__init__.py +++ b/src/scmrepo/git/backend/pygit2/__init__.py @@ -31,6 +31,7 @@ if TYPE_CHECKING: from pygit2.remote import Remote # type: ignore + from pygit2.repository import Repository from scmrepo.progress import GitProgressEvent @@ -154,12 +155,15 @@ def release_odb_handles(self): # can be reacquired later as needed. self.repo.free() - @staticmethod + @classmethod def clone( + cls, url: str, to_path: str, shallow_branch: Optional[str] = None, progress: Callable[["GitProgressEvent"], None] = None, + bare: bool = False, + mirror: bool = False, ): from pygit2 import GitError, clone_repository @@ -167,12 +171,35 @@ def clone( if shallow_branch: raise NotImplementedError + if mirror: + bare = True try: with RemoteCallbacks(progress=progress) as cb: - clone_repository(url, to_path, callbacks=cb) + repo = clone_repository(url, to_path, callbacks=cb, bare=bare) + if mirror: + cls._set_mirror(repo, progress=progress) except GitError as exc: raise CloneError(url, to_path) from exc + @staticmethod + def _set_mirror( + repo: "Repository", + progress: Callable[["GitProgressEvent"], None] = None, + ): + from .callbacks import RemoteCallbacks + + url = repo.remotes["origin"].url + repo.remotes.delete("origin") + # NOTE: Pygit2 remotes.create("origin", url, fetch_refspec) creates a + # duplicate config section for each remote config entry. We just edit + # the config directly so that it creates a single section to be + # consistent with CLI Git + repo.config["remote.origin.url"] = url + repo.config["remote.origin.fetch"] = "+refs/*:refs/*" + repo.config["remote.origin.mirror"] = True + with RemoteCallbacks(progress=progress) as cb: + repo.remotes["origin"].fetch(callbacks=cb) + @staticmethod def init(path: str, bare: bool = False) -> None: from pygit2 import init_repository diff --git a/tests/test_git.py b/tests/test_git.py index 79389550..ba533849 100644 --- a/tests/test_git.py +++ b/tests/test_git.py @@ -907,6 +907,7 @@ async def test_git_ssh( @pytest.mark.parametrize("scheme", ["", "file://"]) @pytest.mark.parametrize("shallow_branch", [None, "master"]) +@pytest.mark.parametrize("bare", [True, False]) def test_clone( tmp_dir: TmpDir, scm: Git, @@ -914,16 +915,29 @@ def test_clone( tmp_dir_factory: TempDirFactory, scheme: str, shallow_branch: Optional[str], + bare: bool, ): tmp_dir.gen("foo", "foo") scm.add_commit("foo", message="init") rev = scm.get_rev() target_dir = tmp_dir_factory.mktemp("git-clone") - git.clone(f"{scheme}{tmp_dir}", str(target_dir), shallow_branch=shallow_branch) + git.clone( + f"{scheme}{tmp_dir}", + str(target_dir), + shallow_branch=shallow_branch, + bare=bare, + ) target = Git(str(target_dir)) assert target.get_rev() == rev - assert (target_dir / "foo").read_text() == "foo" + if bare: + assert not (target_dir / "foo").exists() + else: + assert (target_dir / "foo").read_text() == "foo" + assert (target_dir / "foo").read_text() == "foo" + fs = target.get_fs(rev) + with fs.open("foo", mode="r") as fobj: + assert fobj.read().strip() == "foo" @pytest.mark.skip_git_backend("pygit2")