diff --git a/helm/blueapi/config_schema.json b/helm/blueapi/config_schema.json index d54bc5357..fc0a37dec 100644 --- a/helm/blueapi/config_schema.json +++ b/helm/blueapi/config_schema.json @@ -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", diff --git a/helm/blueapi/values.schema.json b/helm/blueapi/values.schema.json index 1578c0dad..85d23cced 100644 --- a/helm/blueapi/values.schema.json +++ b/helm/blueapi/values.schema.json @@ -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", diff --git a/src/blueapi/cli/scratch.py b/src/blueapi/cli/scratch.py index 5011819f9..85ad9310c 100644 --- a/src/blueapi/cli/scratch.py +++ b/src/blueapi/cli/scratch.py @@ -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. @@ -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: """ diff --git a/src/blueapi/config.py b/src/blueapi/config.py index eb1b3122e..b550cd88c 100644 --- a/src/blueapi/config.py +++ b/src/blueapi/config.py @@ -21,6 +21,7 @@ ValidationError, field_validator, ) +from pydantic.json_schema import SkipJsonSchema from blueapi.utils import BlueapiBaseModel, InvalidConfigError @@ -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 diff --git a/tests/unit_tests/cli/test_scratch.py b/tests/unit_tests/cli/test_scratch.py index 0e0ac4ad5..1c7840143 100644 --- a/tests/unit_tests/cli/test_scratch.py +++ b/tests/unit_tests/cli/test_scratch.py @@ -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, @@ -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() @@ -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( @@ -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, ): @@ -248,6 +299,7 @@ def test_setup_scratch_iterates_repos( ScratchRepository( name="bar", remote_url="http://example.com/bar.git", + branch="demo", ), ], ) @@ -255,8 +307,10 @@ def test_setup_scratch_iterates_repos( 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" + ), ] ) diff --git a/tests/unit_tests/test_helm_chart.py b/tests/unit_tests/test_helm_chart.py index f6e34ddf5..52394c561 100644 --- a/tests/unit_tests/test_helm_chart.py +++ b/tests/unit_tests/test_helm_chart.py @@ -84,6 +84,7 @@ ScratchRepository( name="bar", remote_url="https://example.git", + branch="bar_branch", ), ], ),