gc: add --keep-manifest-projects

By default, `repo gc` treats projects excluded by the configured
manifest groups as unused. This matches the active project set, but it
can remove cached Git data for projects that might be used again after
changing groups.

Add `--keep-manifest-projects` to keep Git data for projects that are
still in the manifests, even if the current groups exclude them or their
checkout is missing. Git data for projects no longer in the manifests
can still be removed.

Change-Id: I63ae18ce1c6acc2463be86f81921476b12fb27d7
Reviewed-on: https://gerrit-review.googlesource.com/c/git-repo/+/629741
Reviewed-by: Gavin Mak <gavinmak@google.com>
Reviewed-by: Mike Frysinger <vapier@google.com>
Tested-by: Victor Pushkarev <corvinus.v@gmail.com>
Commit-Queue: Victor Pushkarev <corvinus.v@gmail.com>
diff --git a/man/repo-gc.1 b/man/repo-gc.1
index 5a8ac47..61b3949 100644
--- a/man/repo-gc.1
+++ b/man/repo-gc.1
@@ -1,5 +1,5 @@
 .\" DO NOT MODIFY THIS FILE!  It was generated by help2man.
-.TH REPO "1" "June 2026" "repo gc" "Repo Manual"
+.TH REPO "1" "September 2026" "repo gc" "Repo Manual"
 .SH NAME
 repo \- repo gc - manual page for repo gc
 .SH SYNOPSIS
@@ -22,6 +22,9 @@
 .TP
 \fB\-\-repack\fR
 repack all projects that use partial clone with filter=blob:none
+.TP
+\fB\-\-keep\-manifest\-projects\fR
+keep Git data for projects still in the manifests
 .SS Logging options:
 .TP
 \fB\-v\fR, \fB\-\-verbose\fR
@@ -44,3 +47,11 @@
 operate on this manifest and its submanifests
 .PP
 Run `repo help gc` to view the detailed manual.
+.SH DETAILS
+.PP
+The 'repo gc' command removes internal Git data for projects that are no longer
+active in the current configuration.
+.PP
+Projects excluded by the configured manifest groups are inactive by default. Use
+\fB\-\-keep\-manifest\-projects\fR to keep Git data for projects that are still in the
+manifests.
diff --git a/subcmds/gc.py b/subcmds/gc.py
index 1d1023a..8b54f87 100644
--- a/subcmds/gc.py
+++ b/subcmds/gc.py
@@ -29,6 +29,14 @@
     helpUsage = """
 %prog
 """
+    helpDescription = """
+The '%prog' command removes internal Git data for projects that are no
+longer active in the current configuration.
+
+Projects excluded by the configured manifest groups are inactive by
+default. Use --keep-manifest-projects to keep Git data for projects that
+are still in the manifests.
+"""
 
     def _Options(self, p):
         p.add_option(
@@ -53,6 +61,12 @@
             help="repack all projects that use partial clone with "
             "filter=blob:none",
         )
+        p.add_option(
+            "--keep-manifest-projects",
+            default=False,
+            action="store_true",
+            help="keep Git data for projects still in the manifests",
+        )
 
     def _find_git_to_delete(
         self, to_keep: Set[str], start_dir: str
@@ -292,16 +306,21 @@
             args, all_manifests=not opt.this_manifest_only
         )
 
