From 63901d18ebad99ab91526fe9ec87e7a989f676dd Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 20 Jun 2026 07:37:42 +0000 Subject: [PATCH 1/5] Surface actionable error when git remote redirects to sign-in or requires credentials (#1138) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, GitRemote.is_git() silently swallowed any git ls-remote failure that wasn't "Could not resolve host", returning False and letting dfetch fall through to SVN or fail with "vcs type unsupported" — giving the user no hint that authentication was needed. Now two additional error patterns are detected in stderr and surfaced as RuntimeError with an actionable message: - "unable to update url base from redirection" (git/git:http.c): server redirected to a login page - "terminal prompts disabled" / "could not read Username" (git/git:credential.c): server returned 401 but prompts are disabled Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01XDsvuqHVthjgZdNWRLjeXo --- CHANGELOG.rst | 7 +++++++ dfetch/vcs/git.py | 16 ++++++++++++++++ tests/test_git_vcs.py | 26 ++++++++++++++++++++++++++ 3 files changed, 49 insertions(+) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 416d6a99c..91261b5ce 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,3 +1,10 @@ +Release 0.14.2 (unreleased) +=========================== + +* Surface an actionable error when a Git remote redirects to a sign-in page or + requires credentials that are unavailable, instead of silently treating the URL + 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..b2db55dac 100644 --- a/dfetch/vcs/git.py +++ b/dfetch/vcs/git.py @@ -110,6 +110,22 @@ def is_git(self) -> bool: 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) + redirect_url = redirect_match.group(1) if redirect_match else "unknown" + raise RuntimeError( + f"'{self._remote}' requires authentication" + f" — git was redirected to '{redirect_url}'.\n" + "Check your credentials or VPN access before running dfetch." + ) from exc + # 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: + raise RuntimeError( + f"'{self._remote}' requires authentication but no credentials are available.\n" + "Configure git credentials (e.g. via a git credential helper or SSH key)" + " before running dfetch." + ) from exc return False except RuntimeError: return False diff --git a/tests/test_git_vcs.py b/tests/test_git_vcs.py index 03f97dcfd..e28f89d24 100644 --- a/tests/test_git_vcs.py +++ b/tests/test_git_vcs.py @@ -238,6 +238,32 @@ 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_raises_on_auth_error(name, stderr): + """Auth-related git errors must surface as RuntimeError, not silent False.""" + os.environ["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)] + + with pytest.raises(RuntimeError, match="requires authentication"): + GitRemote("http://git.example.com/repo").is_git() + + @pytest.mark.parametrize( "name, project, cmd_result, expectation", [ From 32b2abe7356ad6bd8ee4943d7d573063277b5452 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 20 Jun 2026 08:27:38 +0000 Subject: [PATCH 2/5] Fix git auth/redirect silently treated as non-git (#1138) When git ls-remote fails due to a redirect to a sign-in page or credential requirement, is_git() now returns True and logs the situation at debug level, instead of falling through to SVN detection. The auth error surfaces naturally on the subsequent fetch. Also applies review comment: use monkeypatch.setenv in the new test to avoid global os.environ mutation. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01XDsvuqHVthjgZdNWRLjeXo --- CHANGELOG.rst | 7 ++++--- dfetch/vcs/git.py | 27 ++++++++++++++++----------- tests/test_git_vcs.py | 13 +++++++------ 3 files changed, 27 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 91261b5ce..a1d44d4be 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,9 +1,10 @@ Release 0.14.2 (unreleased) =========================== -* Surface an actionable error when a Git remote redirects to a sign-in page or - requires credentials that are unavailable, instead of silently treating the URL - as non-Git (#1138) +* When ``git ls-remote`` fails due to a redirect to a sign-in page or missing + credentials, dfetch now correctly identifies the URL as a Git remote instead + of silently falling through to SVN detection; the auth error surfaces on the + subsequent fetch (#1138) Release 0.14.1 (released 2026-06-19) ==================================== diff --git a/dfetch/vcs/git.py b/dfetch/vcs/git.py index b2db55dac..7d0fddb05 100644 --- a/dfetch/vcs/git.py +++ b/dfetch/vcs/git.py @@ -114,18 +114,23 @@ def is_git(self) -> bool: if "unable to update url base from redirection" in exc.stderr: redirect_match = re.search(r"redirect:\s+(\S+)", exc.stderr) redirect_url = redirect_match.group(1) if redirect_match else "unknown" - raise RuntimeError( - f"'{self._remote}' requires authentication" - f" — git was redirected to '{redirect_url}'.\n" - "Check your credentials or VPN access before running dfetch." - ) from exc + logger.debug( + "'%s' appears to be a git remote but was redirected to '%s' — " + "authentication may be required", + self._remote, + redirect_url, + ) + 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: - raise RuntimeError( - f"'{self._remote}' requires authentication but no credentials are available.\n" - "Configure git credentials (e.g. via a git credential helper or SSH key)" - " before running dfetch." - ) from exc + 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 except RuntimeError: return False diff --git a/tests/test_git_vcs.py b/tests/test_git_vcs.py index e28f89d24..e1ff0523c 100644 --- a/tests/test_git_vcs.py +++ b/tests/test_git_vcs.py @@ -253,15 +253,16 @@ def test_remote_check(name, cmd_result, expectation): ), ], ) -def test_remote_check_raises_on_auth_error(name, stderr): - """Auth-related git errors must surface as RuntimeError, not silent False.""" - os.environ["GIT_SSH_COMMAND"] = "ssh" # prevents additional subprocess call +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)] + run_on_cmdline_mock.side_effect = [ + SubprocessCommandError(stderr=stderr, returncode=128) + ] - with pytest.raises(RuntimeError, match="requires authentication"): - GitRemote("http://git.example.com/repo").is_git() + assert GitRemote("http://git.example.com/repo").is_git() is True @pytest.mark.parametrize( From ac7a6e668570d19b8b8842a8faae53e0d40c5c7c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Jun 2026 07:53:48 +0000 Subject: [PATCH 3/5] Reduce is_git() complexity below xenon B threshold Extract the SubprocessCommandError handler into _handle_ls_remote_error() so is_git() drops to rank A and the helper stays at rank B. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01XDsvuqHVthjgZdNWRLjeXo --- dfetch/vcs/git.py | 63 +++++++++++++++++++++++++++-------------------- 1 file changed, 36 insertions(+), 27 deletions(-) diff --git a/dfetch/vcs/git.py b/dfetch/vcs/git.py index 7d0fddb05..a0278a75e 100644 --- a/dfetch/vcs/git.py +++ b/dfetch/vcs/git.py @@ -105,36 +105,45 @@ 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 - # 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) - redirect_url = redirect_match.group(1) if redirect_match else "unknown" - logger.debug( - "'%s' appears to be a git remote but was redirected to '%s' — " - "authentication may be required", - self._remote, - redirect_url, - ) - 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 + 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) + redirect_url = redirect_match.group(1) if redirect_match else "unknown" + logger.debug( + "'%s' appears to be a git remote but was redirected to '%s' — " + "authentication may be required", + self._remote, + redirect_url, + ) + 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) From 54c7f45bbf4a13a165e9bca63495069edf0837bb Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Jun 2026 08:23:37 +0000 Subject: [PATCH 4/5] Shorten changelog entry and document format rule in AGENTS.md Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01XDsvuqHVthjgZdNWRLjeXo --- AGENTS.md | 4 +++- CHANGELOG.rst | 5 +---- 2 files changed, 4 insertions(+), 5 deletions(-) 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 a1d44d4be..2cdbc63b6 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,10 +1,7 @@ Release 0.14.2 (unreleased) =========================== -* When ``git ls-remote`` fails due to a redirect to a sign-in page or missing - credentials, dfetch now correctly identifies the URL as a Git remote instead - of silently falling through to SVN detection; the auth error surfaces on the - subsequent fetch (#1138) +* Fix git auth/redirect errors being silently misidentified as non-git (#1138) Release 0.14.1 (released 2026-06-19) ==================================== From 6005e0a8f348a94a2e442221d2ea508cb36a4557 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Jun 2026 08:25:32 +0000 Subject: [PATCH 5/5] Redact query/fragment from redirect URL before debug logging OAuth/SSO redirect URLs can carry state tokens in query or fragment. Strip those before passing to logger.debug to avoid leaking credentials. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01XDsvuqHVthjgZdNWRLjeXo --- dfetch/vcs/git.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/dfetch/vcs/git.py b/dfetch/vcs/git.py index a0278a75e..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 @@ -124,12 +125,16 @@ def _handle_ls_remote_error(self, exc: SubprocessCommandError) -> bool: # 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) - redirect_url = redirect_match.group(1) if redirect_match else "unknown" + 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, - redirect_url, + safe_url or "unknown", ) return True # git/git:credential.c — emitted when GIT_TERMINAL_PROMPT=0 and server returns 401