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>
This commit is contained in:
Victor Pushkarev authored and gerrit-scoped@luci-project-accounts.iam.gserviceaccount.com committed 2026-09-16 04:54:02 -07:00
1 parent fb4a91060e
commit c638b54e19
6 files changed
+230 -6

No files matched your search

+17 -2
View File
@@ -545,7 +545,14 @@ class Command:
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 @@ class Command:
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)
+6 -2
View File
@@ -244,10 +244,14 @@ without iterating through the remaining projects.
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(
+4 -1
View File
@@ -109,7 +109,10 @@ This is similar to running: repo forall -c 'echo "$REPO_PATH : $REPO_PROJECT"'.
)
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):
+90 -1
View File
@@ -32,6 +32,7 @@ class FakeProject:
gitdir=None,
derived_subprojects=None,
sync_s=False,
exists: bool = True,
):
self.name = name
self.relpath = relpath
@@ -39,7 +40,7 @@ class FakeProject:
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 @@ class FakeManifest:
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 @@ def test_get_projects_by_name_uses_groups_from_each_manifest() -> None:
"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]
+56
View File
@@ -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 @@ def test_forall_all_projects_called_once(tmp_path: Path) -> None:
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,
)
+57
View File
@@ -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,
)