diff --git a/man/repo-gc.1 b/man/repo-gc.1 index 5a8ac47dc..61b3949da 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 @@ answer yes to all safe prompts .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 @@ only operate on this (sub)manifest 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 1d1023a1f..8b54f8797 100644 --- a/subcmds/gc.py +++ b/subcmds/gc.py @@ -28,6 +28,14 @@ class Gc(Command): helpSummary = "Cleaning up internal repo and Git state." 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): @@ -53,6 +61,12 @@ class Gc(Command): 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 @@ class Gc(Command): 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 f5c415e13..4aaa9718c 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 @@ class GcCommand(unittest.TestCase): 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( + """\ + + + + + + + """, + 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)