diff --git a/AGENTS.md b/AGENTS.md index d4294df35..2c171f69d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -106,7 +106,9 @@ Every change must be reflected in the documentation. Depending on the nature of - **Notable change to the dfetch product/program** (feature, fix, behaviour change) → add an entry to the changelog (`doc/changelog/`) - **Architecture change** → update `doc/explanation/architecture.rst` -The changelog tracks the dfetch product/program only. Documentation-only changes (wording, structure, new explanatory pages) do not require a changelog entry. +The changelog tracks the dfetch product/program only. Documentation-only changes (wording, structure, new explanatory pages) and internal refactors (complexity reduction, test cleanup, CI wiring) do not require a changelog entry. + +Each entry is a single bullet line, max 100 characters including the issue/PR reference, with no continuation lines. Use one bullet per logical user-visible change. Format: `* (#NNNN)` Documentation lives in `doc/` and is built with Sphinx. diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 416d6a99c..2cdbc63b6 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,3 +1,8 @@ +Release 0.14.2 (unreleased) +=========================== + +* Fix git auth/redirect errors being silently misidentified as non-git (#1138) + Release 0.14.1 (released 2026-06-19) ==================================== diff --git a/dfetch/vcs/git.py b/dfetch/vcs/git.py index 4dce4b5b7..cecb01901 100644 --- a/dfetch/vcs/git.py +++ b/dfetch/vcs/git.py @@ -9,6 +9,7 @@ import tempfile from collections.abc import Callable, Generator, Sequence from pathlib import Path +from urllib.parse import urlparse, urlunparse from dfetch.log import get_logger from dfetch.util.cmdline import SubprocessCommandError, run_on_cmdline @@ -105,15 +106,49 @@ def is_git(self) -> bool: ) return True except SubprocessCommandError as exc: - if exc.returncode == 128 and "Could not resolve host" in exc.stderr: - raise RuntimeError( - f">>>{exc.cmd}<<< failed!\n" - + f"'{self._remote}' is not a valid URL or unreachable:\n{exc.stderr or exc.stdout}" - ) from exc - return False + return self._handle_ls_remote_error(exc) except RuntimeError: return False + def _handle_ls_remote_error(self, exc: SubprocessCommandError) -> bool: + """Determine whether a failed git ls-remote still implies a git remote. + + Raises RuntimeError for unrecoverable errors (host unreachable). + Returns True when the server responded but auth is needed. + Returns False when the failure is unrelated to git. + """ + if exc.returncode == 128 and "Could not resolve host" in exc.stderr: + raise RuntimeError( + f">>>{exc.cmd}<<< failed!\n" + + f"'{self._remote}' is not a valid URL or unreachable:\n{exc.stderr or exc.stdout}" + ) from exc + # git/git:http.c — emitted when the server redirects to a login page + if "unable to update url base from redirection" in exc.stderr: + redirect_match = re.search(r"redirect:\s+(\S+)", exc.stderr) + raw_url = redirect_match.group(1) if redirect_match else "" + parsed = urlparse(raw_url) + safe_url = urlunparse( + (parsed.scheme, parsed.netloc, parsed.path, "", "", "") + ) + logger.debug( + "'%s' appears to be a git remote but was redirected to '%s' — " + "authentication may be required", + self._remote, + safe_url or "unknown", + ) + return True + # git/git:credential.c — emitted when GIT_TERMINAL_PROMPT=0 and server returns 401 + if ( + "terminal prompts disabled" in exc.stderr + or "could not read Username" in exc.stderr + ): + logger.debug( + "'%s' appears to be a git remote but requires credentials", + self._remote, + ) + return True + return False + def last_sha_on_branch(self, branch: str) -> str: """Get the last sha of a branch.""" return self._find_sha_of_branch_or_tag(self._ls_remote(self._remote), branch) diff --git a/tests/test_git_vcs.py b/tests/test_git_vcs.py index 03f97dcfd..e1ff0523c 100644 --- a/tests/test_git_vcs.py +++ b/tests/test_git_vcs.py @@ -238,6 +238,33 @@ def test_remote_check(name, cmd_result, expectation): assert GitRemote(name).is_git() == expectation +@pytest.mark.parametrize( + "name, stderr", + [ + ( + "redirect to sign-in page", + "fatal: unable to update url base from redirection:\n" + " asked for: http://git.example.com/repo/info/refs?service=git-upload-pack\n" + " redirect: http://git.example.com/users/sign_in", + ), + ( + "terminal prompts disabled (HTTP 401)", + "fatal: could not read Username for 'https://git.example.com': terminal prompts disabled", + ), + ], +) +def test_remote_check_returns_true_on_auth_error(name, stderr, monkeypatch): + """Auth-related git errors still mean the URL is a git remote — return True.""" + monkeypatch.setenv("GIT_SSH_COMMAND", "ssh") # prevents additional subprocess call + + with patch("dfetch.vcs.git.run_on_cmdline") as run_on_cmdline_mock: + run_on_cmdline_mock.side_effect = [ + SubprocessCommandError(stderr=stderr, returncode=128) + ] + + assert GitRemote("http://git.example.com/repo").is_git() is True + + @pytest.mark.parametrize( "name, project, cmd_result, expectation", [