| # Copyright (C) 2026 The Android Open Source Project | |
| # | |
| # Licensed under the Apache License, Version 2.0 (the "License"); | |
| # you may not use this file except in compliance with the License. | |
| # You may obtain a copy of the License at | |
| # | |
| # http://www.apache.org/licenses/LICENSE-2.0 | |
| # | |
| # Unless required by applicable law or agreed to in writing, software | |
| # distributed under the License is distributed on an "AS IS" BASIS, | |
| # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |
| # See the License for the specific language governing permissions and | |
| # limitations under the License. | |
| """Unittests for the command.py module.""" | |
| from typing import Iterable, List, Optional | |
| import pytest | |
| from command import Command | |
| class FakeProject: | |
| """Minimal project double for Command.GetProjects tests.""" | |
| def __init__( | |
| self, | |
| name, | |
| relpath, | |
| *, | |
| gitdir=None, | |
| derived_subprojects=None, | |
| sync_s=False, | |
| exists: bool = True, | |
| ): | |
| self.name = name | |
| self.relpath = relpath | |
| self.worktree = f"/work/{relpath}" | |
| self.manifest = None | |
| self.gitdir = gitdir or f"/git/{relpath}" | |
| self.sync_s = sync_s | |
| self.Exists = exists | |
| self._derived_subprojects = derived_subprojects or [] | |
| def GetDerivedSubprojects(self): | |
| return list(self._derived_subprojects) | |
| def MatchesGroups(self, _groups): | |
| return True | |
| def RelPath(self, local=True): | |
| return self.relpath | |
| class FakeManifest: | |
| """Minimal manifest double for Command.GetProjects tests.""" | |
| def __init__( | |
| self, | |
| projects: Iterable[FakeProject], | |
| *, | |
| all_projects: Optional[Iterable[FakeProject]] = None, | |
| effective_groups: str = "default", | |
| ): | |
| self.projects = list(projects) | |
| self.all_projects = ( | |
| list(self.projects) if all_projects is None else list(all_projects) | |
| ) | |
| self._effective_groups = effective_groups | |
| self.path_prefix = "" | |
| # all_projects may include projects owned by child manifests, | |
| # so only set this manifest on its direct projects. | |
| for project in self.projects: | |
| self._set_project_manifest(project) | |
| def _set_project_manifest(self, project: FakeProject) -> None: | |
| project.manifest = self | |
| for subproject in project.GetDerivedSubprojects(): | |
| self._set_project_manifest(subproject) | |
| def GetManifestGroupsStr(self): | |
| return self._effective_groups | |
| def GetProjectsWithName( | |
| self, name: str, all_manifests: bool = False | |
| ) -> List[FakeProject]: | |
| projects = self.all_projects if all_manifests else self.projects | |
| return [project for project in projects if project.name == name] | |
| class GroupMatchingFakeProject(FakeProject): | |
| """Fake project with predictable group matches for GetProjects tests. | |
| This lets the tests check which groups GetProjects uses without | |
| reimplementing Project.MatchesGroups. | |
| """ | |
| def __init__( | |
| self, | |
| name: str, | |
| relpath: str, | |
| *, | |
| matching_groups: Iterable[str], | |
| ): | |
| super().__init__(name, relpath) | |
| self._matching_groups = set(matching_groups) | |
| def MatchesGroups(self, groups: Iterable[str]) -> bool: | |
| return bool(self._matching_groups.intersection(groups)) | |
| def test_get_projects_keeps_derived_subprojects_for_repeated_repo(): | |
| """Derived subprojects are keyed by checkout path, not repo identity.""" | |
| submodule_a = FakeProject( | |
| "submodule", | |
| "src/one/submodule", | |
| gitdir="/shared/modules/submodule.git", | |
| ) | |
| submodule_b = FakeProject( | |
| "submodule", | |
| "src/two/submodule", | |
| gitdir="/shared/modules/submodule.git", | |
| ) | |
| project_a = FakeProject( | |
| "project", | |
| "src/one", | |
| derived_subprojects=[submodule_a], | |
| sync_s=True, | |
| ) | |
| project_b = FakeProject( | |
| "project", | |
| "src/two", | |
| derived_subprojects=[submodule_b], | |
| sync_s=True, | |
| ) | |
| manifest = FakeManifest([project_a, project_b]) | |
| cmd = Command(manifest=manifest) | |
| projects = cmd.GetProjects([]) | |
| assert set(projects) == {project_a, project_b, submodule_a, submodule_b} | |
| @pytest.mark.parametrize( | |
| "submodules_ok, sync_s, includes_submodule", | |
| [ | |
| (None, False, False), | |
| (None, True, True), | |
| (True, False, True), | |
| (True, True, True), | |
| (False, False, False), | |
| (False, True, False), | |
| ], | |
| ) | |
| def test_get_projects_submodule_override( | |
| submodules_ok, sync_s, includes_submodule | |
| ): | |
| """The CLI override takes precedence over a project's sync-s setting.""" | |
| submodule = FakeProject("submodule", "project/submodule") | |
| project = FakeProject( | |
| "project", | |
| "project", | |
| derived_subprojects=[submodule], | |
| sync_s=sync_s, | |
| ) | |
| cmd = Command(manifest=FakeManifest([project])) | |
| projects = cmd.GetProjects([], submodules_ok=submodules_ok) | |
| assert (submodule in projects) is includes_submodule | |
| @pytest.mark.parametrize( | |
| ("groups", "expected_relpaths"), | |
| [ | |
| (None, ["outer", "sub/child"]), | |
| ("", ["outer", "sub/child"]), | |
| ("override-group", ["sub/override"]), | |
| ], | |
| ids=("groups-omitted", "groups-empty", "explicit-override"), | |
| ) | |
| def test_get_projects_uses_groups_from_each_manifest_unless_overridden( | |
| groups: Optional[str], | |
| expected_relpaths: List[str], | |
| ) -> None: | |
| """Use each manifest's effective groups unless the caller overrides them.""" | |
| outer_project = GroupMatchingFakeProject( | |
| "outer", | |
| "outer", | |
| matching_groups={"outer-group"}, | |
| ) | |
| # Both child projects also match "outer". Reusing the outer manifest's | |
| # groups would therefore select both child projects. | |
| child_project = GroupMatchingFakeProject( | |
| "child", | |
| "sub/child", | |
| matching_groups={"outer-group", "child-group"}, | |
| ) | |
| override_project = GroupMatchingFakeProject( | |
| "override", | |
| "sub/override", | |
| matching_groups={"outer-group", "override-group"}, | |
| ) | |
| child_manifest = FakeManifest( | |
| [child_project, override_project], | |
| effective_groups="child-group", | |
| ) | |
| outer_manifest = FakeManifest( | |
| [outer_project], | |
| all_projects=[outer_project, *child_manifest.projects], | |
| effective_groups="outer-group", | |
| ) | |
| cmd = Command(manifest=outer_manifest) | |
| projects = cmd.GetProjects( | |
| [], | |
| manifest=outer_manifest, | |
| groups=groups, | |
| all_manifests=True, | |
| ) | |
| assert [project.relpath for project in projects] == expected_relpaths | |
| def test_get_projects_by_name_uses_groups_from_each_manifest() -> None: | |
| """Name matches use the groups from each project's owning manifest.""" | |
| outer_project = GroupMatchingFakeProject( | |
| "shared", | |
| "outer/shared", | |
| matching_groups={"outer-group"}, | |
| ) | |
| child_project = GroupMatchingFakeProject( | |
| "shared", | |
| "sub/shared", | |
| matching_groups={"child-group"}, | |
| ) | |
| child_manifest = FakeManifest( | |
| [child_project], | |
| effective_groups="child-group", | |
| ) | |
| outer_manifest = FakeManifest( | |
| [outer_project], | |
| all_projects=[outer_project, *child_manifest.projects], | |
| effective_groups="outer-group", | |
| ) | |
| cmd = Command(manifest=outer_manifest) | |
| projects = cmd.GetProjects( | |
| ["shared"], | |
| manifest=outer_manifest, | |
| all_manifests=True, | |
| ) | |
| assert [project.relpath for project in projects] == [ | |
| "outer/shared", | |
| "sub/shared", | |
| ] | |
| def test_find_projects_uses_groups_from_each_manifest() -> None: | |
| """Use each manifest's effective groups for regex selection.""" | |
| outer_project = GroupMatchingFakeProject( | |
| "match-outer", | |
| "outer", | |
| matching_groups={"outer-group"}, | |
| ) | |
| child_project = GroupMatchingFakeProject( | |
| "match-child", | |
| "sub/child", | |
| matching_groups={"child-group"}, | |
| ) | |
| excluded_project = GroupMatchingFakeProject( | |
| "match-excluded", | |
| "sub/excluded", | |
| matching_groups={"other-group"}, | |
| ) | |
| child_manifest = FakeManifest( | |
| [child_project, excluded_project], | |
| effective_groups="child-group", | |
| ) | |
| child_manifest.path_prefix = "sub" | |
| outer_manifest = FakeManifest( | |
| [outer_project], | |
| all_projects=[ | |
| outer_project, | |
| *child_manifest.projects, | |
| ], | |
| effective_groups="outer-group", | |
| ) | |
| outer_manifest.outer_client = outer_manifest | |
| cmd = Command(manifest=outer_manifest) | |
| projects = cmd.FindProjects(["match"], all_manifests=True) | |
| assert [project.relpath for project in projects] == [ | |
| "outer", | |
| "sub/child", | |
| ] | |
| def test_find_projects_uses_explicit_groups() -> None: | |
| """Use explicit groups for regex selection.""" | |
| default_project = GroupMatchingFakeProject( | |
| "default", | |
| "default", | |
| matching_groups={"default-group"}, | |
| ) | |
| override_project = GroupMatchingFakeProject( | |
| "override", | |
| "override", | |
| matching_groups={"override-group"}, | |
| ) | |
| manifest = FakeManifest( | |
| [default_project, override_project], | |
| effective_groups="default-group", | |
| ) | |
| cmd = Command(manifest=manifest) | |
| projects = cmd.FindProjects( | |
| ["override"], | |
| groups="override-group", | |
| ) | |
| assert projects == [override_project] | |
| def test_find_projects_allows_missing_projects() -> None: | |
| """Allow regex selection to include projects without a checkout.""" | |
| project = FakeProject( | |
| "missing", | |
| "missing", | |
| exists=False, | |
| ) | |
| cmd = Command(manifest=FakeManifest([project])) | |
| projects = cmd.FindProjects( | |
| ["missing"], | |
| missing_ok=True, | |
| ) | |
| assert projects == [project] |