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,
+    )