command: preserve project filters in regex selection `FindProjects` gets its candidate projects from `GetProjects` and then applies regex matching. However, it only forwards `all_manifests`, so other project filters are lost on regex paths. As a result, `repo forall -r/-i --groups` falls back to the configured group selection instead of using the requested groups. Similarly, `repo list -r --groups` ignores the requested groups, and `repo list -r --all` still excludes projects without a checkout. Pass `groups` and `missing_ok` through `FindProjects`, and forward the existing options from `forall` and `list`. When no group override is given, keep using each manifest's effective groups. Add regression tests for `FindProjects` and both command paths. Change-Id: I5dfc22172fdd81de5312a84433867300a1342aec Reviewed-on: https://gerrit-review.googlesource.com/c/git-repo/+/631281 Reviewed-by: Mike Frysinger <vapier@google.com> Reviewed-by: Gavin Mak <gavinmak@google.com> Commit-Queue: Victor Pushkarev <corvinus.v@gmail.com> Tested-by: Victor Pushkarev <corvinus.v@gmail.com>
diff --git a/command.py b/command.py index 6aa0ca8..7f60834 100644 --- a/command.py +++ b/command.py
@@ -545,7 +545,14 @@ result.sort(key=_getpath) return result - def FindProjects(self, args, inverse=False, all_manifests=False): + def FindProjects( + self, + args: List[str], + inverse: bool = False, + all_manifests: bool = False, + groups: Optional[str] = "", + missing_ok: Optional[bool] = False, + ) -> List["Project"]: """Find projects from command line arguments. Args: @@ -555,10 +562,18 @@ all_manifests: a boolean, if True then all manifests and submanifests are used. If False, then only the local (sub)manifest is used. + groups: a string specifying manifest groups. If empty or None, use + each manifest's effective groups. + missing_ok: a boolean, whether to allow missing projects. """ result = [] patterns = [re.compile(r"%s" % a, re.IGNORECASE) for a in args] - for project in self.GetProjects("", all_manifests=all_manifests): + for project in self.GetProjects( + "", + groups=groups, + missing_ok=missing_ok, + all_manifests=all_manifests, + ): paths = [project.name, project.RelPath(local=not all_manifests)] for pattern in patterns: match = any(pattern.search(x) for x in paths)
diff --git a/subcmds/forall.py b/subcmds/forall.py index 982b1ba..598ab49 100644 --- a/subcmds/forall.py +++ b/subcmds/forall.py
@@ -244,10 +244,14 @@ mirror = self.manifest.IsMirror if opt.regex: - projects = self.FindProjects(args, all_manifests=all_trees) + projects = self.FindProjects( + args, + groups=opt.groups, + all_manifests=all_trees, + ) elif opt.inverse_regex: projects = self.FindProjects( - args, inverse=True, all_manifests=all_trees + args, inverse=True, groups=opt.groups, all_manifests=all_trees ) else: projects = self.GetProjects(
diff --git a/subcmds/list.py b/subcmds/list.py index df9ce5f..3210ccd 100644 --- a/subcmds/list.py +++ b/subcmds/list.py
@@ -109,7 +109,10 @@ ) else: projects = self.FindProjects( - args, all_manifests=not opt.this_manifest_only + args, + groups=opt.groups, + missing_ok=opt.all, + all_manifests=not opt.this_manifest_only, ) def _getpath(x):
diff --git a/tests/test_command.py b/tests/test_command.py index ef6f7a0..45bb09f 100644 --- a/tests/test_command.py +++ b/tests/test_command.py
@@ -32,6 +32,7 @@ gitdir=None, derived_subprojects=None, sync_s=False, + exists: bool = True, ): self.name = name self.relpath = relpath @@ -39,7 +40,7 @@ self.manifest = None self.gitdir = gitdir or f"/git/{relpath}" self.sync_s = sync_s - self.Exists = True + self.Exists = exists self._derived_subprojects = derived_subprojects or [] def GetDerivedSubprojects(self): @@ -67,6 +68,7 @@ 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. @@ -258,3 +260,90 @@ "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]
diff --git a/tests/test_subcmds_forall.py b/tests/test_subcmds_forall.py index 67ec43c..855185c 100644 --- a/tests/test_subcmds_forall.py +++ b/tests/test_subcmds_forall.py
@@ -17,7 +17,9 @@ import contextlib import io from pathlib import Path +from unittest import mock +import pytest import utils_for_test import manifest_xml @@ -105,3 +107,57 @@ line_count = sum(1 for x in output.splitlines() if x) # Verify that we didn't get more lines than expected. assert line_count == 8 + + +@pytest.mark.parametrize( + ("regex_option", "inverse"), + [ + ("-r", False), + ("-i", True), + ], + ids=("regex", "inverse-regex"), +) +def test_forall_regex_modes_pass_groups_to_find_projects( + tmp_path: Path, + regex_option: str, + inverse: bool, +) -> None: + """Pass --groups through in regex modes.""" + manifest = _create_manifest_with_8_projects(tmp_path) + + cmd = subcmds.forall.Forall() + cmd.manifest = manifest + + opts, args = cmd.OptionParser.parse_args( + [ + regex_option, + "--groups", + "special", + "project", + "-c", + "true", + ] + ) + + with mock.patch.object( + cmd, + "FindProjects", + return_value=[], + ) as find_projects, mock.patch.object( + cmd, + "ExecuteInParallel", + return_value=0, + ): + cmd.Execute(opts, args) + + expected_kwargs = { + "groups": "special", + "all_manifests": True, + } + if inverse: + expected_kwargs["inverse"] = True + + find_projects.assert_called_once_with( + ["project"], + **expected_kwargs, + )
diff --git a/tests/test_subcmds_list.py b/tests/test_subcmds_list.py new file mode 100644 index 0000000..29a7032 --- /dev/null +++ b/tests/test_subcmds_list.py
@@ -0,0 +1,57 @@ +# 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 list subcmd.""" + +from typing import List, Optional +from unittest import mock + +import pytest + +import subcmds + + +@pytest.mark.parametrize( + ("extra_args", "expected_groups", "expected_missing_ok"), + [ + (["--groups", "special"], "special", None), + (["--all"], None, True), + ], + ids=("groups", "all"), +) +def test_list_regex_passes_groups_and_all( + extra_args: List[str], + expected_groups: Optional[str], + expected_missing_ok: Optional[bool], +) -> None: + """Pass --groups and --all through in regex mode.""" + cmd = subcmds.list.List() + + opts, args = cmd.OptionParser.parse_args( + ["--regex", *extra_args, "project"] + ) + + with mock.patch.object( + cmd, + "FindProjects", + return_value=[], + ) as find_projects: + cmd.Execute(opts, args) + + find_projects.assert_called_once_with( + ["project"], + groups=expected_groups, + missing_ok=expected_missing_ok, + all_manifests=True, + )