From 0b82311632d14d3f49675fb6a017c20f7a7dee37 Mon Sep 17 00:00:00 2001 From: Andrew Chant Date: Fri, 17 Jul 2026 15:09:06 -0700 Subject: [PATCH] sync: allow syncing groups with repo sync -g group Similar to how repo init -g can restrict repo syncs to a subset of the manifest globally, allow "repo sync -g" to only sync a subset of projects from the manifest when running that specific sync command. Change-Id: I4929aad109de05c73a7db42bb27fd8d47eea32fc Reviewed-on: https://gerrit-review.googlesource.com/c/git-repo/+/609481 Reviewed-by: Mike Frysinger Commit-Queue: Andrew Chant Reviewed-by: Gavin Mak Tested-by: Andrew Chant --- man/repo-smartsync.1 | 3 + man/repo-sync.1 | 3 + subcmds/sync.py | 11 ++++ tests/test_subcmds_sync.py | 129 +++++++++++++++++++++++++++++++++++++ 4 files changed, 146 insertions(+) diff --git a/man/repo-smartsync.1 b/man/repo-smartsync.1 index e3fddbc72..f4566dc01 100644 --- a/man/repo-smartsync.1 +++ b/man/repo-smartsync.1 @@ -68,6 +68,9 @@ fetch all branches from server \fB\-m\fR NAME.xml, \fB\-\-manifest\-name\fR=\fI\,NAME\/\fR.xml temporary manifest to use for this sync .TP +\fB\-g\fR GROUP, \fB\-\-groups\fR=\fI\,GROUP\/\fR +sync projects matching the specific groups. Not persistent unlike when used on init +.TP \fB\-\-clone\-bundle\fR enable use of \fI\,/clone.bundle\/\fP on HTTP/HTTPS .TP diff --git a/man/repo-sync.1 b/man/repo-sync.1 index 66cd37a9e..754c02927 100644 --- a/man/repo-sync.1 +++ b/man/repo-sync.1 @@ -68,6 +68,9 @@ fetch all branches from server \fB\-m\fR NAME.xml, \fB\-\-manifest\-name\fR=\fI\,NAME\/\fR.xml temporary manifest to use for this sync .TP +\fB\-g\fR GROUP, \fB\-\-groups\fR=\fI\,GROUP\/\fR +sync projects matching the specific groups. Not persistent unlike when used on init +.TP \fB\-\-clone\-bundle\fR enable use of \fI\,/clone.bundle\/\fP on HTTP/HTTPS .TP diff --git a/subcmds/sync.py b/subcmds/sync.py index 3fdb36afa..573c3444b 100644 --- a/subcmds/sync.py +++ b/subcmds/sync.py @@ -558,6 +558,13 @@ later is required to fix a server side protocol bug. help="temporary manifest to use for this sync", metavar="NAME.xml", ) + p.add_option( + "-g", + "--groups", + help="sync projects matching the specific groups. Not persistent " + "unlike when used on init", + metavar="GROUP", + ) p.add_option( "--clone-bundle", action="store_true", @@ -774,6 +781,7 @@ later is required to fix a server side protocol bug. all_projects = self.GetProjects( args, + groups=opt.groups, missing_ok=True, submodules_ok=opt.recurse_submodules, manifest=manifest, @@ -1103,6 +1111,7 @@ later is required to fix a server side protocol bug. self._ReloadManifest(None, manifest) all_projects = self.GetProjects( args, + groups=opt.groups, missing_ok=True, submodules_ok=opt.recurse_submodules, manifest=manifest, @@ -2350,6 +2359,7 @@ later is required to fix a server side protocol bug. all_projects = self.GetProjects( args, + groups=opt.groups, missing_ok=True, submodules_ok=opt.recurse_submodules, manifest=manifest, @@ -3013,6 +3023,7 @@ later is required to fix a server side protocol bug. self._ReloadManifest(None, manifest) project_list = self.GetProjects( args, + groups=opt.groups, missing_ok=True, submodules_ok=opt.recurse_submodules, manifest=manifest, diff --git a/tests/test_subcmds_sync.py b/tests/test_subcmds_sync.py index 36ba13cae..bc0a82759 100644 --- a/tests/test_subcmds_sync.py +++ b/tests/test_subcmds_sync.py @@ -15,6 +15,7 @@ import json import os +from pathlib import Path import shutil import tempfile import time @@ -26,6 +27,7 @@ import pytest import command from error import GitError from error import RepoExitError +import manifest_xml from project import SyncNetworkHalfResult from subcmds import sync @@ -96,6 +98,120 @@ def test_get_current_branch_only(use_superproject, cli_args, result): assert cmd._GetCurrentBranchOnly(opts, cmd.manifest) == result +@pytest.mark.parametrize( + "cli_args, expected_groups", + [ + ([], None), + (["-g", "groupA"], "groupA"), + (["--groups=groupB,groupC"], "groupB,groupC"), + ], +) +def test_groups_option_parsing(cli_args, expected_groups): + """Test --groups / -g option parsing.""" + cmd = sync.Sync() + opts, _ = cmd.OptionParser.parse_args(cli_args) + assert opts.groups == expected_groups + + +def _create_manifest_with_groups(topdir: Path) -> manifest_xml.XmlManifest: + """Create a test XmlManifest with projects assigned to various groups.""" + repodir = topdir / ".repo" + manifest_dir = repodir / "manifests" + manifest_file = repodir / manifest_xml.MANIFEST_FILE_NAME + + repodir.mkdir(exist_ok=True) + manifest_dir.mkdir(exist_ok=True) + + gitdir = repodir / "manifests.git" + gitdir.mkdir(exist_ok=True) + (gitdir / "config").write_text( + """[remote "origin"] + url = https://localhost:0/manifest + """, + encoding="utf-8", + ) + + manifest_file.write_text( + """ + + + + + + + + + + """, + encoding="utf-8", + ) + + for p in [ + "proj_g1", + "proj_g2", + "proj_g1_g2", + "proj_default", + "proj_notdefault", + ]: + (repodir / "projects" / f"{p}.git").mkdir(parents=True, exist_ok=True) + + return manifest_xml.XmlManifest(str(repodir), str(manifest_file)) + + +@pytest.mark.parametrize( + "cli_args, expected_projects", + [ + (["-g", "group1"], ["proj_g1", "proj_g1_g2"]), + (["-g", "group2"], ["proj_g2", "proj_g1_g2"]), + (["-g", "group1,group2"], ["proj_g1", "proj_g1_g2", "proj_g2"]), + (["-g", "default,-group1"], ["proj_default", "proj_g2"]), + ([], ["proj_default", "proj_g1", "proj_g1_g2", "proj_g2"]), + ], +) +def test_sync_groups_manifest_filtering( + tmp_path: Path, cli_args, expected_projects +): + """Test that repo sync -g selects only matching projects.""" + manifest = _create_manifest_with_groups(tmp_path) + cmd = sync.Sync() + cmd.manifest = manifest + + opts, args = cmd.OptionParser.parse_args(cli_args) + projects = cmd.GetProjects(args, groups=opts.groups, missing_ok=True) + project_names = sorted([p.name for p in projects]) + assert project_names == sorted(expected_projects) + + +def test_sync_update_projects_revision_id_respects_groups(tmp_path: Path): + """Test that _UpdateProjectsRevisionId filters projects using opt.groups.""" + manifest = _create_manifest_with_groups(tmp_path) + cmd = sync.Sync() + cmd.manifest = manifest + + superproject = mock.MagicMock() + superproject.UpdateProjectsRevisionId.return_value = mock.MagicMock( + manifest_path=None + ) + manifest._superproject = superproject + + opts, args = cmd.OptionParser.parse_args(["-g", "group1"]) + opts.verbose = False + opts.fetch_submodules = False + opts.this_manifest_only = True + opts.local_only = False + + with mock.patch.object( + cmd, "GetProjects", wraps=cmd.GetProjects + ) as spy_get_projects: + with mock.patch.object(cmd, "ManifestList", return_value=[manifest]): + cmd._UpdateProjectsRevisionId(opts, args, {}, manifest) + spy_get_projects.assert_called_once() + _, kwargs = spy_get_projects.call_args + assert kwargs.get("groups") == "group1" + + # Used to patch os.cpu_count() for reliable results. OS_CPU_COUNT = 24 @@ -832,6 +948,19 @@ class SyncCommand(unittest.TestCase): self.assertIn(self.sync_local_half_error, e.aggregate_errors) self.assertIn(self.sync_network_half_error, e.aggregate_errors) + def test_groups_passed_to_get_projects(self): + """Ensure Execute passes opt.groups to GetProjects.""" + self.opt.groups = "my_group" + self.opt.mp_update = False + with mock.patch.object(self.cmd, "_UpdateRepoProject"): + with mock.patch.object(self.cmd, "_ValidateOptionsWithManifest"): + with mock.patch.object(self.cmd, "_SyncInterleaved"): + with mock.patch.object(self.cmd, "_RunPostSyncHook"): + self.cmd.Execute(self.opt, []) + self.cmd.GetProjects.assert_called() + _, kwargs = self.cmd.GetProjects.call_args + self.assertEqual(kwargs.get("groups"), "my_group") + class SyncUpdateRepoProject(unittest.TestCase): """Tests for Sync._UpdateRepoProject."""