-        # If the user specified projects, fetch the global list separately
-        # to avoid deleting untargeted projects.
-        if args:
-            all_projects = self.GetProjects(
+        if opt.keep_manifest_projects:
+            projects_to_keep = self.GetProjects(
+                [],
+                groups="all",
+                missing_ok=True,
+                all_manifests=not opt.this_manifest_only,
+            )
+        elif args:
+            projects_to_keep = self.GetProjects(
                 [], all_manifests=not opt.this_manifest_only
             )
         else:
-            all_projects = projects
+            projects_to_keep = projects
 
-        ret = self.delete_unused_projects(all_projects, opt)
+        ret = self.delete_unused_projects(projects_to_keep, opt)
         if ret != 0:
             return ret
 
diff --git a/tests/test_subcmds_gc.py b/tests/test_subcmds_gc.py
index f5c415e..4aaa971 100644
--- a/tests/test_subcmds_gc.py
+++ b/tests/test_subcmds_gc.py
@@ -14,9 +14,11 @@
 
 """Unittests for the subcmds/gc.py module."""
 
+from pathlib import Path
 import unittest
 from unittest import mock
 
+import manifest_xml
 from subcmds import gc
 
 
@@ -80,3 +82,108 @@
         ret = self.cmd.Execute(self.opt, [])
         self.assertEqual(ret, 1)
         self.mock_repack.assert_not_called()
+
+
+def test_gc_keep_manifest_projects(tmp_path: Path) -> None:
+    """Keep inactive projects that are still in the manifest."""
+    repodir = tmp_path / ".repo"
+    manifest_dir = repodir / "manifests"
+    manifest_file = repodir / manifest_xml.MANIFEST_FILE_NAME
+
+    repodir.mkdir()
+    manifest_dir.mkdir()
+
+    manifest_gitdir = repodir / "manifests.git"
+    manifest_gitdir.mkdir()
+    (manifest_gitdir / "config").write_text(
+        """[remote "origin"]
+        url = https://localhost:0/manifest
+        """,
+        encoding="utf-8",
+    )
+
+    manifest_file.write_text(
+        """\
+        <manifest>
+        <remote name="origin" fetch="http://localhost" />
+        <default remote="origin" revision="refs/heads/main" />
+        <project name="included" groups="active-group" />
+        <project name="excluded" groups="other-group" />
+        </manifest>
+        """,
+        encoding="utf-8",
+    )
+
+    manifest = manifest_xml.XmlManifest(str(repodir), str(manifest_file))
+    manifest.manifestProject.config.SetString(
+        "manifest.groups",
+        "active-group",
+    )
+
+    projects = {project.name: project for project in manifest.projects}
+    included = projects["included"]
+    excluded = projects["excluded"]
+
+    Path(included.gitdir).mkdir(parents=True)
+    Path(included.objdir).mkdir(parents=True)
+
+    # Simulate an inactive project with only its object cache left.
+    Path(excluded.objdir).mkdir(parents=True)
+    assert not excluded.Exists
+
+    orphan_gitdir = repodir / "projects" / "orphan.git"
+    orphan_objdir = repodir / "project-objects" / "orphan.git"
+    orphan_gitdir.mkdir()
+    orphan_objdir.mkdir()
+
+    cmd = gc.Gc(repodir=str(repodir), manifest=manifest)
+
+    opt, args = cmd.OptionParser.parse_args(
+        ["--yes", "--keep-manifest-projects"]
+    )
+    opt.quiet = True
+
+    selected_projects = cmd.GetProjects([], all_manifests=True)
+    assert included in selected_projects
+    assert excluded not in selected_projects
+
+    cmd.Execute(opt, args)
+
+    assert included.Exists
+    assert Path(excluded.objdir).exists()
+    assert not orphan_gitdir.exists()
+    assert not orphan_objdir.exists()
+
+
+def test_gc_keep_manifest_projects_preserves_repack_targets() -> None:
+    """Keep manifest projects without expanding repack targets."""
+    cmd = gc.Gc()
+    opt, _ = cmd.OptionParser.parse_args(
+        ["--keep-manifest-projects", "--repack"]
+    )
+    opt.this_manifest_only = False
+
+    with mock.patch.object(
+        cmd,
+        "GetProjects",
+        side_effect=[["projA"], ["manifest_projects"]],
+    ) as get_projects, mock.patch.object(
+        cmd, "delete_unused_projects", return_value=0
+    ) as delete, mock.patch.object(
+        cmd, "repack_projects", return_value=0
+    ) as repack:
+        cmd.Execute(opt, ["projA"])
+
+    get_projects.assert_has_calls(
+        [
+            mock.call(["projA"], all_manifests=True),
+            mock.call(
+                [],
+                groups="all",
+                missing_ok=True,
+                all_manifests=True,
+            ),
+        ]
+    )
+    delete.assert_called_once_with(["manifest_projects"], opt)
+    repack.assert_called_once_with(["projA"], opt)