Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: `* <Fix|Add|Update|Remove> <short description> (#NNNN)`

Documentation lives in `doc/` and is built with Sphinx.

Expand Down
5 changes: 5 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
@@ -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)
====================================

Expand Down
47 changes: 41 additions & 6 deletions dfetch/vcs/git.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
27 changes: 27 additions & 0 deletions tests/test_git_vcs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
[
Expand Down
Loading