mirror of
https://github.com/home-assistant/supervisor.git
synced 2025-08-27 09:59:20 +00:00
Compare commits
2 Commits
2025.08.3
...
handle-git
Author | SHA1 | Date | |
---|---|---|---|
![]() |
1f7aafcfd7 | ||
![]() |
ed45651fd9 |
@@ -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)
|
||||
|
@@ -337,10 +337,32 @@ 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 f in os.listdir(git_repo_fixtures):
|
||||
if (repo := git_repo_fixtures / f).is_dir():
|
||||
(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 +415,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"
|
||||
)
|
||||
|
@@ -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",
|
||||
[
|
||||
|
Reference in New Issue
Block a user