mirror of
https://github.com/home-assistant/supervisor.git
synced 2025-10-22 18:19:41 +00:00
Compare commits
2 Commits
2025.08.2
...
handle-git
Author | SHA1 | Date | |
---|---|---|---|
![]() |
1f7aafcfd7 | ||
![]() |
ed45651fd9 |
@@ -1,9 +1,11 @@
|
|||||||
"""Init file for Supervisor add-on Git."""
|
"""Init file for Supervisor add-on Git."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
from enum import StrEnum
|
||||||
import errno
|
import errno
|
||||||
import functools as ft
|
import functools as ft
|
||||||
import logging
|
import logging
|
||||||
|
from os import listdir
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from tempfile import TemporaryDirectory
|
from tempfile import TemporaryDirectory
|
||||||
|
|
||||||
@@ -20,6 +22,32 @@ from .validate import RE_REPOSITORY
|
|||||||
_LOGGER: logging.Logger = logging.getLogger(__name__)
|
_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):
|
class GitRepo(CoreSysAttributes):
|
||||||
"""Manage Add-on Git repository."""
|
"""Manage Add-on Git repository."""
|
||||||
|
|
||||||
@@ -50,9 +78,14 @@ class GitRepo(CoreSysAttributes):
|
|||||||
|
|
||||||
async def load(self) -> None:
|
async def load(self) -> None:
|
||||||
"""Init Git add-on repository."""
|
"""Init Git add-on repository."""
|
||||||
if not await self.sys_run_in_executor((self.path / ".git").is_dir):
|
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()
|
await self.clone()
|
||||||
return
|
return
|
||||||
|
# Repository is corrupt. Try to reset it before loading
|
||||||
|
case _RepoDirectoryStatus.MISSING_GIT:
|
||||||
|
await self.reset()
|
||||||
|
|
||||||
# Load repository
|
# Load repository
|
||||||
async with self.lock:
|
async with self.lock:
|
||||||
@@ -175,6 +208,22 @@ class GitRepo(CoreSysAttributes):
|
|||||||
async with self.lock:
|
async with self.lock:
|
||||||
_LOGGER.info("Update add-on %s repository from %s", self.path, self.url)
|
_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:
|
try:
|
||||||
git_cmd = git.Git()
|
git_cmd = git.Git()
|
||||||
await self.sys_run_in_executor(git_cmd.ls_remote, "--heads", self.url)
|
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
|
@pytest.fixture
|
||||||
async def coresys(
|
async def coresys(
|
||||||
docker,
|
docker,
|
||||||
dbus_session_bus,
|
dbus_session_bus,
|
||||||
|
addon_repo_fixtures: dict[str, Path],
|
||||||
all_dbus_services,
|
all_dbus_services,
|
||||||
aiohttp_client,
|
aiohttp_client,
|
||||||
run_supervisor_state,
|
run_supervisor_state,
|
||||||
@@ -393,15 +415,9 @@ async def coresys(
|
|||||||
coresys_obj.host.network._connectivity = True
|
coresys_obj.host.network._connectivity = True
|
||||||
|
|
||||||
# Fix Paths
|
# Fix Paths
|
||||||
su_config.ADDONS_CORE = Path(
|
su_config.ADDONS_CORE = addon_repo_fixtures["core"]
|
||||||
Path(__file__).parent.joinpath("fixtures"), "addons/core"
|
su_config.ADDONS_LOCAL = addon_repo_fixtures["local"]
|
||||||
)
|
su_config.ADDONS_GIT = addon_repo_fixtures["git"]
|
||||||
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.APPARMOR_DATA = Path(
|
su_config.APPARMOR_DATA = Path(
|
||||||
Path(__file__).parent.joinpath("fixtures"), "apparmor"
|
Path(__file__).parent.joinpath("fixtures"), "apparmor"
|
||||||
)
|
)
|
||||||
|
@@ -3,13 +3,15 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import AsyncMock, patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
from git import GitCommandError, InvalidGitRepositoryError, NoSuchPathError
|
from git import GitCommandError, InvalidGitRepositoryError, NoSuchPathError
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from supervisor.coresys import CoreSys
|
from supervisor.coresys import CoreSys
|
||||||
from supervisor.exceptions import StoreGitCloneError, StoreGitError
|
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
|
from supervisor.store.git import GitRepo
|
||||||
|
|
||||||
REPO_URL = "https://github.com/awesome-developer/awesome-repo"
|
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
|
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(
|
@pytest.mark.parametrize(
|
||||||
"git_errors",
|
"git_errors",
|
||||||
[
|
[
|
||||||
|
Reference in New Issue
Block a user