diff --git a/supervisor/store/git.py b/supervisor/store/git.py index 474a4a5fa..117d9f3f0 100644 --- a/supervisor/store/git.py +++ b/supervisor/store/git.py @@ -1,9 +1,11 @@ """Init file for Supervisor add-on Git.""" import asyncio +from enum import StrEnum import errno import functools as ft import logging +from os import listdir from pathlib import Path from tempfile import TemporaryDirectory @@ -20,6 +22,32 @@ from .validate import RE_REPOSITORY _LOGGER: logging.Logger = logging.getLogger(__name__) +class _RepoDirectoryStatus(StrEnum): + """Basic directory status for repo.""" + + GOOD = "good" + EMPTY = "empty" + MISSING_GIT = "missing_git" + + +def _check_repo_directory(path: Path) -> _RepoDirectoryStatus: + """Check repository directory. + + Must be run in executor. + """ + if not path.is_dir(): + return _RepoDirectoryStatus.EMPTY + + if (path / ".git").is_dir(): + return _RepoDirectoryStatus.GOOD + + return ( + _RepoDirectoryStatus.MISSING_GIT + if listdir(path) + else _RepoDirectoryStatus.EMPTY + ) + + class GitRepo(CoreSysAttributes): """Manage Add-on Git repository.""" @@ -50,9 +78,14 @@ class GitRepo(CoreSysAttributes): async def load(self) -> None: """Init Git add-on repository.""" - if not await self.sys_run_in_executor((self.path / ".git").is_dir): - await self.clone() - return + match await self.sys_run_in_executor(_check_repo_directory, self.path): + # Nothing cached. Set up as fresh clone + case _RepoDirectoryStatus.EMPTY: + await self.clone() + return + # Repository is corrupt. Try to reset it before loading + case _RepoDirectoryStatus.MISSING_GIT: + await self.reset() # Load repository async with self.lock: @@ -175,6 +208,22 @@ class GitRepo(CoreSysAttributes): async with self.lock: _LOGGER.info("Update add-on %s repository from %s", self.path, self.url) + # .git is missing, repository is corrupted. Can't continue, raise issue + if ( + await self.sys_run_in_executor(_check_repo_directory, self.path) + != _RepoDirectoryStatus.GOOD + ): + self.sys_resolution.create_issue( + IssueType.CORRUPT_REPOSITORY, + ContextType.STORE, + reference=self.path.stem, + suggestions=[SuggestionType.EXECUTE_RESET], + ) + raise StoreGitError( + f"Can't update {self.url} repo because git information is missing", + _LOGGER.error, + ) + try: git_cmd = git.Git() await self.sys_run_in_executor(git_cmd.ls_remote, "--heads", self.url) diff --git a/tests/conftest.py b/tests/conftest.py index 6625d7601..82af9f705 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -337,10 +337,31 @@ async def fixture_all_dbus_services( ) +@pytest.fixture +def addon_repo_fixtures() -> dict[str, Path]: + """Make addon repo fixtures into valid repositories and return.""" + addon_repo_fixtures = Path(__file__).parent.joinpath("fixtures") / "addons" + core_repo_fixture = addon_repo_fixtures / "core" + local_repo_fixture = addon_repo_fixtures / "local" + git_repo_fixtures = addon_repo_fixtures / "git" + + # Ensure each repo folder has a dummy .git + (core_repo_fixture / ".git").mkdir(exist_ok=True) + for repo in os.listdir(git_repo_fixtures): + Path(repo, ".git").mkdir(exist_ok=True) + + return { + "core": core_repo_fixture, + "local": local_repo_fixture, + "git": git_repo_fixtures, + } + + @pytest.fixture async def coresys( docker, dbus_session_bus, + addon_repo_fixtures: dict[str, Path], all_dbus_services, aiohttp_client, run_supervisor_state, @@ -393,15 +414,9 @@ async def coresys( coresys_obj.host.network._connectivity = True # Fix Paths - su_config.ADDONS_CORE = Path( - Path(__file__).parent.joinpath("fixtures"), "addons/core" - ) - su_config.ADDONS_LOCAL = Path( - Path(__file__).parent.joinpath("fixtures"), "addons/local" - ) - su_config.ADDONS_GIT = Path( - Path(__file__).parent.joinpath("fixtures"), "addons/git" - ) + su_config.ADDONS_CORE = addon_repo_fixtures["core"] + su_config.ADDONS_LOCAL = addon_repo_fixtures["local"] + su_config.ADDONS_GIT = addon_repo_fixtures["git"] su_config.APPARMOR_DATA = Path( Path(__file__).parent.joinpath("fixtures"), "apparmor" ) diff --git a/tests/store/test_repository_git.py b/tests/store/test_repository_git.py index 3bb444232..7b0906e55 100644 --- a/tests/store/test_repository_git.py +++ b/tests/store/test_repository_git.py @@ -3,13 +3,15 @@ from __future__ import annotations from pathlib import Path -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, MagicMock, patch from git import GitCommandError, InvalidGitRepositoryError, NoSuchPathError import pytest from supervisor.coresys import CoreSys from supervisor.exceptions import StoreGitCloneError, StoreGitError +from supervisor.resolution.const import ContextType, IssueType, SuggestionType +from supervisor.resolution.data import Issue, Suggestion from supervisor.store.git import GitRepo REPO_URL = "https://github.com/awesome-developer/awesome-repo" @@ -93,6 +95,70 @@ async def test_git_load(coresys: CoreSys, tmp_path: Path): assert mock_repo.call_count == 1 +@pytest.mark.usefixtures("tmp_supervisor_data", "supervisor_internet") +async def test_git_load_corrupt(coresys: CoreSys, tmp_path: Path): + """Test git load with corrupt repo.""" + coresys.hardware.disk.get_disk_free_space = lambda x: 5000 + repo_dir = tmp_path / "repo" + repo = GitRepo(coresys, repo_dir, REPO_URL) + + # Pretend we have a something but not .git to force a reset + repo_dir.mkdir() + marker = repo_dir / "test.txt" + marker.touch() + + def mock_clone_from(url, path, *args, **kwargs): + """Mock to just make a .git and return.""" + Path(path, ".git").mkdir() + return MagicMock() + + with patch("git.Repo") as mock_repo: + mock_repo.clone_from = mock_clone_from + await repo.load() + assert mock_repo.call_count == 1 + assert not marker.exists() + assert (repo_dir / ".git").is_dir() + + +@pytest.mark.usefixtures("tmp_supervisor_data", "supervisor_internet") +async def test_git_pull_correct(coresys: CoreSys, tmp_path: Path): + """Test git pull with corrupt repo.""" + coresys.hardware.disk.get_disk_free_space = lambda x: 5000 + repo_dir = tmp_path / "repo" + repo = GitRepo(coresys, repo_dir, REPO_URL) + + # Set up a our fake repo + repo_dir.mkdir() + git_dir = repo_dir / ".git" + git_dir.mkdir() + (repo_dir / "test.txt").touch() + + with patch("git.Repo"): + await repo.load() + + # Make it corrupt + git_dir.rmdir() + + # Check that we get an issue on pull + with pytest.raises( + StoreGitError, + match=f"Can't update {REPO_URL} repo because git information is missing", + ): + await repo.pull() + assert ( + Issue( + IssueType.CORRUPT_REPOSITORY, ContextType.STORE, reference=repo_dir.stem + ) + in coresys.resolution.issues + ) + assert ( + Suggestion( + SuggestionType.EXECUTE_RESET, ContextType.STORE, reference=repo_dir.stem + ) + in coresys.resolution.suggestions + ) + + @pytest.mark.parametrize( "git_errors", [