mirror of
https://github.com/esphome/esphome.git
synced 2026-08-17 19:13:18 +08:00
CI / Create common environment (push) Canceled after 0s
CI / Check pylint (push) Canceled after 0s
CI / Run script/ci-custom (push) Canceled after 0s
CI / Check import esphome.__main__ time (push) Canceled after 0s
CI / Test downstream esphome/device-builder (push) Canceled after 0s
CI / Run pytest (macOS-latest, 3.12) (push) Canceled after 0s
CI / Run pytest (macOS-latest, 3.14) (push) Canceled after 0s
CI / Run pytest (ubuntu-latest, 3.12) (push) Canceled after 0s
CI / Run pytest (ubuntu-latest, 3.13) (push) Canceled after 0s
CI / Run pytest (ubuntu-latest, 3.14) (push) Canceled after 0s
CI / Run pytest (windows-latest, 3.12) (push) Canceled after 0s
CI / Run pytest (windows-latest, 3.14) (push) Canceled after 0s
CI / Determine which jobs to run (push) Canceled after 0s
CI / Run integration tests () (push) Canceled after 0s
CI / Run C++ unit tests (push) Canceled after 0s
CI / Run CodSpeed benchmarks (push) Canceled after 0s
CI / Run script/clang-tidy for LibreTiny (push) Canceled after 0s
CI / Run script/clang-tidy for ESP8266 (push) Canceled after 0s
CI / Run script/clang-tidy for RP2 (push) Canceled after 0s
CI / Run script/clang-tidy for ESP32 Arduino (push) Canceled after 0s
CI / Run script/clang-tidy for ZEPHYR (push) Canceled after 0s
CI / Run script/clang-tidy for ESP32 IDF (push) Canceled after 0s
CI / Run script/clang-tidy for ESP32 IDF 1/3 (push) Canceled after 0s
CI / Run script/clang-tidy for ESP32 IDF 2/3 (push) Canceled after 0s
CI / Run script/clang-tidy for ESP32 IDF 3/3 (push) Canceled after 0s
CI / Run script/clang-tidy for ESP32 C6 (push) Canceled after 0s
CI / Run script/clang-tidy for ESP32 P4 (push) Canceled after 0s
CI / Run script/clang-tidy for ESP32 S3 (push) Canceled after 0s
CI / Test components batch () (push) Canceled after 0s
CI / Test esp32 components with PlatformIO (push) Canceled after 0s
CI / Seed pre-commit cache (push) Canceled after 0s
CI / pre-commit.ci lite (push) Canceled after 0s
CI / Build target branch for memory impact (push) Canceled after 0s
CI / Build PR branch for memory impact (push) Canceled after 0s
CI / Comment memory impact (push) Canceled after 0s
CI / CI Status (push) Canceled after 0s
2646 lines
87 KiB
Python
2646 lines
87 KiB
Python
"""Tests for git.py module."""
|
|
|
|
from collections.abc import Callable
|
|
import errno
|
|
import logging
|
|
import os
|
|
from pathlib import Path
|
|
import subprocess
|
|
import threading
|
|
import time
|
|
from typing import Any
|
|
from unittest.mock import Mock, patch
|
|
|
|
from filelock import FileLock
|
|
import pytest
|
|
|
|
from esphome import git
|
|
import esphome.config_validation as cv
|
|
from esphome.core import CORE, EsphomeError, TimePeriodSeconds
|
|
from esphome.git import GitCommandError
|
|
|
|
|
|
def _compute_repo_dir(url: str, ref: str | None, domain: str) -> Path:
|
|
"""Helper to compute the expected repo directory path using git module's logic."""
|
|
key = f"{url}@{ref}"
|
|
return git._compute_destination_path(key, domain)
|
|
|
|
|
|
# The tests must probe the exact location the implementation uses
|
|
_marker_path = git._clone_complete_marker_path
|
|
|
|
|
|
def _mark_clone_complete(repo_dir: Path) -> None:
|
|
"""Write the completion marker so a hand-made repo dir is treated as valid."""
|
|
_marker_path(repo_dir).write_text("test")
|
|
|
|
|
|
def _setup_old_repo(repo_dir: Path, days_old: int = 2) -> None:
|
|
"""Helper to set up a git repo directory structure with an old timestamp.
|
|
|
|
Args:
|
|
repo_dir: The repository directory path to create.
|
|
days_old: Number of days old to make the FETCH_HEAD file (default: 2).
|
|
"""
|
|
# Create repo directory
|
|
repo_dir.mkdir(parents=True)
|
|
git_dir = repo_dir / ".git"
|
|
git_dir.mkdir()
|
|
_mark_clone_complete(repo_dir)
|
|
|
|
# Create FETCH_HEAD file with old timestamp
|
|
fetch_head = git_dir / "FETCH_HEAD"
|
|
fetch_head.write_text("test")
|
|
old_time = time.time() - days_old * 86400
|
|
fetch_head.touch()
|
|
os.utime(fetch_head, (old_time, old_time))
|
|
|
|
|
|
def _get_git_command_type(cmd: list[str]) -> str | None:
|
|
"""Helper to determine the type of git command from a command list.
|
|
|
|
Args:
|
|
cmd: The git command list (e.g., ["git", "rev-parse", "HEAD"]).
|
|
|
|
Returns:
|
|
The command type ("rev-parse", "stash", "fetch", "reset", "clone") or None.
|
|
"""
|
|
# Git commands are always in format ["git", "command", ...], so check index 1
|
|
if len(cmd) > 1:
|
|
return cmd[1]
|
|
return None
|
|
|
|
|
|
def _simulate_cloned_repo(repo_dir: Path) -> None:
|
|
"""Create the directory structure a successful git clone would leave."""
|
|
repo_dir.mkdir(parents=True, exist_ok=True)
|
|
(repo_dir / ".git").mkdir(exist_ok=True)
|
|
|
|
|
|
def _make_clone_side_effect(
|
|
repo_dir: Path,
|
|
gitmodules: bool = False,
|
|
on_clone: Callable[[], None] | None = None,
|
|
) -> Callable[..., str]:
|
|
"""Return a run_git_command side effect whose clone creates the repo dir.
|
|
|
|
With ``gitmodules`` the cloned repo also declares submodules. ``on_clone``
|
|
runs at clone time before the repo dir appears, so a test can probe or
|
|
block mid-clone.
|
|
"""
|
|
|
|
def git_command_side_effect(
|
|
cmd: list[str], cwd: str | None = None, **kwargs: Any
|
|
) -> str:
|
|
if _get_git_command_type(cmd) == "clone":
|
|
if on_clone is not None:
|
|
on_clone()
|
|
_simulate_cloned_repo(repo_dir)
|
|
if gitmodules:
|
|
(repo_dir / ".gitmodules").write_text("test")
|
|
return ""
|
|
|
|
return git_command_side_effect
|
|
|
|
|
|
def _submodule_calls(mock: Mock) -> list[Any]:
|
|
"""Return the mock's `git submodule` calls."""
|
|
return [
|
|
c for c in mock.call_args_list if _get_git_command_type(c[0][0]) == "submodule"
|
|
]
|
|
|
|
|
|
def _assert_submodule_runs_without_isolation(call: Any, repo_dir: Path) -> None:
|
|
"""Assert a git submodule call ran with plain cwd, not GIT_DIR/GIT_WORK_TREE
|
|
isolation, which breaks the submodule porcelain on some installations."""
|
|
assert call.kwargs.get("git_dir") is None
|
|
assert call.kwargs.get("cwd") == repo_dir
|
|
|
|
|
|
def test_run_git_command_success(tmp_path: Path) -> None:
|
|
"""Test that run_git_command returns output on success."""
|
|
# Create a simple git repo to test with
|
|
repo_dir = tmp_path / "test_repo"
|
|
repo_dir.mkdir()
|
|
|
|
# Initialize a git repo
|
|
result = git.run_git_command(["git", "init"], str(repo_dir))
|
|
assert "Initialized empty Git repository" in result or result == ""
|
|
|
|
# Verify we can run a command and get output
|
|
result = git.run_git_command(["git", "status", "--porcelain"], str(repo_dir))
|
|
# Empty repo should have empty status
|
|
assert isinstance(result, str)
|
|
|
|
|
|
def test_run_git_command_debug_log_redacts_credentials(
|
|
tmp_path: Path, mock_subprocess_run: Mock, caplog: pytest.LogCaptureFixture
|
|
) -> None:
|
|
"""Embedded URL credentials never reach the debug log; -v output is
|
|
routinely pasted into public issues. subprocess is mocked so no real
|
|
git ever sees the URL (the path is not creatable on Windows)."""
|
|
mock_subprocess_run.return_value = Mock(returncode=0, stdout=b"", stderr=b"")
|
|
with caplog.at_level(logging.DEBUG, logger="esphome.git"):
|
|
git.run_git_command(
|
|
["git", "clone", "https://user:hunter2@github.com/test/repo"],
|
|
cwd=tmp_path,
|
|
)
|
|
assert "hunter2" not in caplog.text
|
|
assert "://***@github.com/test/repo" in caplog.text
|
|
|
|
|
|
def test_run_git_command_with_git_dir_isolation(
|
|
tmp_path: Path, mock_subprocess_run: Mock
|
|
) -> None:
|
|
"""Test that git_dir parameter properly isolates git operations."""
|
|
repo_dir = tmp_path / "test_repo"
|
|
repo_dir.mkdir()
|
|
git_dir = repo_dir / ".git"
|
|
git_dir.mkdir()
|
|
|
|
# Configure mock to return success
|
|
mock_subprocess_run.return_value = Mock(
|
|
returncode=0,
|
|
stdout=b"test output",
|
|
stderr=b"",
|
|
)
|
|
|
|
# Ambient repo-scoping vars simulate a git hook invoking ESPHome; an
|
|
# ambient GIT_INDEX_FILE surviving into a git_dir invocation fails
|
|
# silently (git operates on the caller's index and exits 0).
|
|
with patch.dict(
|
|
os.environ,
|
|
{"GIT_INDEX_FILE": "/caller/index", "GIT_OBJECT_DIRECTORY": "/caller/objects"},
|
|
):
|
|
result = git.run_git_command(
|
|
["git", "rev-parse", "HEAD"],
|
|
git_dir=repo_dir,
|
|
)
|
|
|
|
# Verify subprocess.run was called
|
|
assert mock_subprocess_run.called
|
|
call_args = mock_subprocess_run.call_args
|
|
|
|
# Verify environment was set
|
|
env = call_args[1]["env"]
|
|
assert "GIT_DIR" in env
|
|
assert "GIT_WORK_TREE" in env
|
|
assert env["GIT_DIR"] == str(repo_dir / ".git")
|
|
assert env["GIT_WORK_TREE"] == str(repo_dir)
|
|
# The ambient scoping vars must be stripped, not passed through.
|
|
assert "GIT_INDEX_FILE" not in env
|
|
assert "GIT_OBJECT_DIRECTORY" not in env
|
|
|
|
assert result == "test output"
|
|
|
|
|
|
def test_run_git_command_raises_git_not_installed_error(
|
|
tmp_path: Path, mock_subprocess_run: Mock
|
|
) -> None:
|
|
"""Test that FileNotFoundError is converted to GitNotInstalledError."""
|
|
from esphome.git import GitNotInstalledError
|
|
|
|
repo_dir = tmp_path / "test_repo"
|
|
|
|
# Configure mock to raise FileNotFoundError
|
|
mock_subprocess_run.side_effect = FileNotFoundError("git not found")
|
|
|
|
with pytest.raises(GitNotInstalledError, match="git is not installed"):
|
|
git.run_git_command(["git", "status"], git_dir=repo_dir)
|
|
|
|
|
|
def test_run_git_command_raises_git_command_error_on_failure(
|
|
tmp_path: Path, mock_subprocess_run: Mock
|
|
) -> None:
|
|
"""Test that failed git commands raise GitCommandError."""
|
|
repo_dir = tmp_path / "test_repo"
|
|
|
|
# Configure mock to return non-zero exit code
|
|
mock_subprocess_run.return_value = Mock(
|
|
returncode=1,
|
|
stdout=b"",
|
|
stderr=b"fatal: not a git repository",
|
|
)
|
|
|
|
with pytest.raises(GitCommandError, match="not a git repository"):
|
|
git.run_git_command(["git", "status"], git_dir=repo_dir)
|
|
|
|
|
|
def test_run_git_command_strips_fatal_prefix(
|
|
tmp_path: Path, mock_subprocess_run: Mock
|
|
) -> None:
|
|
"""Test that 'fatal: ' prefix is stripped from error messages."""
|
|
repo_dir = tmp_path / "test_repo"
|
|
|
|
# Configure mock to return error with "fatal: " prefix
|
|
mock_subprocess_run.return_value = Mock(
|
|
returncode=128,
|
|
stdout=b"",
|
|
stderr=b"fatal: repository not found\n",
|
|
)
|
|
|
|
with pytest.raises(GitCommandError) as exc_info:
|
|
git.run_git_command(["git", "clone", "invalid-url"], git_dir=repo_dir)
|
|
|
|
# Error message should NOT include "fatal: " prefix
|
|
assert "fatal:" not in str(exc_info.value)
|
|
assert "repository not found" in str(exc_info.value)
|
|
|
|
|
|
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
|
|
mock_subprocess_run.return_value = Mock(
|
|
returncode=0,
|
|
stdout=b"Cloning into 'test_repo'...",
|
|
stderr=b"",
|
|
)
|
|
|
|
result = git.run_git_command(["git", "clone", "https://github.com/test/repo"])
|
|
|
|
# Verify subprocess.run was called
|
|
assert mock_subprocess_run.called
|
|
call_args = mock_subprocess_run.call_args
|
|
|
|
# Verify environment does NOT have GIT_DIR or GIT_WORK_TREE set
|
|
# (it should use the default environment or None)
|
|
env = call_args[1].get("env")
|
|
if env is not None:
|
|
assert "GIT_DIR" not in env
|
|
assert "GIT_WORK_TREE" not in env
|
|
|
|
# Verify cwd is None (default)
|
|
assert call_args[1].get("cwd") is None
|
|
|
|
assert result == "Cloning into 'test_repo'..."
|
|
|
|
|
|
@pytest.mark.parametrize("relative", [False, True], ids=["absolute", "relative"])
|
|
def test_run_git_command_with_cwd_runs_in_dir_without_isolation(
|
|
tmp_path: Path,
|
|
mock_subprocess_run: Mock,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
relative: bool,
|
|
) -> None:
|
|
"""The cwd parameter sets the working directory without GIT_DIR/GIT_WORK_TREE.
|
|
|
|
Ambient GIT_DIR/GIT_WORK_TREE (e.g. from a git hook or CI wrapper) must be
|
|
stripped too, and GIT_CEILING_DIRECTORIES must stop git from walking up to
|
|
an enclosing repository if the target repo's .git is missing or corrupt.
|
|
Git silently ignores a relative ceiling entry, so the variable must come
|
|
out absolute even when the given cwd is relative.
|
|
"""
|
|
repo_dir = tmp_path / "test_repo"
|
|
repo_dir.mkdir()
|
|
if relative:
|
|
monkeypatch.chdir(tmp_path)
|
|
cwd_arg = Path("test_repo")
|
|
else:
|
|
cwd_arg = repo_dir
|
|
|
|
mock_subprocess_run.return_value = Mock(
|
|
returncode=0,
|
|
stdout=b"test output",
|
|
stderr=b"",
|
|
)
|
|
|
|
with patch.dict(
|
|
os.environ,
|
|
{
|
|
"GIT_DIR": "/ambient/.git",
|
|
"GIT_WORK_TREE": "/ambient",
|
|
"GIT_INDEX_FILE": "/ambient/.git/index",
|
|
},
|
|
):
|
|
result = git.run_git_command(["git", "submodule", "update"], cwd=cwd_arg)
|
|
|
|
call_args = mock_subprocess_run.call_args
|
|
env = call_args[1]["env"]
|
|
assert "GIT_DIR" not in env
|
|
assert "GIT_WORK_TREE" not in env
|
|
assert "GIT_INDEX_FILE" not in env
|
|
ceiling = Path(env["GIT_CEILING_DIRECTORIES"])
|
|
assert ceiling.is_absolute()
|
|
assert ceiling.samefile(tmp_path)
|
|
assert call_args[1]["cwd"] == cwd_arg
|
|
assert result == "test output"
|
|
|
|
|
|
def test_run_git_command_raises_on_nonfatal_stderr(
|
|
tmp_path: Path, mock_subprocess_run: Mock
|
|
) -> None:
|
|
"""Nonzero exit with stderr lacking a fatal: prefix raises with full stderr."""
|
|
mock_subprocess_run.return_value = Mock(
|
|
returncode=1,
|
|
stdout=b"",
|
|
stderr=b"error: pathspec 'nope' did not match any file(s)\n",
|
|
)
|
|
|
|
with pytest.raises(GitCommandError, match="did not match"):
|
|
git.run_git_command(["git", "checkout", "nope"], git_dir=tmp_path)
|
|
|
|
|
|
def test_run_git_command_raises_on_nonzero_exit_without_stderr(
|
|
tmp_path: Path, mock_subprocess_run: Mock
|
|
) -> None:
|
|
"""A nonzero exit must raise even when git printed nothing to stderr.
|
|
|
|
Silent nonzero exits were previously treated as success, which is how
|
|
broken checkouts could be cached as complete.
|
|
"""
|
|
mock_subprocess_run.return_value = Mock(
|
|
returncode=1,
|
|
stdout=b"",
|
|
stderr=b"",
|
|
)
|
|
|
|
with pytest.raises(GitCommandError, match="exited with code 1"):
|
|
git.run_git_command(["git", "submodule", "update"], cwd=tmp_path)
|
|
|
|
|
|
def test_run_git_command_without_git_dir_raises_error(
|
|
mock_subprocess_run: Mock,
|
|
) -> None:
|
|
"""Test that run_git_command without git_dir can still raise errors."""
|
|
# Configure mock to return error
|
|
mock_subprocess_run.return_value = Mock(
|
|
returncode=128,
|
|
stdout=b"",
|
|
stderr=b"fatal: repository not found\n",
|
|
)
|
|
|
|
with pytest.raises(GitCommandError, match="repository not found"):
|
|
git.run_git_command(["git", "clone", "https://invalid.url/repo.git"])
|
|
|
|
|
|
def test_clone_or_update_with_never_refresh(
|
|
tmp_path: Path, mock_run_git_command: Mock
|
|
) -> None:
|
|
"""Test that NEVER_REFRESH skips updates for existing repos."""
|
|
# Set up CORE.config_path so data_dir uses tmp_path
|
|
CORE.config_path = tmp_path / "test.yaml"
|
|
|
|
url = "https://github.com/test/repo"
|
|
ref = None
|
|
domain = "test"
|
|
repo_dir = _compute_repo_dir(url, ref, domain)
|
|
|
|
# Create the git repo directory structure
|
|
repo_dir.mkdir(parents=True)
|
|
git_dir = repo_dir / ".git"
|
|
git_dir.mkdir()
|
|
_mark_clone_complete(repo_dir)
|
|
|
|
# Create FETCH_HEAD file with current timestamp
|
|
fetch_head = git_dir / "FETCH_HEAD"
|
|
fetch_head.write_text("test")
|
|
|
|
# Call with NEVER_REFRESH
|
|
result_dir, revert = git.clone_or_update(
|
|
url=url,
|
|
ref=ref,
|
|
refresh=git.NEVER_REFRESH,
|
|
domain=domain,
|
|
)
|
|
|
|
# Should NOT call git commands since NEVER_REFRESH and repo exists
|
|
mock_run_git_command.assert_not_called()
|
|
assert result_dir == repo_dir
|
|
assert revert is None
|
|
|
|
|
|
def test_clone_or_update_skips_when_core_skip_external_update(
|
|
tmp_path: Path, mock_run_git_command: Mock
|
|
) -> None:
|
|
"""CORE.skip_external_update short-circuits the refresh for existing repos."""
|
|
CORE.config_path = tmp_path / "test.yaml"
|
|
|
|
url = "https://github.com/test/repo"
|
|
ref = None
|
|
domain = "test"
|
|
repo_dir = _compute_repo_dir(url, ref, domain)
|
|
|
|
repo_dir.mkdir(parents=True)
|
|
git_dir = repo_dir / ".git"
|
|
git_dir.mkdir()
|
|
_mark_clone_complete(repo_dir)
|
|
(git_dir / "FETCH_HEAD").write_text("test")
|
|
|
|
CORE.skip_external_update = True
|
|
result_dir, revert = git.clone_or_update(
|
|
url=url,
|
|
ref=ref,
|
|
refresh=TimePeriodSeconds(days=1),
|
|
domain=domain,
|
|
)
|
|
|
|
mock_run_git_command.assert_not_called()
|
|
assert result_dir == repo_dir
|
|
assert revert is None
|
|
|
|
|
|
def test_clone_or_update_with_refresh_updates_old_repo(
|
|
tmp_path: Path, mock_run_git_command: Mock
|
|
) -> None:
|
|
"""Test that refresh triggers update for old repos."""
|
|
# Set up CORE.config_path so data_dir uses tmp_path
|
|
CORE.config_path = tmp_path / "test.yaml"
|
|
|
|
url = "https://github.com/test/repo"
|
|
ref = None
|
|
domain = "test"
|
|
repo_dir = _compute_repo_dir(url, ref, domain)
|
|
|
|
# Create the git repo directory structure
|
|
repo_dir.mkdir(parents=True)
|
|
git_dir = repo_dir / ".git"
|
|
git_dir.mkdir()
|
|
_mark_clone_complete(repo_dir)
|
|
|
|
# Create FETCH_HEAD file with old timestamp (2 days ago)
|
|
fetch_head = git_dir / "FETCH_HEAD"
|
|
fetch_head.write_text("test")
|
|
old_time = time.time() - 2 * 86400
|
|
fetch_head.touch() # Create the file
|
|
# Set modification time to 2 days ago
|
|
os.utime(fetch_head, (old_time, old_time))
|
|
|
|
# Mock git command responses
|
|
mock_run_git_command.return_value = "abc123" # SHA for rev-parse
|
|
|
|
# Call with refresh=1d (1 day)
|
|
refresh = TimePeriodSeconds(days=1)
|
|
result_dir, revert = git.clone_or_update(
|
|
url=url,
|
|
ref=ref,
|
|
refresh=refresh,
|
|
domain=domain,
|
|
)
|
|
|
|
# Should call git fetch and update commands since repo is older than refresh
|
|
assert mock_run_git_command.called
|
|
# Check for fetch command
|
|
fetch_calls = [
|
|
call
|
|
for call in mock_run_git_command.call_args_list
|
|
if len(call[0]) > 0 and "fetch" in call[0][0]
|
|
]
|
|
assert len(fetch_calls) > 0
|
|
|
|
|
|
def test_clone_or_update_with_refresh_skips_fresh_repo(
|
|
tmp_path: Path, mock_run_git_command: Mock, caplog: pytest.LogCaptureFixture
|
|
) -> None:
|
|
"""Test that refresh doesn't update fresh repos."""
|
|
# Set up CORE.config_path so data_dir uses tmp_path
|
|
CORE.config_path = tmp_path / "test.yaml"
|
|
|
|
url = "https://github.com/test/repo"
|
|
ref = None
|
|
domain = "test"
|
|
repo_dir = _compute_repo_dir(url, ref, domain)
|
|
|
|
# Create the git repo directory structure
|
|
repo_dir.mkdir(parents=True)
|
|
git_dir = repo_dir / ".git"
|
|
git_dir.mkdir()
|
|
_mark_clone_complete(repo_dir)
|
|
|
|
# Create FETCH_HEAD file with recent timestamp (1 hour ago)
|
|
fetch_head = git_dir / "FETCH_HEAD"
|
|
fetch_head.write_text("test")
|
|
recent_time = time.time() - 3600
|
|
fetch_head.touch() # Create the file
|
|
# Set modification time to 1 hour ago
|
|
os.utime(fetch_head, (recent_time, recent_time))
|
|
|
|
# Freeze the clock at 1 hour (plus a margin larger than any filesystem
|
|
# mtime rounding) after the mtime so the logged countdown is deterministic
|
|
frozen_now = fetch_head.stat().st_mtime + 3600.5
|
|
|
|
# Call with refresh=1d (1 day)
|
|
refresh = TimePeriodSeconds(days=1)
|
|
with (
|
|
patch("esphome.git.time.time", return_value=frozen_now),
|
|
caplog.at_level(logging.INFO, logger="esphome.git"),
|
|
):
|
|
result_dir, revert = git.clone_or_update(
|
|
url=url,
|
|
ref=ref,
|
|
refresh=refresh,
|
|
domain=domain,
|
|
)
|
|
|
|
# Should NOT call git fetch since repo is fresh
|
|
mock_run_git_command.assert_not_called()
|
|
assert result_dir == repo_dir
|
|
assert revert is None
|
|
|
|
# Should tell the user the update was skipped and when the next refresh is
|
|
assert f"Skipping update for {url}@{ref}" in caplog.text
|
|
assert "will refresh on the next run after 22h 59min" in caplog.text
|
|
assert "(refresh: 1d)" in caplog.text
|
|
|
|
|
|
def test_clone_or_update_with_refresh_never_logs_refresh_disabled(
|
|
tmp_path: Path, mock_run_git_command: Mock, caplog: pytest.LogCaptureFixture
|
|
) -> None:
|
|
"""Test that a config-level refresh: never skips without a countdown log."""
|
|
# Set up CORE.config_path so data_dir uses tmp_path
|
|
CORE.config_path = tmp_path / "test.yaml"
|
|
|
|
url = "https://github.com/test/repo"
|
|
ref = None
|
|
domain = "test"
|
|
repo_dir = _compute_repo_dir(url, ref, domain)
|
|
|
|
# Create the git repo directory structure
|
|
repo_dir.mkdir(parents=True)
|
|
git_dir = repo_dir / ".git"
|
|
git_dir.mkdir()
|
|
_mark_clone_complete(repo_dir)
|
|
|
|
# Create FETCH_HEAD file with current timestamp
|
|
fetch_head = git_dir / "FETCH_HEAD"
|
|
fetch_head.write_text("test")
|
|
|
|
# refresh: never validates to 365250 days, not the NEVER_REFRESH sentinel
|
|
refresh = cv.source_refresh("never")
|
|
with caplog.at_level(logging.DEBUG, logger="esphome.git"):
|
|
result_dir, revert = git.clone_or_update(
|
|
url=url,
|
|
ref=ref,
|
|
refresh=refresh,
|
|
domain=domain,
|
|
)
|
|
|
|
mock_run_git_command.assert_not_called()
|
|
assert result_dir == repo_dir
|
|
assert revert is None
|
|
|
|
# Should log refresh disabled at debug level, not a countdown
|
|
assert f"Skipping update for {url}@{ref} (refresh disabled)" in caplog.text
|
|
assert "will refresh on the next run" not in caplog.text
|
|
|
|
|
|
def test_clone_or_update_clones_missing_repo(
|
|
tmp_path: Path, mock_run_git_command: Mock
|
|
) -> None:
|
|
"""Test that missing repos are cloned regardless of refresh setting."""
|
|
# Set up CORE.config_path so data_dir uses tmp_path
|
|
CORE.config_path = tmp_path / "test.yaml"
|
|
|
|
url = "https://github.com/test/repo"
|
|
ref = None
|
|
domain = "test"
|
|
repo_dir = _compute_repo_dir(url, ref, domain)
|
|
|
|
# Create base directory but NOT the repo itself
|
|
base_dir = tmp_path / ".esphome" / domain
|
|
base_dir.mkdir(parents=True)
|
|
# repo_dir should NOT exist
|
|
assert not repo_dir.exists()
|
|
|
|
mock_run_git_command.side_effect = _make_clone_side_effect(repo_dir)
|
|
|
|
# Test with NEVER_REFRESH - should still clone since repo doesn't exist
|
|
result_dir, revert = git.clone_or_update(
|
|
url=url,
|
|
ref=ref,
|
|
refresh=git.NEVER_REFRESH,
|
|
domain=domain,
|
|
)
|
|
|
|
# Should call git clone
|
|
assert mock_run_git_command.called
|
|
clone_calls = [
|
|
call
|
|
for call in mock_run_git_command.call_args_list
|
|
if len(call[0]) > 0 and "clone" in call[0][0]
|
|
]
|
|
assert len(clone_calls) > 0
|
|
|
|
|
|
def test_clone_or_update_with_none_refresh_always_updates(
|
|
tmp_path: Path, mock_run_git_command: Mock
|
|
) -> None:
|
|
"""Test that refresh=None always updates existing repos."""
|
|
# Set up CORE.config_path so data_dir uses tmp_path
|
|
CORE.config_path = tmp_path / "test.yaml"
|
|
|
|
url = "https://github.com/test/repo"
|
|
ref = None
|
|
domain = "test"
|
|
repo_dir = _compute_repo_dir(url, ref, domain)
|
|
|
|
# Create the git repo directory structure
|
|
repo_dir.mkdir(parents=True)
|
|
git_dir = repo_dir / ".git"
|
|
git_dir.mkdir()
|
|
_mark_clone_complete(repo_dir)
|
|
|
|
# Create FETCH_HEAD file with very recent timestamp (1 second ago)
|
|
fetch_head = git_dir / "FETCH_HEAD"
|
|
fetch_head.write_text("test")
|
|
recent_time = time.time() - 1
|
|
fetch_head.touch() # Create the file
|
|
# Set modification time to 1 second ago
|
|
os.utime(fetch_head, (recent_time, recent_time))
|
|
|
|
# Mock git command responses
|
|
mock_run_git_command.return_value = "abc123" # SHA for rev-parse
|
|
|
|
# Call with refresh=None (default behavior)
|
|
result_dir, revert = git.clone_or_update(
|
|
url=url,
|
|
ref=ref,
|
|
refresh=None,
|
|
domain=domain,
|
|
)
|
|
|
|
# Should call git fetch and update commands since refresh=None means always update
|
|
assert mock_run_git_command.called
|
|
# Check for fetch command
|
|
fetch_calls = [
|
|
call
|
|
for call in mock_run_git_command.call_args_list
|
|
if len(call[0]) > 0 and "fetch" in call[0][0]
|
|
]
|
|
assert len(fetch_calls) > 0
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("fail_command", "error_message"),
|
|
[
|
|
(
|
|
"rev-parse",
|
|
"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",
|
|
),
|
|
("reset", "fatal: Could not reset index file to revision 'FETCH_HEAD'"),
|
|
],
|
|
)
|
|
def test_clone_or_update_recovers_from_git_failures(
|
|
tmp_path: Path, mock_run_git_command: Mock, fail_command: str, error_message: str
|
|
) -> None:
|
|
"""Test that repos are re-cloned when various git commands fail."""
|
|
# Set up CORE.config_path so data_dir uses tmp_path
|
|
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)
|
|
|
|
# Use helper to set up old repo
|
|
_setup_old_repo(repo_dir)
|
|
|
|
# Track command call counts to make first call fail, subsequent calls succeed
|
|
call_counts: dict[str, int] = {}
|
|
|
|
def git_command_side_effect(
|
|
cmd: list[str], cwd: str | None = None, **kwargs: Any
|
|
) -> str:
|
|
# Determine which command this is
|
|
cmd_type = _get_git_command_type(cmd)
|
|
|
|
# Track call count for this command type
|
|
if cmd_type:
|
|
call_counts[cmd_type] = call_counts.get(cmd_type, 0) + 1
|
|
|
|
# Fail on first call to the specified command, succeed on subsequent calls
|
|
if cmd_type == fail_command and call_counts[cmd_type] == 1:
|
|
raise GitCommandError(error_message)
|
|
|
|
# Default successful responses
|
|
if cmd_type == "rev-parse":
|
|
return "abc123"
|
|
if cmd_type == "clone":
|
|
# Simulate the recovery re-clone creating the repo directory
|
|
_simulate_cloned_repo(repo_dir)
|
|
return ""
|
|
|
|
mock_run_git_command.side_effect = git_command_side_effect
|
|
|
|
refresh = TimePeriodSeconds(days=1)
|
|
result_dir, revert = git.clone_or_update(
|
|
url=url,
|
|
ref=ref,
|
|
refresh=refresh,
|
|
domain=domain,
|
|
)
|
|
|
|
# Verify recovery happened
|
|
call_list = mock_run_git_command.call_args_list
|
|
|
|
# Should have attempted the failing command
|
|
assert any(fail_command in str(c) for c in call_list)
|
|
|
|
# Should have called clone for recovery
|
|
assert any("clone" in str(c) for c in call_list)
|
|
|
|
# Verify the repo directory path is returned
|
|
assert result_dir == repo_dir
|
|
|
|
|
|
def test_clone_or_update_fails_when_recovery_also_fails(
|
|
tmp_path: Path, mock_run_git_command: Mock
|
|
) -> None:
|
|
"""Test that we don't infinitely recurse when recovery also fails."""
|
|
# Set up CORE.config_path so data_dir uses tmp_path
|
|
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)
|
|
|
|
# Use helper to set up old repo
|
|
_setup_old_repo(repo_dir)
|
|
|
|
# Mock git command to fail on clone (simulating network failure during recovery)
|
|
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":
|
|
# First time fails (broken repo)
|
|
raise GitCommandError(
|
|
"ambiguous argument 'HEAD': unknown revision or path not in the working tree."
|
|
)
|
|
if cmd_type == "clone":
|
|
# Clone also fails (recovery fails)
|
|
raise GitCommandError("fatal: unable to access repository")
|
|
return ""
|
|
|
|
mock_run_git_command.side_effect = git_command_side_effect
|
|
|
|
refresh = TimePeriodSeconds(days=1)
|
|
|
|
# Should raise after one recovery attempt fails
|
|
with pytest.raises(GitCommandError, match="fatal: unable to access repository"):
|
|
git.clone_or_update(
|
|
url=url,
|
|
ref=ref,
|
|
refresh=refresh,
|
|
domain=domain,
|
|
)
|
|
|
|
# Verify we only tried to clone once (no infinite recursion)
|
|
call_list = mock_run_git_command.call_args_list
|
|
clone_calls = [c for c in call_list if "clone" in c[0][0]]
|
|
# Should have exactly one clone call (the recovery attempt that failed)
|
|
assert len(clone_calls) == 1
|
|
# Should have tried rev-parse once (which failed and triggered recovery)
|
|
rev_parse_calls = [c for c in call_list if "rev-parse" in c[0][0]]
|
|
assert len(rev_parse_calls) == 1
|
|
|
|
|
|
def test_clone_or_update_recover_broken_flag_prevents_second_recovery(
|
|
tmp_path: Path, mock_run_git_command: Mock
|
|
) -> None:
|
|
"""Test that _recover_broken=False prevents a second recovery attempt (tests the raise path)."""
|
|
# Set up CORE.config_path so data_dir uses tmp_path
|
|
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)
|
|
|
|
# Use helper to set up old repo
|
|
_setup_old_repo(repo_dir)
|
|
|
|
# Track fetch calls to differentiate between first (in clone) and second (in recovery update)
|
|
call_counts: dict[str, int] = {}
|
|
|
|
# Mock git command to fail on fetch during recovery's ref checkout
|
|
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
|
|
|
|
# First attempt: rev-parse fails (broken repo)
|
|
if cmd_type == "rev-parse" and call_counts[cmd_type] == 1:
|
|
raise GitCommandError(
|
|
"ambiguous argument 'HEAD': unknown revision or path not in the working tree."
|
|
)
|
|
|
|
# Recovery: clone succeeds
|
|
if cmd_type == "clone":
|
|
return ""
|
|
|
|
# Recovery: fetch for ref checkout fails
|
|
# This happens in the clone path when ref is not None (line 80 in git.py)
|
|
if cmd_type == "fetch" and call_counts[cmd_type] == 1:
|
|
raise GitCommandError("fatal: couldn't find remote ref main")
|
|
|
|
# Default success
|
|
return "abc123" if cmd_type == "rev-parse" else ""
|
|
|
|
mock_run_git_command.side_effect = git_command_side_effect
|
|
|
|
refresh = TimePeriodSeconds(days=1)
|
|
|
|
# Should raise on the fetch during recovery (when _recover_broken=False)
|
|
# This tests the critical "if not _recover_broken: raise" path
|
|
with pytest.raises(GitCommandError, match="fatal: couldn't find remote ref main"):
|
|
git.clone_or_update(
|
|
url=url,
|
|
ref=ref,
|
|
refresh=refresh,
|
|
domain=domain,
|
|
)
|
|
|
|
# Verify the sequence of events
|
|
call_list = mock_run_git_command.call_args_list
|
|
|
|
# Should have: rev-parse (fail, triggers recovery), clone (success),
|
|
# fetch (fail during ref checkout, raises because _recover_broken=False)
|
|
rev_parse_calls = [c for c in call_list if "rev-parse" in c[0][0]]
|
|
# Should have exactly one rev-parse call that failed
|
|
assert len(rev_parse_calls) == 1
|
|
|
|
clone_calls = [c for c in call_list if "clone" in c[0][0]]
|
|
# Should have exactly one clone call (the recovery attempt)
|
|
assert len(clone_calls) == 1
|
|
|
|
fetch_calls = [c for c in call_list if "fetch" in c[0][0]]
|
|
# Should have exactly one fetch call that failed (during ref checkout in recovery)
|
|
assert len(fetch_calls) == 1
|
|
|
|
|
|
def test_clone_or_update_recover_broken_flag_prevents_infinite_loop(
|
|
tmp_path: Path, mock_run_git_command: Mock
|
|
) -> None:
|
|
"""Test that _recover_broken=False prevents infinite recursion when repo persists."""
|
|
# This tests the critical "if not _recover_broken: raise" path at line 124-125
|
|
# Set up CORE.config_path so data_dir uses tmp_path
|
|
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)
|
|
|
|
# Use helper to set up old repo
|
|
_setup_old_repo(repo_dir)
|
|
|
|
# Mock shutil.rmtree to NOT actually delete the directory
|
|
# This simulates a scenario where deletion fails (permissions, etc.)
|
|
import unittest.mock
|
|
|
|
def mock_rmtree(path, *args, **kwargs):
|
|
# Don't actually delete - this causes the recursive call to still see the repo
|
|
pass
|
|
|
|
# Mock git commands to always fail on stash
|
|
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 == "stash":
|
|
# Always fails
|
|
raise GitCommandError("fatal: unable to write new index file")
|
|
return ""
|
|
|
|
mock_run_git_command.side_effect = git_command_side_effect
|
|
|
|
refresh = TimePeriodSeconds(days=1)
|
|
|
|
# Mock shutil.rmtree and test
|
|
# Should raise on the second attempt when _recover_broken=False
|
|
# This hits the "if not _recover_broken: raise" path
|
|
with (
|
|
unittest.mock.patch("esphome.git.rmtree", side_effect=mock_rmtree),
|
|
pytest.raises(GitCommandError, match="fatal: unable to write new index file"),
|
|
):
|
|
git.clone_or_update(
|
|
url=url,
|
|
ref=ref,
|
|
refresh=refresh,
|
|
domain=domain,
|
|
)
|
|
|
|
# Verify the sequence: stash fails twice (once triggering recovery, once raising)
|
|
call_list = mock_run_git_command.call_args_list
|
|
stash_calls = [c for c in call_list if "stash" in c[0][0]]
|
|
# Should have exactly two stash calls
|
|
assert len(stash_calls) == 2
|
|
|
|
|
|
def test_clone_or_update_cleans_up_on_failed_ref_fetch(
|
|
tmp_path: Path, mock_run_git_command: Mock
|
|
) -> None:
|
|
"""Test that a failed ref fetch removes the incomplete clone directory.
|
|
|
|
When cloning with a specific ref, if `git clone` succeeds but the
|
|
subsequent `git fetch <ref>` fails, the clone directory should be
|
|
removed so the next attempt starts fresh instead of finding a stale
|
|
clone on the default branch.
|
|
"""
|
|
CORE.config_path = tmp_path / "test.yaml"
|
|
|
|
url = "https://github.com/test/repo"
|
|
ref = "pull/123/head"
|
|
domain = "test"
|
|
repo_dir = _compute_repo_dir(url, ref, domain)
|
|
|
|
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 == "clone":
|
|
# Simulate successful clone by creating the directory
|
|
repo_dir.mkdir(parents=True, exist_ok=True)
|
|
(repo_dir / ".git").mkdir(exist_ok=True)
|
|
return ""
|
|
if cmd_type == "fetch":
|
|
raise GitCommandError("fatal: couldn't find remote ref pull/123/head")
|
|
return ""
|
|
|
|
mock_run_git_command.side_effect = git_command_side_effect
|
|
|
|
refresh = TimePeriodSeconds(days=1)
|
|
|
|
with pytest.raises(GitCommandError, match="couldn't find remote ref"):
|
|
git.clone_or_update(
|
|
url=url,
|
|
ref=ref,
|
|
refresh=refresh,
|
|
domain=domain,
|
|
)
|
|
|
|
# The incomplete clone directory should have been removed
|
|
assert not repo_dir.exists()
|
|
|
|
# Verify clone was attempted then fetch failed
|
|
call_list = mock_run_git_command.call_args_list
|
|
clone_calls = [c for c in call_list if "clone" in c[0][0]]
|
|
assert len(clone_calls) == 1
|
|
fetch_calls = [c for c in call_list if "fetch" in c[0][0]]
|
|
assert len(fetch_calls) == 1
|
|
|
|
|
|
def test_clone_or_update_stale_clone_is_retried_after_cleanup(
|
|
tmp_path: Path, mock_run_git_command: Mock
|
|
) -> None:
|
|
"""Test that after cleanup, a subsequent call does a fresh clone.
|
|
|
|
This is the full scenario: first call fails at fetch (directory cleaned up),
|
|
second call sees no directory and clones fresh.
|
|
"""
|
|
CORE.config_path = tmp_path / "test.yaml"
|
|
|
|
url = "https://github.com/test/repo"
|
|
ref = "pull/123/head"
|
|
domain = "test"
|
|
repo_dir = _compute_repo_dir(url, ref, domain)
|
|
|
|
call_count = {"clone": 0, "fetch": 0}
|
|
|
|
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 == "clone":
|
|
call_count["clone"] += 1
|
|
repo_dir.mkdir(parents=True, exist_ok=True)
|
|
(repo_dir / ".git").mkdir(exist_ok=True)
|
|
return ""
|
|
if cmd_type == "fetch":
|
|
call_count["fetch"] += 1
|
|
if call_count["fetch"] == 1:
|
|
# First fetch fails
|
|
raise GitCommandError("fatal: couldn't find remote ref pull/123/head")
|
|
# Second fetch succeeds
|
|
return ""
|
|
if cmd_type == "reset":
|
|
return ""
|
|
return ""
|
|
|
|
mock_run_git_command.side_effect = git_command_side_effect
|
|
|
|
refresh = TimePeriodSeconds(days=1)
|
|
|
|
# First call: clone succeeds, fetch fails, directory cleaned up
|
|
with pytest.raises(GitCommandError, match="couldn't find remote ref"):
|
|
git.clone_or_update(url=url, ref=ref, refresh=refresh, domain=domain)
|
|
|
|
assert not repo_dir.exists()
|
|
|
|
# Second call: fresh clone + fetch succeeds
|
|
result_dir, _ = git.clone_or_update(
|
|
url=url, ref=ref, refresh=refresh, domain=domain
|
|
)
|
|
|
|
assert result_dir == repo_dir
|
|
assert repo_dir.exists()
|
|
assert call_count["clone"] == 2
|
|
assert call_count["fetch"] == 2
|
|
|
|
|
|
def test_clone_or_update_recloned_when_marker_missing_with_never_refresh(
|
|
tmp_path: Path, mock_run_git_command: Mock
|
|
) -> None:
|
|
"""A repo dir without the completion marker is an interrupted clone.
|
|
|
|
It must be removed and re-cloned even with NEVER_REFRESH, which would
|
|
otherwise trust the broken directory forever.
|
|
"""
|
|
CORE.config_path = tmp_path / "test.yaml"
|
|
|
|
url = "https://github.com/test/repo"
|
|
ref = "1.8.4"
|
|
domain = "test"
|
|
repo_dir = _compute_repo_dir(url, ref, domain)
|
|
|
|
# Simulate an interrupted clone: directory exists, no marker
|
|
repo_dir.mkdir(parents=True)
|
|
(repo_dir / ".git").mkdir()
|
|
|
|
mock_run_git_command.side_effect = _make_clone_side_effect(repo_dir)
|
|
|
|
result_dir, _ = git.clone_or_update(
|
|
url=url, ref=ref, refresh=git.NEVER_REFRESH, domain=domain
|
|
)
|
|
|
|
clone_calls = [c for c in mock_run_git_command.call_args_list if "clone" in c[0][0]]
|
|
assert len(clone_calls) == 1
|
|
assert result_dir == repo_dir
|
|
# The fresh clone completed, so the marker must now be present
|
|
assert _marker_path(repo_dir).is_file()
|
|
|
|
|
|
def test_clone_or_update_recloned_when_marker_missing_with_skip_external_update(
|
|
tmp_path: Path, mock_run_git_command: Mock
|
|
) -> None:
|
|
"""skip_external_update must not preserve an interrupted clone."""
|
|
CORE.config_path = tmp_path / "test.yaml"
|
|
|
|
url = "https://github.com/test/repo"
|
|
domain = "test"
|
|
repo_dir = _compute_repo_dir(url, None, domain)
|
|
|
|
repo_dir.mkdir(parents=True)
|
|
(repo_dir / ".git").mkdir()
|
|
|
|
mock_run_git_command.side_effect = _make_clone_side_effect(repo_dir)
|
|
|
|
CORE.skip_external_update = True
|
|
result_dir, _ = git.clone_or_update(
|
|
url=url, ref=None, refresh=TimePeriodSeconds(days=1), domain=domain
|
|
)
|
|
|
|
clone_calls = [c for c in mock_run_git_command.call_args_list if "clone" in c[0][0]]
|
|
assert len(clone_calls) == 1
|
|
assert result_dir == repo_dir
|
|
assert _marker_path(repo_dir).is_file()
|
|
|
|
|
|
def test_fresh_clone_writes_completion_marker_with_debug_info(
|
|
tmp_path: Path, mock_run_git_command: Mock
|
|
) -> None:
|
|
"""The marker is written after a fresh clone and records key and hash dir."""
|
|
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)
|
|
|
|
mock_run_git_command.side_effect = _make_clone_side_effect(repo_dir)
|
|
|
|
git.clone_or_update(url=url, ref=ref, refresh=git.NEVER_REFRESH, domain=domain)
|
|
|
|
marker = _marker_path(repo_dir)
|
|
assert marker.is_file()
|
|
content = marker.read_text()
|
|
assert f"{url}@{ref}" in content
|
|
assert repo_dir.name in content
|
|
|
|
|
|
def test_marker_is_deleted_before_rmtree(
|
|
tmp_path: Path, mock_run_git_command: Mock
|
|
) -> None:
|
|
"""The marker must be gone even if rmtree fails partway.
|
|
|
|
Simulated by an rmtree that does nothing: the directory survives but the
|
|
marker must already have been deleted, so the next run still re-clones
|
|
instead of trusting a partially deleted worktree.
|
|
"""
|
|
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)
|
|
assert _marker_path(repo_dir).is_file()
|
|
|
|
def git_command_side_effect(
|
|
cmd: list[str], cwd: str | None = None, **kwargs: Any
|
|
) -> str:
|
|
if _get_git_command_type(cmd) == "stash":
|
|
raise GitCommandError("fatal: unable to write new index file")
|
|
return "abc123"
|
|
|
|
mock_run_git_command.side_effect = git_command_side_effect
|
|
|
|
with (
|
|
patch("esphome.git.rmtree"),
|
|
pytest.raises(GitCommandError),
|
|
):
|
|
git.clone_or_update(
|
|
url=url, ref=ref, refresh=TimePeriodSeconds(days=1), domain=domain
|
|
)
|
|
|
|
# rmtree never deleted anything, yet the marker is gone
|
|
assert repo_dir.is_dir()
|
|
assert not _marker_path(repo_dir).is_file()
|
|
|
|
|
|
def test_failed_marker_write_does_not_fail_the_clone(
|
|
tmp_path: Path,
|
|
mock_run_git_command: Mock,
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
"""A marker write failure must not fail an otherwise complete clone.
|
|
|
|
The clone is valid; the missing marker only costs a re-clone on the next
|
|
run, so the error is logged as a warning instead of propagating.
|
|
"""
|
|
CORE.config_path = tmp_path / "test.yaml"
|
|
|
|
url = "https://github.com/test/repo"
|
|
domain = "test"
|
|
repo_dir = _compute_repo_dir(url, None, domain)
|
|
|
|
mock_run_git_command.side_effect = _make_clone_side_effect(repo_dir)
|
|
|
|
with patch(
|
|
"esphome.git.write_file", side_effect=EsphomeError("Could not write file")
|
|
):
|
|
result_dir, _ = git.clone_or_update(
|
|
url=url, ref=None, refresh=git.NEVER_REFRESH, domain=domain
|
|
)
|
|
|
|
assert result_dir == repo_dir
|
|
assert not _marker_path(repo_dir).is_file()
|
|
assert "Could not write clone completion marker" in caplog.text
|
|
|
|
|
|
def test_corrupt_git_dir_without_head_recovers(
|
|
tmp_path: Path, mock_run_git_command: Mock
|
|
) -> None:
|
|
"""A .git with neither FETCH_HEAD nor HEAD must recover, not crash.
|
|
|
|
The age check stats FETCH_HEAD falling back to HEAD; if both are gone
|
|
(partially deleted clone) the stat raised an unhandled FileNotFoundError
|
|
before the broken-repository recovery could run.
|
|
"""
|
|
CORE.config_path = tmp_path / "test.yaml"
|
|
|
|
url = "https://github.com/test/repo"
|
|
domain = "test"
|
|
repo_dir = _compute_repo_dir(url, None, domain)
|
|
|
|
# Marker present but .git gutted: no FETCH_HEAD, no HEAD
|
|
repo_dir.mkdir(parents=True)
|
|
(repo_dir / ".git").mkdir()
|
|
_mark_clone_complete(repo_dir)
|
|
|
|
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":
|
|
raise GitCommandError("ambiguous argument 'HEAD': unknown revision")
|
|
if cmd_type == "clone":
|
|
_simulate_cloned_repo(repo_dir)
|
|
return ""
|
|
|
|
mock_run_git_command.side_effect = git_command_side_effect
|
|
|
|
result_dir, _ = git.clone_or_update(
|
|
url=url, ref=None, refresh=TimePeriodSeconds(days=1), domain=domain
|
|
)
|
|
|
|
assert result_dir == repo_dir
|
|
clone_calls = [c for c in mock_run_git_command.call_args_list if "clone" in c[0][0]]
|
|
assert len(clone_calls) == 1
|
|
assert _marker_path(repo_dir).is_file()
|
|
|
|
|
|
def test_remove_repo_dir_tolerates_marker_unlink_failure(tmp_path: Path) -> None:
|
|
"""A locked marker file must not abort the directory removal."""
|
|
repo_dir = tmp_path / "repo"
|
|
repo_dir.mkdir()
|
|
(repo_dir / ".git").mkdir()
|
|
_mark_clone_complete(repo_dir)
|
|
|
|
with patch.object(Path, "unlink", side_effect=PermissionError("locked")):
|
|
git._remove_repo_dir(repo_dir)
|
|
|
|
# rmtree still removed the directory, marker included
|
|
assert not repo_dir.exists()
|
|
|
|
|
|
def test_clone_or_update_recovery_preserves_subpath(
|
|
tmp_path: Path, mock_run_git_command: Mock
|
|
) -> None:
|
|
"""Recovery must re-clone into the same subpath-ed directory.
|
|
|
|
Without passing subpath through, the recursive recovery call would
|
|
recompute the destination without the subpath and clone (and write the
|
|
completion marker) at the wrong location.
|
|
"""
|
|
CORE.config_path = tmp_path / "test.yaml"
|
|
|
|
url = "https://github.com/test/repo"
|
|
ref = "main"
|
|
domain = "test"
|
|
subpath = Path("mylib")
|
|
repo_dir = _compute_repo_dir(url, ref, domain) / subpath
|
|
|
|
_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
|
|
# First rev-parse fails (broken repo) to trigger recovery
|
|
if cmd_type == "rev-parse" and call_counts[cmd_type] == 1:
|
|
raise GitCommandError(
|
|
"ambiguous argument 'HEAD': unknown revision or path not in the working tree."
|
|
)
|
|
if cmd_type == "clone":
|
|
# Create whatever directory the clone was asked to target
|
|
target = Path(cmd[-1])
|
|
target.mkdir(parents=True, exist_ok=True)
|
|
(target / ".git").mkdir(exist_ok=True)
|
|
if cmd_type == "rev-parse":
|
|
return "abc123"
|
|
return ""
|
|
|
|
mock_run_git_command.side_effect = git_command_side_effect
|
|
|
|
result_dir, _ = git.clone_or_update(
|
|
url=url,
|
|
ref=ref,
|
|
refresh=TimePeriodSeconds(days=1),
|
|
domain=domain,
|
|
subpath=subpath,
|
|
)
|
|
|
|
# The recovery re-clone must target the subpath-ed directory and the
|
|
# completion marker must land there too
|
|
clone_calls = [c for c in mock_run_git_command.call_args_list if "clone" in c[0][0]]
|
|
assert len(clone_calls) == 1
|
|
assert clone_calls[0][0][0][-1] == str(repo_dir)
|
|
assert result_dir == repo_dir
|
|
assert _marker_path(repo_dir).is_file()
|
|
|
|
|
|
def test_clone_with_ref_uses_shallow_fetch(
|
|
tmp_path: Path, mock_run_git_command: Mock
|
|
) -> None:
|
|
"""Clone with a ref should use --depth=1 on both clone and fetch."""
|
|
CORE.config_path = tmp_path / "test.yaml"
|
|
|
|
url = "https://github.com/test/repo"
|
|
ref = "pull/123/head"
|
|
domain = "test"
|
|
repo_dir = _compute_repo_dir(url, ref, domain)
|
|
|
|
def git_command_side_effect(
|
|
cmd: list[str], cwd: str | None = None, **kwargs: Any
|
|
) -> str:
|
|
if _get_git_command_type(cmd) == "clone":
|
|
repo_dir.mkdir(parents=True, exist_ok=True)
|
|
(repo_dir / ".git").mkdir(exist_ok=True)
|
|
return ""
|
|
|
|
mock_run_git_command.side_effect = git_command_side_effect
|
|
|
|
git.clone_or_update(url=url, ref=ref, refresh=None, domain=domain)
|
|
|
|
call_list = mock_run_git_command.call_args_list
|
|
|
|
clone_calls = [c for c in call_list if "clone" in c[0][0]]
|
|
assert len(clone_calls) == 1
|
|
assert "--depth=1" in clone_calls[0][0][0]
|
|
|
|
fetch_calls = [c for c in call_list if "fetch" in c[0][0]]
|
|
assert len(fetch_calls) == 1
|
|
assert "--depth=1" in fetch_calls[0][0][0]
|
|
# Ref must still be passed so the requested commit/branch is fetched.
|
|
assert ref in fetch_calls[0][0][0]
|
|
|
|
|
|
def test_refresh_fetch_is_shallow(tmp_path: Path, mock_run_git_command: Mock) -> None:
|
|
"""The refresh-path fetch should use --depth=1."""
|
|
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)
|
|
mock_run_git_command.return_value = "abc123"
|
|
|
|
git.clone_or_update(
|
|
url=url, ref=ref, refresh=TimePeriodSeconds(days=1), domain=domain
|
|
)
|
|
|
|
fetch_calls = [c for c in mock_run_git_command.call_args_list if "fetch" in c[0][0]]
|
|
assert len(fetch_calls) == 1
|
|
cmd = fetch_calls[0][0][0]
|
|
assert "--depth=1" in cmd
|
|
# Ref must still be in the refresh fetch so the right tip is updated.
|
|
assert cmd[-1] == ref
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"refresh", [None, TimePeriodSeconds(days=1)], ids=["clone", "refresh"]
|
|
)
|
|
def test_all_submodules_skipped_without_gitmodules(
|
|
tmp_path: Path, mock_run_git_command: Mock, refresh: TimePeriodSeconds | None
|
|
) -> None:
|
|
"""init_submodules is a no-op for repos with no .gitmodules.
|
|
|
|
This is the esp-idf toolchain library scenario from issue #17860: the
|
|
PlatformIO library converter requests "all submodules" for every git
|
|
library, and most libraries declare none. The git submodule porcelain
|
|
must not run at all in that case — it fails outright on some git
|
|
installations.
|
|
"""
|
|
CORE.config_path = tmp_path / "test.yaml"
|
|
|
|
url = "https://github.com/test/repo"
|
|
domain = "test"
|
|
repo_dir = _compute_repo_dir(url, None, domain)
|
|
|
|
if refresh is None:
|
|
mock_run_git_command.side_effect = _make_clone_side_effect(repo_dir)
|
|
else:
|
|
_setup_old_repo(repo_dir)
|
|
mock_run_git_command.return_value = "abc123"
|
|
|
|
git.clone_or_update(
|
|
url=url,
|
|
ref=None,
|
|
refresh=refresh,
|
|
domain=domain,
|
|
init_submodules=True,
|
|
)
|
|
|
|
assert not _submodule_calls(mock_run_git_command)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"refresh", [None, TimePeriodSeconds(days=1)], ids=["clone", "refresh"]
|
|
)
|
|
def test_all_submodules_updated_with_gitmodules(
|
|
tmp_path: Path, mock_run_git_command: Mock, refresh: TimePeriodSeconds | None
|
|
) -> None:
|
|
"""init_submodules initializes all submodules when .gitmodules exists."""
|
|
CORE.config_path = tmp_path / "test.yaml"
|
|
|
|
url = "https://github.com/test/repo"
|
|
domain = "test"
|
|
repo_dir = _compute_repo_dir(url, None, 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=None,
|
|
refresh=refresh,
|
|
domain=domain,
|
|
init_submodules=True,
|
|
)
|
|
|
|
submodule_calls = _submodule_calls(mock_run_git_command)
|
|
# Which submodules get populated is git's own policy, so no status
|
|
# verification follows the update.
|
|
assert len(submodule_calls) == 1
|
|
cmd = submodule_calls[0][0][0]
|
|
assert cmd[2] == "update"
|
|
assert "--depth=1" in cmd
|
|
# Recursive, mirroring PlatformIO's recursive library clones.
|
|
assert "--recursive" in cmd
|
|
_assert_submodule_runs_without_isolation(submodule_calls[0], repo_dir)
|
|
|
|
|
|
def test_recovery_reclone_keeps_credentials_and_cache_key(
|
|
tmp_path: Path, mock_run_git_command: Mock
|
|
) -> None:
|
|
"""The recovery re-clone must not re-apply credentials to the already
|
|
rewritten URL (no doubled userinfo) and must land in the same cache
|
|
directory, or a credentialed private repo re-clones on every run."""
|
|
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")
|
|
|
|
calls = {"submodule": 0}
|
|
|
|
def git_command_side_effect(
|
|
cmd: list[str], cwd: str | None = None, **kwargs: Any
|
|
) -> str:
|
|
if _get_git_command_type(cmd) == "clone":
|
|
_simulate_cloned_repo(repo_dir)
|
|
if _get_git_command_type(cmd) == "submodule":
|
|
calls["submodule"] += 1
|
|
if calls["submodule"] == 1:
|
|
raise git.GitCommandError("git submodule update exited with code 1")
|
|
return ""
|
|
|
|
mock_run_git_command.side_effect = git_command_side_effect
|
|
|
|
recovered_dir, _ = git.clone_or_update(
|
|
url=url,
|
|
ref=None,
|
|
refresh=TimePeriodSeconds(days=1),
|
|
domain=domain,
|
|
username="user",
|
|
password="hunter2",
|
|
init_submodules=True,
|
|
)
|
|
|
|
assert recovered_dir == repo_dir
|
|
clone_cmds = [
|
|
c[0][0]
|
|
for c in mock_run_git_command.call_args_list
|
|
if _get_git_command_type(c[0][0]) == "clone"
|
|
]
|
|
assert clone_cmds
|
|
clone_url = clone_cmds[0][-2]
|
|
assert clone_url == "https://user:hunter2@github.com/test/repo"
|
|
assert clone_url.count("@") == 1
|
|
|
|
|
|
def test_refresh_submodule_failure_recovers_then_raises(
|
|
tmp_path: Path, mock_run_git_command: Mock
|
|
) -> None:
|
|
"""A refresh-path submodule failure routes through the recovery re-clone.
|
|
|
|
The broken repo is removed and re-cloned; when the submodule update fails
|
|
again on the fresh clone the cache entry is removed and the error
|
|
propagates, instead of leaving behind a repo the refresh window would
|
|
silently accept on the next run.
|
|
"""
|
|
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")
|
|
|
|
def git_command_side_effect(
|
|
cmd: list[str], cwd: str | None = None, **kwargs: Any
|
|
) -> str:
|
|
if _get_git_command_type(cmd) == "clone":
|
|
_simulate_cloned_repo(repo_dir)
|
|
(repo_dir / ".gitmodules").write_text("test")
|
|
if _get_git_command_type(cmd) == "submodule":
|
|
raise git.GitCommandError("git submodule update exited with code 1")
|
|
return ""
|
|
|
|
mock_run_git_command.side_effect = git_command_side_effect
|
|
|
|
with pytest.raises(git.GitCommandError, match="exited with code 1"):
|
|
git.clone_or_update(
|
|
url=url,
|
|
ref=None,
|
|
refresh=TimePeriodSeconds(days=1),
|
|
domain=domain,
|
|
init_submodules=True,
|
|
)
|
|
|
|
assert not repo_dir.is_dir()
|
|
# Recovery removed the repo and re-cloned before failing again.
|
|
assert any(
|
|
_get_git_command_type(c[0][0]) == "clone"
|
|
for c in mock_run_git_command.call_args_list
|
|
)
|
|
|
|
|
|
def _lock_path(url: str, ref: str | None, domain: str) -> Path:
|
|
"""The lock file the implementation uses for one cache entry."""
|
|
return git._repo_lock_path(git._cache_key(url, ref), domain)
|
|
|
|
|
|
class _SetEventOnWaitLog(logging.Handler):
|
|
"""Set an event when the 'Waiting for another process' record is emitted,
|
|
so tests can react to a caller observably blocking on the lock instead of
|
|
racing a wall-clock timer."""
|
|
|
|
def __init__(self, event: threading.Event) -> None:
|
|
super().__init__()
|
|
self._event = event
|
|
|
|
def emit(self, record: logging.LogRecord) -> None:
|
|
if "Waiting for another process" in record.getMessage():
|
|
self._event.set()
|
|
|
|
|
|
def test_clone_or_update_serializes_concurrent_clones(
|
|
tmp_path: Path, mock_run_git_command: Mock, caplog: pytest.LogCaptureFixture
|
|
) -> None:
|
|
"""Two concurrent callers for the same uncached repo must not both clone.
|
|
|
|
Without the per-entry lock both callers pass the is_dir() check before
|
|
either clone finishes, so the second one either clones on top of the
|
|
first or reads a half populated worktree (device-builder issue 2425).
|
|
"""
|
|
CORE.config_path = tmp_path / "test.yaml"
|
|
|
|
url = "https://github.com/test/repo"
|
|
domain = "test"
|
|
repo_dir = _compute_repo_dir(url, None, domain)
|
|
|
|
start_together = threading.Barrier(2)
|
|
other_caller_waiting = threading.Event()
|
|
|
|
def on_clone() -> None:
|
|
# Hold the lock until the other caller is observably blocked on it,
|
|
# so the interleaving is guaranteed rather than raced on a timer.
|
|
assert other_caller_waiting.wait(timeout=30)
|
|
|
|
mock_run_git_command.side_effect = _make_clone_side_effect(
|
|
repo_dir, on_clone=on_clone
|
|
)
|
|
|
|
results: list[Path] = []
|
|
errors: list[BaseException] = []
|
|
|
|
def call() -> None:
|
|
try:
|
|
start_together.wait()
|
|
result_dir, _ = git.clone_or_update(
|
|
url=url, ref=None, refresh=git.NEVER_REFRESH, domain=domain
|
|
)
|
|
results.append(result_dir)
|
|
except BaseException as err: # noqa: BLE001 - re-raised via errors below
|
|
errors.append(err)
|
|
|
|
handler = _SetEventOnWaitLog(other_caller_waiting)
|
|
git_logger = logging.getLogger("esphome.git")
|
|
git_logger.addHandler(handler)
|
|
try:
|
|
with caplog.at_level(logging.INFO):
|
|
threads = [threading.Thread(target=call) for _ in range(2)]
|
|
for t in threads:
|
|
t.start()
|
|
for t in threads:
|
|
t.join()
|
|
finally:
|
|
git_logger.removeHandler(handler)
|
|
|
|
assert not errors
|
|
assert results == [repo_dir, repo_dir]
|
|
clone_calls = [
|
|
c
|
|
for c in mock_run_git_command.call_args_list
|
|
if _get_git_command_type(c[0][0]) == "clone"
|
|
]
|
|
# The second caller waited for the lock, then saw the completed clone.
|
|
assert len(clone_calls) == 1
|
|
assert _marker_path(repo_dir).is_file()
|
|
|
|
|
|
def test_clone_or_update_creates_lock_file_next_to_hash_dir(
|
|
tmp_path: Path, mock_run_git_command: Mock
|
|
) -> None:
|
|
"""The lock file lives beside the hash dir, never inside it.
|
|
|
|
Checked while the clone runs (the lock is held): filelock's Windows
|
|
backend deletes the lock file on release, so probing after the call
|
|
would only work on Unix.
|
|
"""
|
|
CORE.config_path = tmp_path / "test.yaml"
|
|
|
|
url = "https://github.com/test/repo"
|
|
domain = "test"
|
|
repo_dir = _compute_repo_dir(url, None, domain)
|
|
lock_path = _lock_path(url, None, domain)
|
|
lock_held_during_clone: list[bool] = []
|
|
|
|
mock_run_git_command.side_effect = _make_clone_side_effect(
|
|
repo_dir, on_clone=lambda: lock_held_during_clone.append(lock_path.is_file())
|
|
)
|
|
|
|
result_dir, _ = git.clone_or_update(
|
|
url=url, ref=None, refresh=git.NEVER_REFRESH, domain=domain
|
|
)
|
|
|
|
assert result_dir == repo_dir
|
|
assert lock_path.parent == repo_dir.parent
|
|
assert lock_held_during_clone == [True]
|
|
|
|
|
|
def test_clone_or_update_subpath_locks_at_hash_dir_level(
|
|
tmp_path: Path, mock_run_git_command: Mock
|
|
) -> None:
|
|
"""With a subpath the lock still guards the whole hash dir cache entry."""
|
|
CORE.config_path = tmp_path / "test.yaml"
|
|
|
|
url = "https://github.com/test/repo"
|
|
domain = "test"
|
|
hash_dir = _compute_repo_dir(url, None, domain)
|
|
repo_dir = hash_dir / "lib"
|
|
lock_path = _lock_path(url, None, domain)
|
|
lock_held_during_clone: list[bool] = []
|
|
|
|
mock_run_git_command.side_effect = _make_clone_side_effect(
|
|
repo_dir, on_clone=lambda: lock_held_during_clone.append(lock_path.is_file())
|
|
)
|
|
|
|
result_dir, _ = git.clone_or_update(
|
|
url=url,
|
|
ref=None,
|
|
refresh=git.NEVER_REFRESH,
|
|
domain=domain,
|
|
subpath=Path("lib"),
|
|
)
|
|
|
|
assert result_dir == repo_dir
|
|
assert lock_path == hash_dir.parent / f"{hash_dir.name}.lock"
|
|
assert lock_held_during_clone == [True]
|
|
|
|
|
|
def test_clone_or_update_recovery_holds_lock_without_deadlock(
|
|
tmp_path: Path, mock_run_git_command: Mock
|
|
) -> None:
|
|
"""Recovery re-clones while holding the lock and must not re-acquire it.
|
|
|
|
A naive re-acquisition would deadlock here, since OS file locks taken on
|
|
separate descriptors conflict even within one process. The lock file
|
|
itself must survive the recovery rmtree of the broken repo dir: the
|
|
re-clone runs after the rmtree, so probing the lock file there proves
|
|
it. Probing after the call would only work on Unix, since filelock's
|
|
Windows backend deletes the lock file on release.
|
|
"""
|
|
CORE.config_path = tmp_path / "test.yaml"
|
|
|
|
url = "https://github.com/test/repo"
|
|
domain = "test"
|
|
repo_dir = _compute_repo_dir(url, None, domain)
|
|
lock_path = _lock_path(url, None, domain)
|
|
lock_held_during_reclone: list[bool] = []
|
|
|
|
_setup_old_repo(repo_dir)
|
|
|
|
def git_command_side_effect(
|
|
cmd: list[str], cwd: str | None = None, **kwargs: Any
|
|
) -> str:
|
|
if _get_git_command_type(cmd) == "stash":
|
|
raise git.GitCommandError("broken repository")
|
|
if _get_git_command_type(cmd) == "clone":
|
|
lock_held_during_reclone.append(lock_path.is_file())
|
|
_simulate_cloned_repo(repo_dir)
|
|
return ""
|
|
|
|
mock_run_git_command.side_effect = git_command_side_effect
|
|
|
|
recovered_dir, _ = git.clone_or_update(
|
|
url=url, ref=None, refresh=TimePeriodSeconds(days=1), domain=domain
|
|
)
|
|
|
|
assert recovered_dir == repo_dir
|
|
assert lock_held_during_reclone == [True]
|
|
|
|
|
|
def _hold_lock_in_thread(lock_path: Path) -> tuple[threading.Thread, threading.Event]:
|
|
"""Hold the lock from another thread; returns the thread and its release event.
|
|
|
|
OS file locks taken on separate descriptors conflict even within one
|
|
process, so a second FileLock instance in a thread contends the same
|
|
way another process would.
|
|
"""
|
|
held = threading.Event()
|
|
release = threading.Event()
|
|
|
|
def hold() -> None:
|
|
with FileLock(str(lock_path)):
|
|
held.set()
|
|
release.wait(timeout=30)
|
|
|
|
holder = threading.Thread(target=hold)
|
|
holder.start()
|
|
assert held.wait(timeout=30)
|
|
return holder, release
|
|
|
|
|
|
def test_clone_or_update_logs_wait_on_contended_lock(
|
|
tmp_path: Path, mock_run_git_command: Mock, caplog: pytest.LogCaptureFixture
|
|
) -> None:
|
|
"""A contended acquire logs a redacted waiting message instead of
|
|
silently blocking."""
|
|
CORE.config_path = tmp_path / "test.yaml"
|
|
|
|
url = "https://user:hunter2@github.com/test/repo"
|
|
domain = "test"
|
|
repo_dir = _compute_repo_dir(url, None, domain)
|
|
mock_run_git_command.side_effect = _make_clone_side_effect(repo_dir)
|
|
|
|
holder, release = _hold_lock_in_thread(_lock_path(url, None, domain))
|
|
|
|
# Free the lock only once the waiting message has been emitted, so the
|
|
# release is caused by the thing being asserted instead of racing it.
|
|
handler = _SetEventOnWaitLog(release)
|
|
git_logger = logging.getLogger("esphome.git")
|
|
git_logger.addHandler(handler)
|
|
try:
|
|
with caplog.at_level(logging.INFO):
|
|
result_dir, _ = git.clone_or_update(
|
|
url=url, ref=None, refresh=git.NEVER_REFRESH, domain=domain
|
|
)
|
|
finally:
|
|
git_logger.removeHandler(handler)
|
|
release.set()
|
|
holder.join()
|
|
|
|
assert result_dir == repo_dir
|
|
waiting = [
|
|
r.getMessage()
|
|
for r in caplog.records
|
|
if "Waiting for another process" in r.getMessage()
|
|
]
|
|
assert len(waiting) == 1
|
|
assert "hunter2" not in waiting[0]
|
|
assert "://***@" in waiting[0]
|
|
|
|
|
|
def test_revert_skips_on_contended_lock(
|
|
tmp_path: Path,
|
|
mock_run_git_command: Mock,
|
|
caplog: pytest.LogCaptureFixture,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""revert() only runs on an already-failing path; when it cannot get the
|
|
lock within the bounded timeout it warns and skips instead of hanging."""
|
|
CORE.config_path = tmp_path / "test.yaml"
|
|
monkeypatch.setattr(git, "_REVERT_LOCK_TIMEOUT_SECONDS", 0.05)
|
|
|
|
url = "https://github.com/test/repo"
|
|
domain = "test"
|
|
repo_dir = _compute_repo_dir(url, None, domain)
|
|
_setup_old_repo(repo_dir)
|
|
|
|
# A bare return value satisfies the rev-parse; the other commands'
|
|
# outputs are unused.
|
|
mock_run_git_command.return_value = "old_sha"
|
|
|
|
_, revert = git.clone_or_update(
|
|
url=url, ref=None, refresh=TimePeriodSeconds(days=1), domain=domain
|
|
)
|
|
assert revert is not None
|
|
|
|
holder, release = _hold_lock_in_thread(_lock_path(url, None, domain))
|
|
calls_before = len(mock_run_git_command.call_args_list)
|
|
with caplog.at_level(logging.INFO):
|
|
assert revert() is False
|
|
release.set()
|
|
holder.join()
|
|
|
|
# No git reset was issued; the wait and the skip were both logged.
|
|
assert len(mock_run_git_command.call_args_list) == calls_before
|
|
assert any("Waiting for another process" in r.getMessage() for r in caplog.records)
|
|
assert any("skipping revert" in r.getMessage() for r in caplog.records)
|
|
|
|
|
|
def test_update_clears_marker_while_rewriting(
|
|
tmp_path: Path, mock_run_git_command: Mock
|
|
) -> None:
|
|
"""The completion marker is absent while a refresh rewrites the entry
|
|
and restored once the rewrite finishes, so a timed-out peer's fallback
|
|
never trusts a mid-rewrite tree and a crashed update re-clones."""
|
|
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)
|
|
|
|
marker_during_rewrite: list[bool] = []
|
|
|
|
def git_command_side_effect(
|
|
cmd: list[str], cwd: str | None = None, **kwargs: Any
|
|
) -> str:
|
|
if _get_git_command_type(cmd) in ("stash", "fetch", "reset"):
|
|
marker_during_rewrite.append(_marker_path(repo_dir).is_file())
|
|
return ""
|
|
|
|
mock_run_git_command.side_effect = git_command_side_effect
|
|
|
|
git.clone_or_update(
|
|
url=url, ref=None, refresh=TimePeriodSeconds(days=1), domain=domain
|
|
)
|
|
|
|
assert marker_during_rewrite == [False, False, False]
|
|
assert _marker_path(repo_dir).is_file()
|
|
|
|
|
|
def test_revert_skips_when_checkout_moved(
|
|
tmp_path: Path, mock_run_git_command: Mock, caplog: pytest.LogCaptureFixture
|
|
) -> None:
|
|
"""revert() only undoes this process's own update; when another process
|
|
refreshed the entry in the meantime it skips instead of rolling the
|
|
peer's newer checkout backwards."""
|
|
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)
|
|
|
|
# Pre-update SHA, post-update SHA, then a peer's SHA at revert time.
|
|
shas = iter(["old_sha", "new_sha", "peer_sha"])
|
|
|
|
def git_command_side_effect(
|
|
cmd: list[str], cwd: str | None = None, **kwargs: Any
|
|
) -> str:
|
|
if _get_git_command_type(cmd) == "rev-parse":
|
|
return next(shas)
|
|
return ""
|
|
|
|
mock_run_git_command.side_effect = git_command_side_effect
|
|
|
|
_, revert = git.clone_or_update(
|
|
url=url, ref=None, refresh=TimePeriodSeconds(days=1), domain=domain
|
|
)
|
|
assert revert is not None
|
|
|
|
with caplog.at_level(logging.WARNING):
|
|
assert revert() is False
|
|
|
|
resets = [
|
|
c[0][0]
|
|
for c in mock_run_git_command.call_args_list
|
|
if _get_git_command_type(c[0][0]) == "reset" and c[0][0][-1] == "old_sha"
|
|
]
|
|
assert resets == []
|
|
assert any("checkout moved" in r.getMessage() for r in caplog.records)
|
|
|
|
|
|
def test_revert_returns_false_when_reset_fails(
|
|
tmp_path: Path, mock_run_git_command: Mock, caplog: pytest.LogCaptureFixture
|
|
) -> None:
|
|
"""A failed git reset inside revert() is reported through the bool
|
|
contract instead of raising a cv.Invalid that would replace the
|
|
caller's original error."""
|
|
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)
|
|
|
|
def git_command_side_effect(
|
|
cmd: list[str], cwd: str | None = None, **kwargs: Any
|
|
) -> str:
|
|
if _get_git_command_type(cmd) == "rev-parse":
|
|
return "old_sha"
|
|
# Only revert's reset targets the recorded SHA; the update path's
|
|
# reset targets FETCH_HEAD and must succeed.
|
|
if cmd[-1] == "old_sha":
|
|
raise git.GitCommandError("object not found")
|
|
return ""
|
|
|
|
mock_run_git_command.side_effect = git_command_side_effect
|
|
|
|
_, revert = git.clone_or_update(
|
|
url=url, ref=None, refresh=TimePeriodSeconds(days=1), domain=domain
|
|
)
|
|
assert revert is not None
|
|
|
|
with caplog.at_level(logging.WARNING):
|
|
assert revert() is False
|
|
|
|
assert any("Could not revert" in r.getMessage() for r in caplog.records)
|
|
# The entry cannot be trusted after a failed reset; the dropped marker
|
|
# forces a re-clone on the next use.
|
|
assert not _marker_path(repo_dir).is_file()
|
|
|
|
|
|
def _raise_oserror_on_acquire(
|
|
monkeypatch: pytest.MonkeyPatch, code: int = errno.ENOLCK
|
|
) -> None:
|
|
"""Make every FileLock acquire fail with the given errno."""
|
|
|
|
def broken_acquire(self: FileLock, *args: Any, **kwargs: Any) -> None:
|
|
raise OSError(code, os.strerror(code))
|
|
|
|
monkeypatch.setattr(FileLock, "acquire", broken_acquire)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("code", "expected_fragment"),
|
|
[
|
|
# Genuinely missing lock support is reported as such.
|
|
(errno.ENOLCK, "does not support locking"),
|
|
# A cache directory problem is not blamed on lock support; git
|
|
# reports the real error when it actually matters.
|
|
(errno.EROFS, "Could not take the cache entry lock"),
|
|
],
|
|
)
|
|
def test_clone_or_update_continues_unlocked_when_filesystem_cannot_lock(
|
|
tmp_path: Path,
|
|
mock_run_git_command: Mock,
|
|
caplog: pytest.LogCaptureFixture,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
code: int,
|
|
expected_fragment: str,
|
|
) -> None:
|
|
"""A filesystem where taking the lock fails (e.g. NFS without a lock
|
|
daemon) degrades to the old unlocked behavior instead of failing."""
|
|
CORE.config_path = tmp_path / "test.yaml"
|
|
|
|
url = "https://github.com/test/repo"
|
|
domain = "test"
|
|
repo_dir = _compute_repo_dir(url, None, domain)
|
|
mock_run_git_command.side_effect = _make_clone_side_effect(repo_dir)
|
|
_raise_oserror_on_acquire(monkeypatch, code)
|
|
|
|
with caplog.at_level(logging.WARNING):
|
|
result_dir, _ = git.clone_or_update(
|
|
url=url, ref=None, refresh=git.NEVER_REFRESH, domain=domain
|
|
)
|
|
|
|
assert result_dir == repo_dir
|
|
assert _marker_path(repo_dir).is_file()
|
|
warnings = [
|
|
r.getMessage()
|
|
for r in caplog.records
|
|
if "continuing without a lock" in r.getMessage()
|
|
]
|
|
assert warnings
|
|
assert expected_fragment in warnings[0]
|
|
|
|
|
|
@pytest.mark.parametrize("broken_from_start", [True, False])
|
|
def test_revert_continues_unlocked_when_filesystem_cannot_lock(
|
|
tmp_path: Path,
|
|
mock_run_git_command: Mock,
|
|
caplog: pytest.LogCaptureFixture,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
broken_from_start: bool,
|
|
) -> None:
|
|
"""A revert still resets when locking is unavailable, whether the wrapper
|
|
already fell back to unlocked (revert sees no lock at all) or the
|
|
filesystem stops locking between the update and the revert."""
|
|
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)
|
|
|
|
# A bare return value satisfies the rev-parse; the other commands'
|
|
# outputs are unused.
|
|
mock_run_git_command.return_value = "old_sha"
|
|
|
|
if broken_from_start:
|
|
_raise_oserror_on_acquire(monkeypatch)
|
|
|
|
with caplog.at_level(logging.WARNING):
|
|
_, revert = git.clone_or_update(
|
|
url=url, ref=None, refresh=TimePeriodSeconds(days=1), domain=domain
|
|
)
|
|
assert revert is not None
|
|
if not broken_from_start:
|
|
_raise_oserror_on_acquire(monkeypatch)
|
|
# The reset ran (unlocked), so the revert reports success.
|
|
assert revert() is True
|
|
|
|
assert mock_run_git_command.call_args_list[-1][0][0] == [
|
|
"git",
|
|
"reset",
|
|
"--hard",
|
|
"old_sha",
|
|
]
|
|
assert any("continuing without a lock" in r.getMessage() for r in caplog.records)
|
|
|
|
|
|
def _script_acquire_statuses(
|
|
monkeypatch: pytest.MonkeyPatch, statuses: list["git._LockStatus"]
|
|
) -> list[float]:
|
|
"""Replace _acquire_repo_lock with a scripted sequence; returns the
|
|
timeouts it was called with."""
|
|
timeouts: list[float] = []
|
|
status_iter = iter(statuses)
|
|
|
|
def fake_acquire(
|
|
lock: FileLock, safe_key: str, timeout: float, **kwargs: Any
|
|
) -> "git._LockStatus":
|
|
timeouts.append(timeout)
|
|
return next(status_iter)
|
|
|
|
monkeypatch.setattr(git, "_acquire_repo_lock", fake_acquire)
|
|
return timeouts
|
|
|
|
|
|
@pytest.mark.parametrize("subpath", [None, Path("lib")])
|
|
def test_clone_or_update_uses_complete_entry_when_lock_wait_times_out(
|
|
tmp_path: Path,
|
|
mock_run_git_command: Mock,
|
|
caplog: pytest.LogCaptureFixture,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
subpath: Path | None,
|
|
) -> None:
|
|
"""A bounded wait behind a stalled holder falls back to an existing
|
|
complete cache entry instead of hanging every peer."""
|
|
CORE.config_path = tmp_path / "test.yaml"
|
|
|
|
url = "https://github.com/test/repo"
|
|
domain = "test"
|
|
repo_dir = _compute_repo_dir(url, None, domain)
|
|
if subpath is not None:
|
|
repo_dir = repo_dir / subpath
|
|
_simulate_cloned_repo(repo_dir)
|
|
_mark_clone_complete(repo_dir)
|
|
|
|
timeouts = _script_acquire_statuses(monkeypatch, [git._LockStatus.TIMEOUT])
|
|
|
|
with caplog.at_level(logging.WARNING):
|
|
result_dir, revert = git.clone_or_update(
|
|
url=url,
|
|
ref=None,
|
|
refresh=TimePeriodSeconds(days=1),
|
|
domain=domain,
|
|
subpath=subpath,
|
|
)
|
|
|
|
assert result_dir == repo_dir
|
|
assert revert is None
|
|
# Nothing was cloned or refreshed; the existing entry was used as-is.
|
|
assert mock_run_git_command.call_args_list == []
|
|
assert timeouts == [git._COMPLETE_ENTRY_LOCK_TIMEOUT_SECONDS]
|
|
assert any(
|
|
"proceeding with the existing clone" in r.getMessage() for r in caplog.records
|
|
)
|
|
|
|
|
|
def test_clone_or_update_waits_unbounded_without_complete_entry(
|
|
tmp_path: Path,
|
|
mock_run_git_command: Mock,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""With no complete entry there is nothing to fall back to, so after the
|
|
bounded wait expires the caller keeps waiting for the holder's clone."""
|
|
CORE.config_path = tmp_path / "test.yaml"
|
|
|
|
url = "https://github.com/test/repo"
|
|
domain = "test"
|
|
repo_dir = _compute_repo_dir(url, None, domain)
|
|
mock_run_git_command.side_effect = _make_clone_side_effect(repo_dir)
|
|
|
|
timeouts = _script_acquire_statuses(
|
|
monkeypatch, [git._LockStatus.TIMEOUT, git._LockStatus.ACQUIRED]
|
|
)
|
|
|
|
result_dir, _ = git.clone_or_update(
|
|
url=url, ref=None, refresh=git.NEVER_REFRESH, domain=domain
|
|
)
|
|
|
|
assert result_dir == repo_dir
|
|
assert timeouts == [git._COMPLETE_ENTRY_LOCK_TIMEOUT_SECONDS, -1]
|
|
# The clone proceeded normally once the lock was finally acquired.
|
|
assert _marker_path(repo_dir).is_file()
|
|
|
|
|
|
def _real_git(*args: str, cwd: Path) -> None:
|
|
"""Run real git to build a test fixture repository."""
|
|
subprocess.run(
|
|
[
|
|
"git",
|
|
"-c",
|
|
"user.email=test@test.invalid",
|
|
"-c",
|
|
"user.name=test",
|
|
"-c",
|
|
"commit.gpgsign=false",
|
|
"-c",
|
|
"protocol.file.allow=always",
|
|
*args,
|
|
],
|
|
cwd=cwd,
|
|
check=True,
|
|
capture_output=True,
|
|
)
|
|
|
|
|
|
# Git blocks file-protocol submodules by default (CVE-2022-39253); the e2e
|
|
# tests allow them via GIT_CONFIG_* environment variables, which reach the
|
|
# child git processes through run_git_command's filtered environment.
|
|
_ALLOW_FILE_PROTOCOL_ENV = {
|
|
"GIT_CONFIG_COUNT": "1",
|
|
"GIT_CONFIG_KEY_0": "protocol.file.allow",
|
|
"GIT_CONFIG_VALUE_0": "always",
|
|
}
|
|
|
|
|
|
def _make_real_repo(path: Path, filename: str) -> None:
|
|
"""Create a real git repository containing one committed file."""
|
|
path.mkdir()
|
|
_real_git("init", "-q", cwd=path)
|
|
(path / filename).write_text("content")
|
|
_real_git("add", filename, cwd=path)
|
|
_real_git("commit", "-q", "-m", "init", cwd=path)
|
|
|
|
|
|
def _add_submodule(
|
|
repo: Path, url: Path, path: str, *, update_none: bool = False
|
|
) -> None:
|
|
"""Add ``url`` as a submodule of ``repo`` at ``path`` and commit it."""
|
|
_real_git("submodule", "add", str(url), path, cwd=repo)
|
|
if update_none:
|
|
_real_git(
|
|
"config", "-f", ".gitmodules", f"submodule.{path}.update", "none", cwd=repo
|
|
)
|
|
_real_git("add", ".gitmodules", cwd=repo)
|
|
_real_git("commit", "-q", "-m", f"add submodule {path}", cwd=repo)
|
|
|
|
|
|
def test_clone_or_update_real_git_without_submodules(tmp_path: Path) -> None:
|
|
"""End-to-end with real git: a repo with no .gitmodules clones cleanly.
|
|
|
|
This is the issue #17860 scenario: requesting "all submodules" on a
|
|
submodule-less repository must not invoke the git submodule porcelain
|
|
and must produce a usable checkout.
|
|
"""
|
|
CORE.config_path = tmp_path / "test.yaml"
|
|
|
|
upstream = tmp_path / "upstream"
|
|
_make_real_repo(upstream, "README.md")
|
|
|
|
repo_dir, _ = git.clone_or_update(
|
|
url=str(upstream),
|
|
ref=None,
|
|
refresh=None,
|
|
domain="test_e2e",
|
|
init_submodules=True,
|
|
)
|
|
|
|
assert (repo_dir / "README.md").is_file()
|
|
|
|
|
|
def test_clone_or_update_real_git_initializes_submodules(tmp_path: Path) -> None:
|
|
"""End-to-end with real git: submodules are actually checked out.
|
|
|
|
Exercises the real `git submodule update` invocation, including the
|
|
env handling in run_git_command that the mocked tests cannot cover.
|
|
"""
|
|
CORE.config_path = tmp_path / "test.yaml"
|
|
|
|
sub_repo = tmp_path / "sub"
|
|
_make_real_repo(sub_repo, "sub_file.txt")
|
|
|
|
upstream = tmp_path / "upstream"
|
|
_make_real_repo(upstream, "README.md")
|
|
_add_submodule(upstream, sub_repo, "vendor/sub")
|
|
|
|
with patch.dict(os.environ, _ALLOW_FILE_PROTOCOL_ENV):
|
|
repo_dir, _ = git.clone_or_update(
|
|
url=str(upstream),
|
|
ref=None,
|
|
refresh=None,
|
|
domain="test_e2e",
|
|
init_submodules=True,
|
|
)
|
|
|
|
assert (repo_dir / "vendor" / "sub" / "sub_file.txt").is_file()
|
|
|
|
|
|
def test_clone_or_update_real_git_honors_update_none_submodule(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""End-to-end with real git: submodules declared `update = none` stay skipped.
|
|
|
|
Shows git itself skipping the declared paths at both nesting levels
|
|
(and exiting 0) while the regular submodules check out.
|
|
"""
|
|
CORE.config_path = tmp_path / "test.yaml"
|
|
|
|
sub_repo = tmp_path / "sub"
|
|
_make_real_repo(sub_repo, "sub_file.txt")
|
|
|
|
# Intermediate submodule that itself declares a skipped nested submodule.
|
|
mid_repo = tmp_path / "mid"
|
|
_make_real_repo(mid_repo, "mid_file.txt")
|
|
_add_submodule(mid_repo, sub_repo, "vendor/leaf", update_none=True)
|
|
|
|
upstream = tmp_path / "upstream"
|
|
_make_real_repo(upstream, "README.md")
|
|
_add_submodule(upstream, sub_repo, "vendor/sub")
|
|
_add_submodule(upstream, sub_repo, "vendor/skipped", update_none=True)
|
|
_add_submodule(upstream, mid_repo, "vendor/mid")
|
|
|
|
with patch.dict(os.environ, _ALLOW_FILE_PROTOCOL_ENV):
|
|
repo_dir, _ = git.clone_or_update(
|
|
url=str(upstream),
|
|
ref=None,
|
|
refresh=None,
|
|
domain="test_e2e",
|
|
init_submodules=True,
|
|
)
|
|
|
|
assert (repo_dir / "vendor" / "sub" / "sub_file.txt").is_file()
|
|
assert not (repo_dir / "vendor" / "skipped" / "sub_file.txt").exists()
|
|
assert (repo_dir / "vendor" / "mid" / "mid_file.txt").is_file()
|
|
assert not (
|
|
repo_dir / "vendor" / "mid" / "vendor" / "leaf" / "sub_file.txt"
|
|
).exists()
|
|
|
|
|
|
def test_refresh_picks_up_new_remote_commits(
|
|
tmp_path: Path, mock_run_git_command: Mock
|
|
) -> None:
|
|
"""Shallow fetch must still pull new commits when the remote tip moves.
|
|
|
|
Simulates a stale local repo at SHA "old" while the remote has advanced
|
|
to SHA "new". The refresh path must run fetch (with --depth=1) followed
|
|
by reset --hard FETCH_HEAD so the working tree advances to the new tip.
|
|
"""
|
|
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)
|
|
|
|
# rev-parse is called once before fetch to record the pre-update SHA.
|
|
rev_parse_calls = {"count": 0}
|
|
|
|
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":
|
|
rev_parse_calls["count"] += 1
|
|
return "old_sha"
|
|
return ""
|
|
|
|
mock_run_git_command.side_effect = git_command_side_effect
|
|
|
|
_, revert = git.clone_or_update(
|
|
url=url, ref=ref, refresh=TimePeriodSeconds(days=1), domain=domain
|
|
)
|
|
|
|
# Verify the refresh sequence: rev-parse -> stash -> fetch (depth=1) -> reset
|
|
call_list = mock_run_git_command.call_args_list
|
|
cmd_sequence = [_get_git_command_type(c[0][0]) for c in call_list]
|
|
# The trailing rev-parse records the post-update SHA for revert().
|
|
assert cmd_sequence == ["rev-parse", "stash", "fetch", "reset", "rev-parse"]
|
|
|
|
fetch_cmd = call_list[2][0][0]
|
|
assert "--depth=1" in fetch_cmd
|
|
assert fetch_cmd[-1] == ref
|
|
|
|
reset_cmd = call_list[3][0][0]
|
|
assert reset_cmd[-1] == "FETCH_HEAD"
|
|
|
|
# revert callback should reset back to the recorded pre-update SHA.
|
|
assert revert is not None
|
|
assert revert() is True
|
|
assert mock_run_git_command.call_args_list[-1][0][0] == [
|
|
"git",
|
|
"reset",
|
|
"--hard",
|
|
"old_sha",
|
|
]
|
|
|
|
|
|
def test_resolve_symlink_stub_returns_none_on_non_windows(
|
|
tmp_path: Path, mock_run_git_command: Mock
|
|
) -> None:
|
|
"""On non-Windows, resolve_symlink_stub returns None without calling git."""
|
|
repo_dir = tmp_path / "repo"
|
|
repo_dir.mkdir()
|
|
stub = repo_dir / "file.yaml"
|
|
stub.write_text("static/file.yaml")
|
|
|
|
with patch("esphome.git.sys.platform", "linux"):
|
|
result = git.resolve_symlink_stub(repo_dir, stub)
|
|
|
|
assert result is None
|
|
mock_run_git_command.assert_not_called()
|
|
|
|
|
|
def test_resolve_symlink_stub_returns_target_for_mode_120000(
|
|
tmp_path: Path, mock_run_git_command: Mock
|
|
) -> None:
|
|
"""A mode-120000 file is recognised as a stub; its target Path is returned."""
|
|
repo_dir = tmp_path / "repo"
|
|
repo_dir.mkdir()
|
|
(repo_dir / "static").mkdir()
|
|
|
|
target = repo_dir / "static" / "real.yaml"
|
|
target.write_text("esphome:\n name: real\n")
|
|
|
|
stub = repo_dir / "real.yaml"
|
|
stub.write_text("static/real.yaml")
|
|
|
|
mock_run_git_command.return_value = "120000 abc123 0\treal.yaml"
|
|
|
|
with patch("esphome.git.sys.platform", "win32"):
|
|
result = git.resolve_symlink_stub(repo_dir, stub)
|
|
|
|
assert result == target.resolve()
|
|
# Stub file itself was not modified — only inspected.
|
|
assert stub.read_text() == "static/real.yaml"
|
|
|
|
|
|
def test_resolve_symlink_stub_resolves_relative_parent_paths(
|
|
tmp_path: Path, mock_run_git_command: Mock
|
|
) -> None:
|
|
"""Symlink targets with ``..`` segments resolve correctly within the repo."""
|
|
repo_dir = tmp_path / "repo"
|
|
(repo_dir / "subdir").mkdir(parents=True)
|
|
(repo_dir / "static").mkdir()
|
|
|
|
target = repo_dir / "static" / "shared.yaml"
|
|
target.write_text("shared content")
|
|
|
|
stub = repo_dir / "subdir" / "shared.yaml"
|
|
stub.write_text("../static/shared.yaml")
|
|
|
|
mock_run_git_command.return_value = "120000 abc123 0\tsubdir/shared.yaml"
|
|
|
|
with patch("esphome.git.sys.platform", "win32"):
|
|
result = git.resolve_symlink_stub(repo_dir, stub)
|
|
|
|
assert result == target.resolve()
|
|
|
|
|
|
def test_resolve_symlink_stub_refuses_escape_outside_repo(
|
|
tmp_path: Path, mock_run_git_command: Mock
|
|
) -> None:
|
|
"""A symlink pointing outside the repository is not followed."""
|
|
outside = tmp_path / "outside.yaml"
|
|
outside.write_text("sensitive")
|
|
|
|
repo_dir = tmp_path / "repo"
|
|
repo_dir.mkdir()
|
|
|
|
stub = repo_dir / "escape.yaml"
|
|
stub.write_text("../outside.yaml")
|
|
|
|
mock_run_git_command.return_value = "120000 abc123 0\tescape.yaml"
|
|
|
|
with patch("esphome.git.sys.platform", "win32"):
|
|
result = git.resolve_symlink_stub(repo_dir, stub)
|
|
|
|
assert result is None
|
|
|
|
|
|
def test_resolve_symlink_stub_returns_none_for_real_symlink(
|
|
tmp_path: Path, mock_run_git_command: Mock
|
|
) -> None:
|
|
"""A real symlink already opens transparently, so the helper short-circuits.
|
|
|
|
Skipped on Windows where symlink creation requires
|
|
SeCreateSymbolicLinkPrivilege.
|
|
"""
|
|
if os.name == "nt":
|
|
pytest.skip("Requires symlink-creation privilege on Windows")
|
|
|
|
repo_dir = tmp_path / "repo"
|
|
repo_dir.mkdir()
|
|
target = repo_dir / "real.yaml"
|
|
target.write_text("real content")
|
|
|
|
real_link = repo_dir / "link.yaml"
|
|
real_link.symlink_to("real.yaml")
|
|
|
|
with patch("esphome.git.sys.platform", "win32"):
|
|
result = git.resolve_symlink_stub(repo_dir, real_link)
|
|
|
|
assert result is None
|
|
# No git call needed for real symlinks.
|
|
mock_run_git_command.assert_not_called()
|
|
|
|
|
|
def test_resolve_symlink_stub_returns_none_for_regular_file(
|
|
tmp_path: Path, mock_run_git_command: Mock
|
|
) -> None:
|
|
"""A regular file (mode 100644) whose content looks path-shaped is not
|
|
followed."""
|
|
repo_dir = tmp_path / "repo"
|
|
repo_dir.mkdir()
|
|
|
|
regular = repo_dir / "looks_like_path.txt"
|
|
regular.write_text("static/something.yaml")
|
|
|
|
mock_run_git_command.return_value = "100644 abc123 0\tlooks_like_path.txt"
|
|
|
|
with patch("esphome.git.sys.platform", "win32"):
|
|
result = git.resolve_symlink_stub(repo_dir, regular)
|
|
|
|
assert result is None
|
|
|
|
|
|
def test_resolve_symlink_stub_returns_none_when_git_fails(
|
|
tmp_path: Path, mock_run_git_command: Mock
|
|
) -> None:
|
|
"""If ``git ls-files`` fails (e.g. not a repo), the helper returns None."""
|
|
repo_dir = tmp_path / "repo"
|
|
repo_dir.mkdir()
|
|
|
|
stub = repo_dir / "real.yaml"
|
|
stub.write_text("static/real.yaml")
|
|
|
|
mock_run_git_command.side_effect = GitCommandError("ls-files exploded")
|
|
|
|
with patch("esphome.git.sys.platform", "win32"):
|
|
result = git.resolve_symlink_stub(repo_dir, stub)
|
|
|
|
assert result is None
|
|
|
|
|
|
def test_resolve_symlink_stub_returns_none_for_non_utf8_content(
|
|
tmp_path: Path, mock_run_git_command: Mock
|
|
) -> None:
|
|
"""A file whose bytes are not valid UTF-8 must not raise — return None."""
|
|
repo_dir = tmp_path / "repo"
|
|
repo_dir.mkdir()
|
|
|
|
stub = repo_dir / "binary.bin"
|
|
stub.write_bytes(b"\xff\xfe\x00\xff")
|
|
|
|
mock_run_git_command.return_value = "120000 abc123 0\tbinary.bin"
|
|
|
|
with patch("esphome.git.sys.platform", "win32"):
|
|
result = git.resolve_symlink_stub(repo_dir, stub)
|
|
|
|
assert result is None
|
|
|
|
|
|
def test_resolve_symlink_stub_preserves_whitespace_in_target(
|
|
tmp_path: Path, mock_run_git_command: Mock
|
|
) -> None:
|
|
"""Only trailing CR/LF is stripped — internal whitespace is preserved."""
|
|
repo_dir = tmp_path / "repo"
|
|
repo_dir.mkdir()
|
|
target_dir = repo_dir / "dir with spaces"
|
|
target_dir.mkdir()
|
|
target = target_dir / "real.yaml"
|
|
target.write_text("hello")
|
|
|
|
stub = repo_dir / "link.yaml"
|
|
# Trailing newline (as git's checkout may append) is stripped, but
|
|
# whitespace inside the target path itself must survive.
|
|
stub.write_bytes(b"dir with spaces/real.yaml\n")
|
|
|
|
mock_run_git_command.return_value = "120000 abc123 0\tlink.yaml"
|
|
|
|
with patch("esphome.git.sys.platform", "win32"):
|
|
result = git.resolve_symlink_stub(repo_dir, stub)
|
|
|
|
assert result == target.resolve()
|
|
|
|
|
|
def test_resolve_symlink_stub_returns_none_for_directory_target(
|
|
tmp_path: Path, mock_run_git_command: Mock
|
|
) -> None:
|
|
"""A symlink pointing at a directory has no file content to load."""
|
|
repo_dir = tmp_path / "repo"
|
|
repo_dir.mkdir()
|
|
(repo_dir / "dir_target").mkdir()
|
|
|
|
stub = repo_dir / "link_to_dir"
|
|
stub.write_text("dir_target")
|
|
|
|
mock_run_git_command.return_value = "120000 abc123 0\tlink_to_dir"
|
|
|
|
with patch("esphome.git.sys.platform", "win32"):
|
|
result = git.resolve_symlink_stub(repo_dir, stub)
|
|
|
|
assert result is None
|
|
|
|
|
|
def test_resolve_symlink_stub_returns_none_when_resolve_raises(
|
|
tmp_path: Path, mock_run_git_command: Mock
|
|
) -> None:
|
|
"""Path.resolve() raising (e.g. on a malformed target) must not propagate."""
|
|
repo_dir = tmp_path / "repo"
|
|
repo_dir.mkdir()
|
|
|
|
stub = repo_dir / "broken.yaml"
|
|
stub.write_text("ignored")
|
|
|
|
mock_run_git_command.return_value = "120000 abc123 0\tbroken.yaml"
|
|
|
|
with (
|
|
patch("esphome.git.sys.platform", "win32"),
|
|
patch.object(Path, "resolve", side_effect=OSError("bad path")),
|
|
):
|
|
result = git.resolve_symlink_stub(repo_dir, stub)
|
|
|
|
assert result is None
|
|
|
|
|
|
def test_resolve_symlink_stub_returns_none_when_file_missing(
|
|
tmp_path: Path, mock_run_git_command: Mock
|
|
) -> None:
|
|
"""A file path that doesn't exist is rejected before git is consulted."""
|
|
repo_dir = tmp_path / "repo"
|
|
repo_dir.mkdir()
|
|
|
|
missing = repo_dir / "ghost.yaml" # not created
|
|
|
|
with patch("esphome.git.sys.platform", "win32"):
|
|
result = git.resolve_symlink_stub(repo_dir, missing)
|
|
|
|
assert result is None
|
|
mock_run_git_command.assert_not_called()
|
|
|
|
|
|
def test_resolve_symlink_stub_returns_none_when_path_outside_repo(
|
|
tmp_path: Path, mock_run_git_command: Mock
|
|
) -> None:
|
|
"""A file path that isn't under repo_dir is rejected (ValueError from relative_to)."""
|
|
repo_dir = tmp_path / "repo"
|
|
repo_dir.mkdir()
|
|
|
|
outside = tmp_path / "stray.yaml"
|
|
outside.write_text("something")
|
|
|
|
with patch("esphome.git.sys.platform", "win32"):
|
|
result = git.resolve_symlink_stub(repo_dir, outside)
|
|
|
|
assert result is None
|
|
mock_run_git_command.assert_not_called()
|
|
|
|
|
|
def test_resolve_symlink_stub_returns_none_when_untracked(
|
|
tmp_path: Path, mock_run_git_command: Mock
|
|
) -> None:
|
|
"""Empty `git ls-files` output (untracked file) makes the helper return None."""
|
|
repo_dir = tmp_path / "repo"
|
|
repo_dir.mkdir()
|
|
|
|
stub = repo_dir / "untracked.yaml"
|
|
stub.write_text("static/foo.yaml")
|
|
|
|
mock_run_git_command.return_value = ""
|
|
|
|
with patch("esphome.git.sys.platform", "win32"):
|
|
result = git.resolve_symlink_stub(repo_dir, stub)
|
|
|
|
assert result is None
|
|
|
|
|
|
def test_resolve_symlink_stub_returns_none_when_read_bytes_raises(
|
|
tmp_path: Path, mock_run_git_command: Mock
|
|
) -> None:
|
|
"""An OSError from read_bytes() (e.g. file vanished mid-call) must not propagate."""
|
|
repo_dir = tmp_path / "repo"
|
|
repo_dir.mkdir()
|
|
|
|
stub = repo_dir / "racy.yaml"
|
|
stub.write_text("static/racy.yaml")
|
|
|
|
mock_run_git_command.return_value = "120000 abc123 0\tracy.yaml"
|
|
|
|
with (
|
|
patch("esphome.git.sys.platform", "win32"),
|
|
patch.object(Path, "read_bytes", side_effect=OSError("vanished")),
|
|
):
|
|
result = git.resolve_symlink_stub(repo_dir, stub)
|
|
|
|
assert result is None
|