diff --git a/scmrepo/git/backend/base.py b/scmrepo/git/backend/base.py index 5c64362f..1063c31b 100644 --- a/scmrepo/git/backend/base.py +++ b/scmrepo/git/backend/base.py @@ -330,7 +330,7 @@ def checkout_index( @abstractmethod def status( - self, ignored: bool = False + self, ignored: bool = False, untracked_files: str = "all" ) -> Tuple[Mapping[str, Iterable[str]], Iterable[str], Iterable[str]]: """Return tuple of (staged_files, unstaged_files, untracked_files). @@ -339,6 +339,15 @@ def status( If ignored is True, gitignored files will be included in untracked_paths. + + untracked_files can be one of the following: + - "no" return no untracked files + - "normal" (git cli default) return untracked files and directories + - "all" (default) return all untracked files in untracked directories + + Using "no" or "normal" will be faster than "all" when large untracked + directories are present in the workspace, as collecting all untracked + files can take some time. """ def _reset(self) -> None: diff --git a/scmrepo/git/backend/dulwich/__init__.py b/scmrepo/git/backend/dulwich/__init__.py index fe35f4f4..8dd8f355 100644 --- a/scmrepo/git/backend/dulwich/__init__.py +++ b/scmrepo/git/backend/dulwich/__init__.py @@ -750,12 +750,12 @@ def checkout_index( raise NotImplementedError def status( - self, ignored: bool = False + self, ignored: bool = False, untracked_files: str = "all" ) -> Tuple[Mapping[str, Iterable[str]], Iterable[str], Iterable[str]]: from dulwich.porcelain import status as git_status staged, unstaged, untracked = git_status( - self.root_dir, ignored=ignored + self.root_dir, ignored=ignored, untracked_files=untracked_files ) return ( { diff --git a/scmrepo/git/backend/gitpython.py b/scmrepo/git/backend/gitpython.py index 5d4adb6a..b0270a9d 100644 --- a/scmrepo/git/backend/gitpython.py +++ b/scmrepo/git/backend/gitpython.py @@ -615,7 +615,7 @@ def checkout_index( self.repo.index.checkout(paths=paths_list, force=force) def status( - self, ignored: bool = False + self, ignored: bool = False, untracked_files: str = "all" ) -> Tuple[Mapping[str, Iterable[str]], Iterable[str], Iterable[str]]: raise NotImplementedError diff --git a/scmrepo/git/backend/pygit2.py b/scmrepo/git/backend/pygit2.py index defbaa86..18489ff4 100644 --- a/scmrepo/git/backend/pygit2.py +++ b/scmrepo/git/backend/pygit2.py @@ -594,7 +594,7 @@ def iter_remote_refs(self, url: str, base: Optional[str] = None, **kwargs): raise NotImplementedError def status( - self, ignored: bool = False + self, ignored: bool = False, untracked_files: str = "all" ) -> Tuple[Mapping[str, Iterable[str]], Iterable[str], Iterable[str]]: raise NotImplementedError diff --git a/tests/test_git.py b/tests/test_git.py index 04f0ce5c..3389ee9e 100644 --- a/tests/test_git.py +++ b/tests/test_git.py @@ -9,7 +9,12 @@ from pytest_test_utils import TempDirFactory, TmpDir from pytest_test_utils.matchers import Matcher -from scmrepo.exceptions import MergeConflictError, RevError, SCMError +from scmrepo.exceptions import ( + InvalidRemote, + MergeConflictError, + RevError, + SCMError, +) from scmrepo.git import Git # pylint: disable=redefined-outer-name,unused-argument,protected-access @@ -417,6 +422,44 @@ def test_fetch_refspecs( assert baz_rev == scm.get_ref("refs/foo/baz") +@pytest.mark.skip_git_backend("pygit2", "gitpython") +@pytest.mark.parametrize("use_url", [True, False]) +def test_iter_remote_refs( + tmp_dir: TmpDir, + scm: Git, + git: Git, + remote_git_dir: TmpDir, + tmp_dir_factory: TempDirFactory, + use_url: bool, +): + url = f"file://{remote_git_dir.resolve().as_posix()}" + + scm.gitpython.repo.create_remote("origin", url) + remote_scm = Git(remote_git_dir) + remote_git_dir.gen("file", "0") + remote_scm.add_commit("file", message="init") + remote_scm.branch("new-branch") + remote_scm.add_commit("file", message="bar") + remote_scm.add_commit("file", message="baz") + remote_scm.tag("a-tag") + + with pytest.raises(InvalidRemote): + set(git.iter_remote_refs("bad-remote")) + + with pytest.raises(InvalidRemote): + tmp_directory = tmp_dir_factory.mktemp("not_a_git_repo") + remote = f"file://{tmp_directory.as_posix()}" + set(git.iter_remote_refs(remote)) + + remote = url if use_url else "origin" + assert { + "refs/heads/master", + "HEAD", + "refs/heads/new-branch", + "refs/tags/a-tag", + } == set(git.iter_remote_refs(remote)) + + @pytest.mark.skip_git_backend("dulwich", "pygit2") def test_list_all_commits( tmp_dir: TmpDir, scm: Git, git: Git, matcher: Type[Matcher] @@ -945,3 +988,56 @@ def test_fetch( target.fetch() assert target.get_ref("refs/remotes/origin/master") == rev + + +@pytest.mark.skip_git_backend("pygit2", "gitpython") +@pytest.mark.parametrize("untracked_files", ["all", "no"]) +@pytest.mark.parametrize("ignored", [False, True]) +def test_status( + tmp_dir: TmpDir, + scm: Git, + git: Git, + tmp_dir_factory: TempDirFactory, + untracked_files: str, + ignored: bool, +): + tmp_dir.gen( + { + "foo": "foo", + "bar": "bar", + ".gitignore": "ignored", + "ignored": "ignored", + } + ) + scm.add_commit(["foo", "bar", ".gitignore"], message="init") + + staged, unstaged, untracked = git.status(ignored, untracked_files) + + assert not staged + assert not unstaged + if ignored and untracked_files != "no": + assert untracked == ["ignored"] + else: + assert not untracked + + with (tmp_dir / "foo").open("a") as fobj: + fobj.write("modified") + + with (tmp_dir / "bar").open("a") as fobj: + fobj.write("modified") + + tmp_dir.gen({"untracked_dir": {"subfolder": {"subfile": "subfile"}}}) + expected_untracked = [] + if ignored and untracked_files != "no": + expected_untracked.append("ignored") + if untracked_files != "no": + expected_untracked.append( + os.path.join("untracked_dir", "subfolder", "subfile") + ) + + git.add("foo") + staged, unstaged, untracked = git.status(ignored, untracked_files) + + assert staged["modify"] == ["foo"] + assert unstaged == ["bar"] + assert untracked == expected_untracked