Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions helm/blueapi/config_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -429,6 +429,11 @@
"description": "URL to clone from",
"title": "Remote Url",
"type": "string"
},
"branch": {
"description": "Branch of repo to check out - defaults to remote's default when cloning and the existing branch when the repo already exists",
"title": "Branch",
"type": "string"
}
},
"title": "ScratchRepository",
Expand Down
5 changes: 5 additions & 0 deletions helm/blueapi/values.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -840,6 +840,11 @@
"title": "ScratchRepository",
"type": "object",
"properties": {
"branch": {
"title": "Branch",
"description": "Branch of repo to check out - defaults to remote's default when cloning and the existing branch when the repo already exists",
"type": "string"
},
"name": {
"title": "Name",
"description": "Unique name for this repository in the scratch directory",
Expand Down
23 changes: 19 additions & 4 deletions src/blueapi/cli/scratch.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +48,13 @@ def setup_scratch(
)
for repo in config.repositories:
local_directory = config.root / repo.name
ensure_repo(repo.remote_url, local_directory)
ensure_repo(repo.remote_url, local_directory, repo.branch)
scratch_install(local_directory, timeout=install_timeout)


def ensure_repo(remote_url: str, local_directory: Path) -> None:
def ensure_repo(
remote_url: str, local_directory: Path, branch: str | None = None
) -> None:
"""
Ensure that a repository is checked out for use in the scratch area.
Clone it if it isn't.
Expand All @@ -67,16 +69,29 @@ def ensure_repo(remote_url: str, local_directory: Path) -> None:

if not local_directory.exists():
LOGGER.info(f"Cloning {remote_url}")
Repo.clone_from(remote_url, local_directory)
repo = Repo.clone_from(remote_url, local_directory)
LOGGER.info(f"Cloned {remote_url} -> {local_directory}")
elif local_directory.is_dir():
Repo(local_directory)
repo = Repo(local_directory)
LOGGER.info(f"Found {local_directory}")
else:
raise KeyError(
f"Unable to open {local_directory} as a git repository because it is a file"
)

if branch:
if not (local := getattr(repo.heads, branch, None)):
origin = repo.remotes[0]
origin.fetch()
LOGGER.info(
"Creating branch '%s' to track remote '%s'", branch, origin.refs[branch]
)
local = repo.create_head(branch, origin.refs[branch])
local.set_tracking_branch(origin.refs[branch])

LOGGER.info("Checking out branch '%s'", branch)
local.checkout()


def scratch_install(path: Path, timeout: float = _DEFAULT_INSTALL_TIMEOUT) -> None:
"""
Expand Down
11 changes: 11 additions & 0 deletions src/blueapi/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
ValidationError,
field_validator,
)
from pydantic.json_schema import SkipJsonSchema

from blueapi.utils import BlueapiBaseModel, InvalidConfigError

Expand Down Expand Up @@ -176,6 +177,16 @@ class ScratchRepository(BlueapiBaseModel):
description="URL to clone from",
default="https://github.com/example/example.git",
)
branch: str | SkipJsonSchema[None] = Field(
description=(
"Branch of repo to check out - defaults to remote's default when "
"cloning and the existing branch when the repo already exists"
),
exclude_if=lambda f: f is None,
# using default_factory instead of default means the schema doesn't
# include an invalid value
default_factory=lambda: None,
)

@field_validator("remote_url")
@classmethod
Expand Down
60 changes: 57 additions & 3 deletions tests/unit_tests/cli/test_scratch.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@
from collections.abc import Generator
from pathlib import Path
from tempfile import TemporaryDirectory
from unittest.mock import Mock, PropertyMock, call, patch
from unittest.mock import ANY, MagicMock, Mock, PropertyMock, call, patch

import pytest
from git import Repo

from blueapi.cli.scratch import (
_fetch_installed_packages_details,
Expand Down Expand Up @@ -99,6 +100,11 @@ def test_repo_not_cloned_and_validated_if_found_locally(
mock_repo: Mock,
directory_path_with_sgid: Path,
):
repo = MagicMock(spec=Repo)
# No branch is specified so raise error if branches are checked
del repo.heads
mock_repo.return_value = repo

ensure_repo("http://example.com/foo.git", directory_path_with_sgid)
mock_repo.assert_called_once_with(directory_path_with_sgid)
mock_repo.clone_from.assert_not_called()
Expand All @@ -109,6 +115,11 @@ def test_repo_cloned_if_not_found_locally(
mock_repo: Mock,
nonexistant_path: Path,
):
repo = MagicMock(spec=Repo)
# No branch is specified so raise error if branches are checked
del repo.heads
mock_repo.clone_from.return_value = repo

ensure_repo("http://example.com/foo.git", nonexistant_path)
mock_repo.assert_not_called()
mock_repo.clone_from.assert_called_once_with(
Expand Down Expand Up @@ -143,6 +154,46 @@ def test_repo_discovery_errors_if_file_found_with_repo_name(file_path: Path):
ensure_repo("http://example.com/foo.git", file_path)


@patch("blueapi.cli.scratch.Repo")
def test_cloned_repo_changes_to_new_branch(mock_repo, directory_path: Path):
repo = MagicMock(name="ClonedRepo", spec=Repo)
repo.heads.demo = None
mock_repo.clone_from.return_value = repo

ensure_repo("http://example.com/foo.git", directory_path / "demo_branch", "demo")

mock_repo.clone_from.assert_called_once_with("http://example.com/foo.git", ANY)
repo.create_head.assert_called_once_with("demo", ANY)
repo.create_head().checkout.assert_called_once()


@patch("blueapi.cli.scratch.Repo")
def test_existing_repo_changes_to_existing_branch(mock_repo, directory_path: Path):
(directory_path / "demo_branch").mkdir()
repo = Mock(spec=Repo)
mock_repo.return_value = repo

ensure_repo("http://example.com/foo.git", directory_path / "demo_branch", "demo")

mock_repo.clone_from.assert_not_called()
repo.create_head.assert_not_called()
repo.heads.demo.checkout.assert_called_once()


@patch("blueapi.cli.scratch.Repo")
def test_existing_repo_changes_to_new_branch(mock_repo, directory_path: Path):
(directory_path / "demo_branch").mkdir()
repo = MagicMock(name="ExistingRepo", spec=Repo)
repo.heads.demo = None
mock_repo.return_value = repo

ensure_repo("http://example.com/foo.git", directory_path / "demo_branch", "demo")

mock_repo.clone_from.assert_not_called()
repo.create_head.assert_called_once_with("demo", repo.remotes[0].refs["demo"])
repo.create_head().checkout.assert_called_once()


def test_setup_scratch_fails_on_nonexistant_root(
nonexistant_path: Path,
):
Expand Down Expand Up @@ -248,15 +299,18 @@ def test_setup_scratch_iterates_repos(
ScratchRepository(
name="bar",
remote_url="http://example.com/bar.git",
branch="demo",
),
],
)
setup_scratch(config, install_timeout=120.0)

mock_ensure_repo.assert_has_calls(
[
call("http://example.com/foo.git", directory_path_with_sgid / "foo"),
call("http://example.com/bar.git", directory_path_with_sgid / "bar"),
call("http://example.com/foo.git", directory_path_with_sgid / "foo", None),
call(
"http://example.com/bar.git", directory_path_with_sgid / "bar", "demo"
),
]
)

Expand Down
1 change: 1 addition & 0 deletions tests/unit_tests/test_helm_chart.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@
ScratchRepository(
name="bar",
remote_url="https://example.git",
branch="bar_branch",
),
],
),
Expand Down