[core] Retry transient git network failures with backoff (#18242)

This commit is contained in:
J. Nick Koston
2026-08-11 08:41:55 -05:00
committed by GitHub
parent 8ee3c8d41d
commit f3a5a9fbd5
4 changed files with 766 additions and 13 deletions
+6 -1
View File
@@ -458,11 +458,16 @@ def _clone_idf_with_submodules(
key = f"{git_url}@{ref}" if ref else git_url
_LOGGER.info("Cloning ESP-IDF from %s", key)
run_git_command(["git", "clone", "--depth=1", "--", git_url, str(framework_path)])
run_git_command(
["git", "clone", "--depth=1", "--", git_url, str(framework_path)],
network=True,
retry_cleanup=framework_path,
)
if ref:
run_git_command(
["git", "fetch", "--depth=1", "--", "origin", ref],
git_dir=framework_path,
network=True,
)
run_git_command(
["git", "reset", "--hard", "FETCH_HEAD"],
+173 -8
View File
@@ -5,6 +5,7 @@ from enum import Enum, auto
import errno
import hashlib
import logging
import math
import os
from pathlib import Path
import re
@@ -77,6 +78,45 @@ _GIT_REPO_SCOPING_ENV = frozenset(
}
)
# Substrings (matched case-insensitively against git's full stderr) that
# identify transient network failures worth retrying. Auth failures,
# missing repositories, and bad refs must fail immediately. Patterns are
# phrase-anchored so a repository URL quoted back in stderr never matches.
_TRANSIENT_GIT_ERROR_PATTERNS: tuple[str, ...] = (
"unable to access",
"could not resolve host",
"could not connect",
"failed to connect",
"timed out",
"connection reset",
"connection refused",
"early eof",
"rpc failed",
"certificate verification failed",
# Anchored to curl's diagnostic prefix so repository URLs containing
# "ssl_" tokens never classify as transient
"openssl ssl_",
"ssl routines",
"ssl connect error",
"gnutls recv error",
"gnutls_handshake",
"unexpected disconnect",
"remote end hung up unexpectedly",
)
# git quotes HTTP failures in two forms: curl's "The requested URL returned
# error: <n>" and smart-HTTP's "RPC failed; HTTP <n> curl <m>". 4xx is
# permanent (rejected credentials, missing repository) except 429 rate
# limiting; 408/425 are also treated as permanent, a deliberate trade for a
# simple rule since git hosts rarely emit them.
_PERMANENT_HTTP_ERROR_RE = re.compile(r"(?:http |returned error: )4(?!29)\d\d")
# Network commands get 3 attempts with 2s/4s backoff. Worst case is ~3x
# the command's own duration plus 6s of sleep, held under the cache entry
# lock; peers with a complete entry fall back to it after
# _COMPLETE_ENTRY_LOCK_TIMEOUT_SECONDS.
_NETWORK_MAX_ATTEMPTS = 3
class GitException(cv.Invalid):
"""Base exception for git-related errors."""
@@ -87,7 +127,18 @@ class GitNotInstalledError(GitException):
class GitCommandError(GitException):
"""Exception raised when a git command fails."""
"""Exception raised when a git command fails.
``stderr`` holds git's full stderr output; the exception message is
usually only the last ``fatal:`` line, but transient network markers
(``RPC failed``, ``GnuTLS``, ...) often appear on earlier lines.
Empty when git produced no stderr, so classification never reads the
command line (which embeds the user-supplied repository URL).
"""
def __init__(self, message: str, stderr: str = "") -> None:
super().__init__(message)
self.stderr = stderr
class GitRepositoryError(GitException):
@@ -103,8 +154,23 @@ def _redact_url_credentials(text: str) -> str:
return re.sub(r"://[^/@\s]+@", "://***@", text)
def _is_transient_git_error(stderr: str) -> bool:
"""Return True when git's stderr looks like a transient network failure."""
lowered = stderr.lower()
if _PERMANENT_HTTP_ERROR_RE.search(lowered):
return False
if "authentication failed" in lowered:
return False
return any(pattern in lowered for pattern in _TRANSIENT_GIT_ERROR_PATTERNS)
def run_git_command(
cmd: list[str], git_dir: Path | None = None, *, cwd: Path | None = None
cmd: list[str],
git_dir: Path | None = None,
*,
cwd: Path | None = None,
network: bool = False,
retry_cleanup: Path | None = None,
) -> str:
"""Run a git command and return its stdout.
@@ -113,7 +179,50 @@ def run_git_command(
to that repository and runs the command there; ``cwd`` alone runs the
command in that directory with GIT_CEILING_DIRECTORIES capping repository
discovery at its parent.
``network=True`` marks a command that talks to a remote (clone, fetch,
submodule update): transient network failures (DNS, TLS, dropped
connections) are retried with a short backoff so a momentary blip does
not fail the whole build. Local-only commands must not set it.
``retry_cleanup`` names a directory to remove before each retry, for
commands like clone that can leave a partial destination behind.
"""
attempts = _NETWORK_MAX_ATTEMPTS if network else 1
attempt = 0
while True:
try:
return _run_git_command_once(cmd, git_dir, cwd=cwd)
except GitCommandError as err:
attempt += 1
if attempt >= attempts or not _is_transient_git_error(err.stderr):
raise
if retry_cleanup is not None and retry_cleanup.is_dir():
try:
rmtree(retry_cleanup)
except OSError as cleanup_err:
# A retry would fail on the leftover directory anyway;
# give up and keep the git error as the reported cause.
_LOGGER.warning(
"Could not remove %s before retry (%s); not retrying",
retry_cleanup,
cleanup_err,
)
raise err from None
delay = 2**attempt
_LOGGER.warning(
"Git command failed: %s. Retrying in %d seconds... (attempt %d/%d)",
_redact_url_credentials(str(err)),
delay,
attempt,
attempts,
)
time.sleep(delay)
def _run_git_command_once(
cmd: list[str], git_dir: Path | None = None, *, cwd: Path | None = None
) -> str:
"""Single attempt of ``run_git_command``; see its docstring."""
# Every invocation starts from an environment with the repository-scoping
# variables stripped (see _GIT_REPO_SCOPING_ENV) so a git hook or CI
# wrapper invoking ESPHome can never redirect these commands to its own
@@ -168,11 +277,15 @@ def run_git_command(
if ret.returncode != 0:
if ret.stderr:
err_str = ret.stderr.decode("utf-8")
# errors="replace": git can emit locale-encoded (non-UTF-8) bytes
# in stderr; the error path must never raise UnicodeDecodeError.
err_str = ret.stderr.decode("utf-8", errors="replace")
lines = [x.strip() for x in err_str.splitlines()]
if lines[-1].startswith("fatal:"):
raise GitCommandError(lines[-1][len("fatal: ") :])
raise GitCommandError(err_str)
raise GitCommandError(lines[-1][len("fatal: ") :], stderr=err_str)
raise GitCommandError(err_str, stderr=err_str)
# No stderr (e.g. git killed by a signal): nothing to classify,
# never retried.
raise GitCommandError(
f"git exited with code {ret.returncode}: "
f"{_redact_url_credentials(' '.join(cmd))}"
@@ -409,6 +522,7 @@ def update_submodules(repo_dir: Path, key: str) -> None:
run_git_command(
["git", "submodule", "update", "--init", "--recursive", "--depth=1"],
cwd=repo_dir,
network=True,
)
@@ -605,7 +719,7 @@ def _clone_or_update_locked(
try:
cmd = ["git", "clone", "--depth=1"]
cmd += ["--", url, str(repo_dir)]
run_git_command(cmd)
run_git_command(cmd, network=True, retry_cleanup=repo_dir)
if ref is not None:
# We need to fetch the PR branch first, otherwise git will complain
@@ -614,6 +728,7 @@ def _clone_or_update_locked(
run_git_command(
["git", "fetch", "--depth=1", "--", "origin", ref],
git_dir=repo_dir,
network=True,
)
run_git_command(
["git", "reset", "--hard", "FETCH_HEAD"], git_dir=repo_dir
@@ -684,7 +799,57 @@ def _clone_or_update_locked(
cmd = ["git", "fetch", "--depth=1", "--", "origin"]
if ref is not None:
cmd.append(ref)
run_git_command(cmd, git_dir=repo_dir)
fetch_head = Path(repo_dir) / ".git" / "FETCH_HEAD"
try:
fetch_head_stat = fetch_head.stat()
except OSError:
# Missing (or unreadable): no pre-fetch FETCH_HEAD
fetch_head_stat = None
try:
run_git_command(cmd, git_dir=repo_dir, network=True)
except GitCommandError as err:
if not _is_transient_git_error(err.stderr):
raise
# Verified clone, untouched worktree, network-only
# failure: keep the clone instead of destroying it via
# recovery, which would re-clone on the same dead
# network. The marker must be restored or the next run
# removes the entry as an incomplete clone.
#
# A failed fetch still freshens FETCH_HEAD's mtime,
# which would suppress refresh attempts for the whole
# refresh window; restore it so the next run retries.
try:
if fetch_head_stat is not None:
os.utime(
fetch_head,
(fetch_head_stat.st_atime, fetch_head_stat.st_mtime),
)
else:
fetch_head.unlink(missing_ok=True)
except OSError as stamp_err:
# Cannot keep the fallback honest; let the git error
# route through the recovery below instead.
_LOGGER.warning(
"Could not restore the refresh timestamp for %s (%s)",
safe_key,
stamp_err,
)
raise err from None
_LOGGER.warning(
"Could not refresh %s (%s); using the existing clone "
"at %s (last updated %s ago)",
safe_key,
_redact_url_credentials(str(err)),
old_sha,
# age_seconds is inf when neither FETCH_HEAD nor HEAD
# could be stat'ed; format_duration would overflow
format_duration(age_seconds)
if math.isfinite(age_seconds)
else "unknown time",
)
_write_clone_complete_marker(repo_dir, key, hash_dir_name, safe_key)
return repo_dir, None
# Hard reset to FETCH_HEAD (short-lived git ref corresponding to most recent fetch)
run_git_command(
@@ -719,7 +884,7 @@ def _clone_or_update_locked(
_LOGGER.warning(
"Repository %s has issues (%s), attempting recovery",
safe_key,
err,
_redact_url_credentials(str(err)),
)
_LOGGER.info("Removing broken repository at %s", repo_dir)
_remove_repo_dir(repo_dir)
+12
View File
@@ -178,6 +178,11 @@ def test_clone_idf_with_submodules_without_ref(tmp_path: Path) -> None:
assert calls[-1][:5] == ["git", "submodule", "update", "--init", "--recursive"]
assert not any(c[1] == "fetch" for c in calls)
assert not any(c[1] == "reset" for c in calls)
# The clone must retry transient network failures and clean up a
# partial destination between attempts
clone_kwargs = run_git_command_mock.call_args_list[0].kwargs
assert clone_kwargs["network"] is True
assert clone_kwargs["retry_cleanup"] == framework_path
def test_clone_idf_with_submodules_with_ref(tmp_path: Path) -> None:
@@ -205,6 +210,13 @@ def test_clone_idf_with_submodules_with_ref(tmp_path: Path) -> None:
]
assert calls[2] == ["git", "reset", "--hard", "FETCH_HEAD"]
assert calls[3][:5] == ["git", "submodule", "update", "--init", "--recursive"]
# Clone and fetch talk to the network and must carry the retry flag;
# the local reset must not
kwargs = [c.kwargs for c in run_git_command_mock.call_args_list]
assert kwargs[0]["network"] is True
assert kwargs[0]["retry_cleanup"] == framework_path
assert kwargs[1]["network"] is True
assert "network" not in kwargs[2]
def test_clone_idf_with_submodules_raises_when_tree_missing(
+575 -4
View File
@@ -247,6 +247,347 @@ def test_run_git_command_strips_fatal_prefix(
assert "repository not found" in str(exc_info.value)
def _git_failure(stderr: bytes, returncode: int = 128) -> Mock:
"""Build a failed subprocess.run result with the given stderr."""
return Mock(returncode=returncode, stdout=b"", stderr=stderr)
_GIT_OK = Mock(returncode=0, stdout=b"ok", stderr=b"")
def test_run_git_command_network_retries_transient_then_succeeds(
mock_subprocess_run: Mock,
) -> None:
"""A transient network failure is retried and the retry's result returned."""
mock_subprocess_run.side_effect = [
_git_failure(
b"fatal: unable to access 'https://github.com/test/repo/': "
b"Could not resolve host: github.com\n"
),
_GIT_OK,
]
with patch("esphome.git.time.sleep") as mock_sleep:
result = git.run_git_command(
["git", "clone", "--depth=1", "--", "https://github.com/test/repo", "x"],
network=True,
)
assert result == "ok"
assert mock_subprocess_run.call_count == 2
mock_sleep.assert_called_once_with(2)
def test_run_git_command_network_gives_up_after_max_attempts(
mock_subprocess_run: Mock,
) -> None:
"""A persistent transient-looking failure raises after the final attempt."""
mock_subprocess_run.side_effect = lambda *args, **kwargs: _git_failure(
b"fatal: unable to access 'https://github.com/test/repo/': "
b"server certificate verification failed. CAfile: none CRLfile: none\n"
)
with (
patch("esphome.git.time.sleep") as mock_sleep,
pytest.raises(GitCommandError, match="certificate verification failed"),
):
git.run_git_command(["git", "fetch", "--", "origin"], network=True)
assert mock_subprocess_run.call_count == 3
assert [c.args[0] for c in mock_sleep.call_args_list] == [2, 4]
@pytest.mark.parametrize(
("stderr", "transient"),
[
# Transient: DNS, TLS, dropped connections, server-side errors
("unable to access 'https://x/': The requested URL returned error: 502", True),
("unable to access 'https://x/': Could not resolve host: github.com", True),
("unable to access 'https://x/': Failed to connect: Timed out", True),
("unable to access 'https://x/': Recv failure: Connection reset", True),
("unable to access 'https://x/': Connection refused", True),
("fatal: early EOF\nfatal: fetch-pack: invalid index-pack output", True),
(
(
"error: RPC failed; HTTP 500 curl 22 The requested URL returned "
"error: 500\nfatal: expected flush after ref listing"
),
True,
),
(
(
"unable to access 'https://x/': server certificate verification "
"failed. CAfile: none CRLfile: none"
),
True,
),
(
(
"error: RPC failed; curl 56 GnuTLS recv error (-110)\n"
"fatal: the remote end hung up unexpectedly"
),
True,
),
(
(
"fetch-pack: unexpected disconnect while reading sideband packet\n"
"fatal: early EOF"
),
True,
),
# 429 rate limiting is the one retryable 4xx, in both curl forms
("unable to access 'https://x/': The requested URL returned error: 429", True),
("error: RPC failed; HTTP 429 curl 22\nfatal: expected flush", True),
(
(
"unable to access 'https://x/': OpenSSL SSL_read: error:0A000126:"
"SSL routines::unexpected eof while reading, errno 0"
),
True,
),
# Permanent: missing repo, auth, bad ref, other 4xx
("fatal: repository 'https://github.com/test/repo/' not found", False),
(
(
"fatal: could not read Username for 'https://github.com': "
"terminal prompts disabled"
),
False,
),
("fatal: couldn't find remote ref refs/heads/nope", False),
(
(
"unable to access 'https://github.com/org/private.git/': "
"The requested URL returned error: 403"
),
False,
),
("fatal: Authentication failed for 'https://github.com/test/repo/'", False),
# Smart-HTTP (HTTP/2) 4xx form has no "returned error:" text and
# mixes in transient-looking wording; still permanent
(
(
"error: RPC failed; HTTP 403 curl 92 HTTP/2 stream 5 was not "
"closed cleanly: CANCEL (err 8)\nfatal: expected flush after "
"ref listing"
),
False,
),
(
(
"error: RPC failed; HTTP 404 curl 22\n"
"fatal: the remote end hung up unexpectedly"
),
False,
),
(
(
"fatal: unable to access 'https://x/': gnutls_handshake() "
"failed: The TLS connection was non-properly terminated."
),
True,
),
# Transient-looking tokens in the URL must not classify as transient
("fatal: repository 'https://github.com/x/esp32_ssl_reader/' not found", False),
("fatal: repository 'https://gitlab.com/gnutls/gnutls.git/' not found", False),
("", False),
],
)
def test_is_transient_git_error(stderr: str, transient: bool) -> None:
"""Real-world stderr outputs classify correctly as transient or permanent."""
assert git._is_transient_git_error(stderr) is transient
def test_run_git_command_network_no_retry_on_permanent_error(
mock_subprocess_run: Mock,
) -> None:
"""Permanent failures (missing repo, auth, bad ref) fail on the first try."""
mock_subprocess_run.return_value = _git_failure(
b"fatal: repository 'https://github.com/test/repo/' not found\n"
)
with (
patch("esphome.git.time.sleep") as mock_sleep,
pytest.raises(GitCommandError),
):
git.run_git_command(["git", "fetch", "--", "origin"], network=True)
assert mock_subprocess_run.call_count == 1
mock_sleep.assert_not_called()
def test_run_git_command_network_no_retry_when_git_missing(
mock_subprocess_run: Mock,
) -> None:
"""A missing git binary is not transient and must not be retried."""
from esphome.git import GitNotInstalledError
mock_subprocess_run.side_effect = FileNotFoundError("git not found")
with (
patch("esphome.git.time.sleep") as mock_sleep,
pytest.raises(GitNotInstalledError),
):
git.run_git_command(["git", "fetch", "--", "origin"], network=True)
assert mock_subprocess_run.call_count == 1
mock_sleep.assert_not_called()
def test_run_git_command_no_retry_by_default(mock_subprocess_run: Mock) -> None:
"""Without network=True even a transient-looking failure is not retried."""
mock_subprocess_run.return_value = _git_failure(
b"fatal: unable to access 'https://github.com/test/repo/': "
b"Could not resolve host: github.com\n"
)
with (
patch("esphome.git.time.sleep") as mock_sleep,
pytest.raises(GitCommandError),
):
git.run_git_command(["git", "status"])
assert mock_subprocess_run.call_count == 1
mock_sleep.assert_not_called()
def test_run_git_command_network_retry_matches_full_stderr_not_last_line(
mock_subprocess_run: Mock,
) -> None:
"""The transient marker often sits above the final fatal line; the retry
decision must look at the full stderr, not just the extracted message."""
mock_subprocess_run.side_effect = [
_git_failure(
b"error: RPC failed; curl 56 GnuTLS recv error (-54)\n"
b"fatal: fetch-pack: invalid index-pack output\n"
),
_GIT_OK,
]
with patch("esphome.git.time.sleep"):
result = git.run_git_command(["git", "fetch", "--", "origin"], network=True)
assert result == "ok"
assert mock_subprocess_run.call_count == 2
def test_run_git_command_retry_warning_redacts_credentials(
mock_subprocess_run: Mock, caplog: pytest.LogCaptureFixture
) -> None:
"""The retry warning embeds the git error, which embeds the URL; embedded
credentials must be redacted since warnings end up in pasted logs."""
mock_subprocess_run.side_effect = [
_git_failure(
b"fatal: unable to access 'https://user:hunter2@github.com/test/repo/': "
b"Could not resolve host: github.com\n"
),
_GIT_OK,
]
with (
patch("esphome.git.time.sleep"),
caplog.at_level(logging.WARNING, logger="esphome.git"),
):
git.run_git_command(["git", "fetch", "--", "origin"], network=True)
assert "hunter2" not in caplog.text
assert "://***@github.com/test/repo" in caplog.text
def test_run_git_command_clone_retry_removes_leftover_destination(
tmp_path: Path, mock_subprocess_run: Mock
) -> None:
"""A partial clone destination left by a failed attempt is removed before
the retry, so the retry cannot fail on 'destination path already exists'."""
dest = tmp_path / "leftover_clone"
dest.mkdir()
(dest / "partial").write_text("x")
mock_subprocess_run.side_effect = [
_git_failure(
b"fatal: unable to access 'https://github.com/test/repo/': "
b"Could not resolve host: github.com\n"
),
_GIT_OK,
]
with patch("esphome.git.time.sleep"):
result = git.run_git_command(
[
"git",
"clone",
"--depth=1",
"--",
"https://github.com/test/repo",
str(dest),
],
network=True,
retry_cleanup=dest,
)
assert result == "ok"
assert mock_subprocess_run.call_count == 2
assert not dest.exists()
def test_run_git_command_cleanup_failure_reraises_original_error(
tmp_path: Path, mock_subprocess_run: Mock
) -> None:
"""When the pre-retry cleanup fails, the git error stays the reported
cause instead of being replaced by the cleanup OSError."""
dest = tmp_path / "leftover_clone"
dest.mkdir()
mock_subprocess_run.return_value = _git_failure(
b"fatal: unable to access 'https://github.com/test/repo/': "
b"Could not resolve host: github.com\n"
)
with (
patch("esphome.git.rmtree", side_effect=OSError("locked")),
patch("esphome.git.time.sleep") as mock_sleep,
pytest.raises(GitCommandError, match="Could not resolve host"),
):
git.run_git_command(
["git", "clone", "--depth=1", "--", "https://github.com/test/repo", "x"],
network=True,
retry_cleanup=dest,
)
assert mock_subprocess_run.call_count == 1
mock_sleep.assert_not_called()
def test_run_git_command_no_retry_on_empty_stderr_failure(
mock_subprocess_run: Mock,
) -> None:
"""A failure with no stderr (e.g. git killed by a signal) is not retried."""
mock_subprocess_run.return_value = _git_failure(b"", returncode=1)
with (
patch("esphome.git.time.sleep") as mock_sleep,
pytest.raises(GitCommandError, match="git exited with code 1"),
):
git.run_git_command(["git", "fetch", "--", "origin"], network=True)
assert mock_subprocess_run.call_count == 1
mock_sleep.assert_not_called()
def test_run_git_command_non_utf8_stderr_does_not_crash(
mock_subprocess_run: Mock,
) -> None:
"""Locale-encoded (non-UTF-8) stderr must not raise UnicodeDecodeError."""
mock_subprocess_run.return_value = _git_failure(
b"fatal: repositorio no encontrado \xe9\xff\n"
)
with pytest.raises(GitCommandError, match="repositorio no encontrado"):
git.run_git_command(["git", "fetch", "--", "origin"], network=True)
assert mock_subprocess_run.call_count == 1
def test_run_git_command_without_git_dir(mock_subprocess_run: Mock) -> None:
"""Test that run_git_command works without git_dir (clone case)."""
# Configure mock to return success
@@ -677,10 +1018,10 @@ def test_clone_or_update_with_none_refresh_always_updates(
"ambiguous argument 'HEAD': unknown revision or path not in the working tree.",
),
("stash", "fatal: unable to write new index file"),
(
"fetch",
"fatal: unable to access 'https://github.com/test/repo/': Could not resolve host",
),
# The fetch failure must be non-transient: a transient one (e.g.
# "Could not resolve host") now keeps the existing clone instead of
# triggering recovery.
("fetch", "fatal: couldn't find remote ref main"),
("reset", "fatal: Could not reset index file to revision 'FETCH_HEAD'"),
],
)
@@ -747,6 +1088,236 @@ def test_clone_or_update_recovers_from_git_failures(
assert result_dir == repo_dir
@pytest.mark.parametrize("fetch_head_preexists", [True, False])
def test_clone_or_update_transient_fetch_keeps_existing_clone(
tmp_path: Path,
mock_run_git_command: Mock,
caplog: pytest.LogCaptureFixture,
fetch_head_preexists: bool,
) -> None:
"""A transient network failure while refreshing a verified clone falls back
to the existing clone instead of destroying it with a recovery re-clone."""
CORE.config_path = tmp_path / "test.yaml"
url = "https://github.com/test/repo"
ref = "main"
domain = "test"
repo_dir = _compute_repo_dir(url, ref, domain)
_setup_old_repo(repo_dir)
if not fetch_head_preexists:
# First-ever refresh: age comes from HEAD, FETCH_HEAD absent
(repo_dir / ".git" / "FETCH_HEAD").unlink()
head = repo_dir / ".git" / "HEAD"
head.write_text("test")
old_time = time.time() - 2 * 86400
os.utime(head, (old_time, old_time))
def git_command_side_effect(
cmd: list[str], cwd: str | None = None, **kwargs: Any
) -> str:
cmd_type = _get_git_command_type(cmd)
if cmd_type == "rev-parse":
return "abc123"
if cmd_type == "fetch":
# A failed fetch still freshens FETCH_HEAD, like real git
(repo_dir / ".git" / "FETCH_HEAD").touch()
stderr = (
"fatal: unable to access "
"'https://user:hunter2@github.com/test/repo/': "
"Could not resolve host: github.com"
)
raise GitCommandError(stderr, stderr=stderr)
return ""
mock_run_git_command.side_effect = git_command_side_effect
refresh = TimePeriodSeconds(days=1)
with caplog.at_level(logging.WARNING, logger="esphome.git"):
result_dir, revert = git.clone_or_update(
url=url,
ref=ref,
refresh=refresh,
domain=domain,
)
# The existing clone is returned, not removed or re-cloned
assert result_dir == repo_dir
assert repo_dir.is_dir()
assert revert is None
assert not any(
_get_git_command_type(c[0][0]) == "clone"
for c in mock_run_git_command.call_args_list
)
# The completion marker must be restored, or the next run treats the
# entry as an incomplete clone and removes it
assert _marker_path(repo_dir).is_file()
# The warning must say what the build will actually use and how stale it is
assert "using the existing clone at abc123" in caplog.text
assert "ago" in caplog.text
# Credentials embedded in the URL must not reach the warning log
assert "hunter2" not in caplog.text
assert "://***@github.com/test/repo" in caplog.text
# The FETCH_HEAD the failed fetch freshened must not survive, or the
# refresh window would suppress retrying the update on subsequent runs
fetch_head = repo_dir / ".git" / "FETCH_HEAD"
if fetch_head_preexists:
assert time.time() - fetch_head.stat().st_mtime > refresh.total_seconds
else:
assert not fetch_head.exists()
def test_clone_or_update_timestamp_restore_failure_routes_to_recovery(
tmp_path: Path, mock_run_git_command: Mock, caplog: pytest.LogCaptureFixture
) -> None:
"""If the FETCH_HEAD restore fails, the fallback cannot stay honest, so
the git error must route through recovery instead of a raw OSError."""
CORE.config_path = tmp_path / "test.yaml"
url = "https://github.com/test/repo"
ref = "main"
domain = "test"
repo_dir = _compute_repo_dir(url, ref, domain)
_setup_old_repo(repo_dir)
call_counts: dict[str, int] = {}
def git_command_side_effect(
cmd: list[str], cwd: str | None = None, **kwargs: Any
) -> str:
cmd_type = _get_git_command_type(cmd)
if cmd_type:
call_counts[cmd_type] = call_counts.get(cmd_type, 0) + 1
if cmd_type == "rev-parse":
return "abc123"
if cmd_type == "fetch" and call_counts[cmd_type] == 1:
stderr = (
"fatal: unable to access 'https://github.com/test/repo/': "
"Could not resolve host: github.com"
)
raise GitCommandError(stderr, stderr=stderr)
if cmd_type == "clone":
_simulate_cloned_repo(repo_dir)
return ""
mock_run_git_command.side_effect = git_command_side_effect
refresh = TimePeriodSeconds(days=1)
with (
patch("esphome.git.os.utime", side_effect=OSError("read-only")),
caplog.at_level(logging.WARNING, logger="esphome.git"),
):
result_dir, _ = git.clone_or_update(
url=url,
ref=ref,
refresh=refresh,
domain=domain,
)
assert result_dir == repo_dir
assert "Could not restore the refresh timestamp" in caplog.text
# Recovery re-cloned rather than surfacing the OSError
assert call_counts.get("clone", 0) == 1
@pytest.mark.parametrize(
"refresh", [None, TimePeriodSeconds(days=1)], ids=["clone", "refresh"]
)
def test_clone_or_update_network_commands_carry_retry_flag(
tmp_path: Path, mock_run_git_command: Mock, refresh: TimePeriodSeconds | None
) -> None:
"""clone/fetch/submodule opt into transient-failure retry; local commands
(rev-parse, stash, reset) must not, so a refactor cannot silently drop or
widen the retry wiring."""
CORE.config_path = tmp_path / "test.yaml"
url = "https://github.com/test/repo"
ref = "main"
domain = "test"
repo_dir = _compute_repo_dir(url, ref, domain)
if refresh is None:
mock_run_git_command.side_effect = _make_clone_side_effect(
repo_dir, gitmodules=True
)
else:
_setup_old_repo(repo_dir)
(repo_dir / ".gitmodules").write_text("test")
mock_run_git_command.return_value = "abc123"
git.clone_or_update(
url=url,
ref=ref,
refresh=refresh,
domain=domain,
init_submodules=True,
)
seen: set[str] = set()
for call in mock_run_git_command.call_args_list:
cmd_type = _get_git_command_type(call.args[0])
seen.add(cmd_type)
if cmd_type in ("clone", "fetch", "submodule"):
assert call.kwargs.get("network") is True, cmd_type
else:
assert "network" not in call.kwargs, cmd_type
if cmd_type == "clone":
assert call.kwargs.get("retry_cleanup") == repo_dir
expected = {"fetch", "reset", "submodule"}
expected |= {"clone"} if refresh is None else {"rev-parse", "stash"}
assert expected <= seen
def test_clone_or_update_transient_submodule_failure_still_recovers(
tmp_path: Path, mock_run_git_command: Mock
) -> None:
"""A transient failure after the reset (submodules) leaves a half-updated
tree, so it must route through recovery instead of keeping the clone."""
CORE.config_path = tmp_path / "test.yaml"
url = "https://github.com/test/repo"
domain = "test"
repo_dir = _compute_repo_dir(url, None, domain)
_setup_old_repo(repo_dir)
(repo_dir / ".gitmodules").write_text("test")
call_counts: dict[str, int] = {}
def git_command_side_effect(
cmd: list[str], cwd: str | None = None, **kwargs: Any
) -> str:
cmd_type = _get_git_command_type(cmd)
if cmd_type:
call_counts[cmd_type] = call_counts.get(cmd_type, 0) + 1
if cmd_type == "rev-parse":
return "abc123"
if cmd_type == "submodule" and call_counts[cmd_type] == 1:
stderr = (
"fatal: unable to access 'https://github.com/test/sub/': "
"Could not resolve host: github.com"
)
raise GitCommandError(stderr, stderr=stderr)
if cmd_type == "clone":
_simulate_cloned_repo(repo_dir)
(repo_dir / ".gitmodules").write_text("test")
return ""
mock_run_git_command.side_effect = git_command_side_effect
refresh = TimePeriodSeconds(days=1)
result_dir, _ = git.clone_or_update(
url=url,
ref=None,
refresh=refresh,
domain=domain,
init_submodules=True,
)
assert result_dir == repo_dir
# The half-updated tree must be recovered via re-clone, not kept
assert call_counts.get("clone", 0) == 1
def test_clone_or_update_fails_when_recovery_also_fails(
tmp_path: Path, mock_run_git_command: Mock
) -> None: