diff --git a/dvc/repo/open_repo.py b/dvc/repo/open_repo.py index 25ddf877a7..8ea58b6fb5 100644 --- a/dvc/repo/open_repo.py +++ b/dvc/repo/open_repo.py @@ -212,11 +212,22 @@ def _clone_default_branch(url, rev): def _pull(git: "Git", unshallow: bool = False): + from scmrepo.exceptions import AuthError + from dvc.repo.experiments.utils import fetch_all_exps + from dvc.scm import GitAuthError + + try: + git.fetch(unshallow=unshallow) + except AuthError as exc: + raise GitAuthError(str(exc)) from exc - git.fetch(unshallow=unshallow) _merge_upstream(git) - fetch_all_exps(git, "origin") + + try: + fetch_all_exps(git, "origin") + except AuthError as exc: + raise GitAuthError(str(exc)) from exc def _merge_upstream(git: "Git"): diff --git a/dvc/scm.py b/dvc/scm.py index c451a501eb..f3e5a86e90 100644 --- a/dvc/scm.py +++ b/dvc/scm.py @@ -144,6 +144,7 @@ def update_git(self, event: "GitProgressEvent") -> None: def clone(url: str, to_path: str, **kwargs): + from scmrepo.exceptions import AuthError from scmrepo.exceptions import CloneError as InternalCloneError from dvc.repo.experiments.utils import fetch_all_exps @@ -154,6 +155,8 @@ def clone(url: str, to_path: str, **kwargs): if "shallow_branch" not in kwargs: fetch_all_exps(git, url, progress=pbar.update_git) return git + except AuthError as exc: + raise GitAuthError(str(exc)) from exc except InternalCloneError as exc: raise CloneError("SCM error") from exc diff --git a/test_auth_error_fix.py b/test_auth_error_fix.py new file mode 100644 index 0000000000..ce90f85125 --- /dev/null +++ b/test_auth_error_fix.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +""" +Test that git authentication errors are properly reported in DVC fetch/import operations. +""" + +from unittest.mock import MagicMock, patch + +import pytest +from scmrepo.exceptions import AuthError + +from dvc.repo.open_repo import _pull, clone +from dvc.scm import GitAuthError + + +def test_pull_auth_error_propagation(): + """Test that _pull properly converts AuthError to GitAuthError.""" + mock_git = MagicMock() + mock_git.fetch.side_effect = AuthError("Authentication failed") + + with pytest.raises(GitAuthError) as exc_info: + _pull(mock_git) + + assert "Authentication failed" in str(exc_info.value) + assert "See https://dvc.org/doc/user-guide/troubleshooting#git-auth" in str( + exc_info.value + ) + + +def test_pull_fetch_all_exps_auth_error(): + """Test that _pull handles AuthError from fetch_all_exps.""" + mock_git = MagicMock() + mock_git.fetch.return_value = None # fetch succeeds + + with patch("dvc.repo.open_repo.fetch_all_exps") as mock_fetch_all_exps: + mock_fetch_all_exps.side_effect = AuthError( + "Authentication failed for experiments" + ) + + with pytest.raises(GitAuthError) as exc_info: + _pull(mock_git) + + assert "Authentication failed for experiments" in str(exc_info.value) + + +def test_clone_auth_error_propagation(): + """Test that clone properly converts AuthError to GitAuthError.""" + with patch("dvc.scm.Git.clone") as mock_git_clone: + mock_git_clone.side_effect = AuthError("Bad PAT token") + + with pytest.raises(GitAuthError) as exc_info: + clone("https://github.com/test/repo.git", "/tmp/test") + + assert "Bad PAT token" in str(exc_info.value) + assert "See https://dvc.org/doc/user-guide/troubleshooting#git-auth" in str( + exc_info.value + ) + + +def test_clone_fetch_all_exps_auth_error(): + """Test that clone handles AuthError from fetch_all_exps.""" + mock_git = MagicMock() + + with patch("dvc.scm.Git.clone", return_value=mock_git): + with patch("dvc.repo.experiments.utils.fetch_all_exps") as mock_fetch_all_exps: + mock_fetch_all_exps.side_effect = AuthError("Experiments fetch auth failed") + + with pytest.raises(GitAuthError) as exc_info: + clone("https://github.com/test/repo.git", "/tmp/test") + + assert "Experiments fetch auth failed" in str(exc_info.value) + + +if __name__ == "__main__": + # Run basic tests + test_pull_auth_error_propagation() + test_pull_fetch_all_exps_auth_error() + test_clone_auth_error_propagation() + test_clone_fetch_all_exps_auth_error() + print("✅ All tests passed!")