diff --git a/git_superproject.py b/git_superproject.py index 81a6b2e59..27bc10e10 100644 --- a/git_superproject.py +++ b/git_superproject.py @@ -34,6 +34,7 @@ import urllib.parse from git_command import git_require from git_command import GitCommand +from git_config import IsId from git_config import RepoConfig from git_refs import GitRefs import platform_utils @@ -132,6 +133,10 @@ class Superproject: """Set the _print_messages attribute.""" self._print_messages = value + def SetRevisionId(self, revision_id: str) -> None: + """Set the revisionId of the superproject to sync to.""" + self.revision = revision_id + @property def commit_id(self): """Returns the commit ID of the superproject checkout.""" @@ -314,7 +319,14 @@ class Superproject: cmd.extend(["--negotiation-tip", rev_commit]) if self.revision: - cmd += [self.revision + ":" + self.revision] + # If revision is a commit hash, fetch it directly to avoid + # creating a local branch of the same name. + refspec = ( + self.revision + if IsId(self.revision) + else f"{self.revision}:{self.revision}" + ) + cmd.append(refspec) p = GitCommand( None, cmd, @@ -401,6 +413,8 @@ class Superproject: if not self._Init(): return SyncResult(False, should_exit) + if IsId(self.revision) and self.commit_id: + return SyncResult(True, False) if not self._Fetch(): return SyncResult(False, should_exit) if not self._quiet: diff --git a/man/repo-smartsync.1 b/man/repo-smartsync.1 index 5ea063e6b..25b85f126 100644 --- a/man/repo-smartsync.1 +++ b/man/repo-smartsync.1 @@ -1,5 +1,5 @@ .\" DO NOT MODIFY THIS FILE! It was generated by help2man. -.TH REPO "1" "August 2025" "repo smartsync" "Repo Manual" +.TH REPO "1" "May 2026" "repo smartsync" "Repo Manual" .SH NAME repo \- repo smartsync - manual page for repo smartsync .SH SYNOPSIS @@ -101,6 +101,10 @@ implies \fB\-c\fR \fB\-\-no\-use\-superproject\fR disable use of manifest superprojects .TP +\fB\-\-superproject\-revision\fR=\fI\,SUPERPROJECT_REVISION\/\fR +sync to superproject revision (applies to outer +manifest) +.TP \fB\-\-tags\fR fetch tags .TP diff --git a/man/repo-sync.1 b/man/repo-sync.1 index 8f145a09e..4e115e200 100644 --- a/man/repo-sync.1 +++ b/man/repo-sync.1 @@ -1,5 +1,5 @@ .\" DO NOT MODIFY THIS FILE! It was generated by help2man. -.TH REPO "1" "August 2025" "repo sync" "Repo Manual" +.TH REPO "1" "May 2026" "repo sync" "Repo Manual" .SH NAME repo \- repo sync - manual page for repo sync .SH SYNOPSIS @@ -101,6 +101,10 @@ implies \fB\-c\fR \fB\-\-no\-use\-superproject\fR disable use of manifest superprojects .TP +\fB\-\-superproject\-revision\fR=\fI\,SUPERPROJECT_REVISION\/\fR +sync to superproject revision (applies to outer +manifest) +.TP \fB\-\-tags\fR fetch tags .TP diff --git a/subcmds/sync.py b/subcmds/sync.py index 8c2591180..7e0e7418c 100644 --- a/subcmds/sync.py +++ b/subcmds/sync.py @@ -64,6 +64,7 @@ from error import SyncError from error import UpdateManifestError import event_log from git_command import git_require +from git_command import GitCommand from git_config import GetUrlCookieFile from git_refs import HEAD from git_refs import R_HEADS @@ -565,6 +566,11 @@ later is required to fix a server side protocol bug. dest="use_superproject", help="disable use of manifest superprojects", ) + p.add_option( + "--superproject-revision", + action="store", + help="sync to superproject revision (applies to outer manifest)", + ) p.add_option("--tags", action="store_true", help="fetch tags") p.add_option( "--no-tags", @@ -668,6 +674,24 @@ later is required to fix a server side protocol bug. or opt.current_branch_only ) + def _ConfigureSuperproject( + self, + opt: optparse.Values, + manifest, + revision: Optional[str] = None, + ) -> bool: + """Configure superproject with options.""" + if not manifest.superproject: + return False + manifest.superproject.SetQuiet(not opt.verbose) + print_messages = git_superproject.PrintMessages( + opt.use_superproject, manifest + ) + manifest.superproject.SetPrintMessages(print_messages) + if revision: + manifest.superproject.SetRevisionId(revision) + return print_messages + def _UpdateProjectsRevisionId( self, opt, args, superproject_logging_data, manifest ): @@ -741,11 +765,7 @@ later is required to fix a server side protocol bug. if not use_super: continue - m.superproject.SetQuiet(not opt.verbose) - print_messages = git_superproject.PrintMessages( - opt.use_superproject, m - ) - m.superproject.SetPrintMessages(print_messages) + print_messages = self._ConfigureSuperproject(opt, m) update_result = m.superproject.UpdateProjectsRevisionId( per_manifest[m.path_prefix], git_event_log=self.git_event_log ) @@ -1832,7 +1852,11 @@ later is required to fix a server side protocol bug. mp: the manifestProject to query. manifest_name: Manifest file to be reloaded. """ - if not mp.standalone_manifest_url: + if opt.superproject_revision and mp.manifest == self.outer_manifest: + self._SyncToSuperprojectRev( + opt, mp.manifest, mp, manifest_name, errors + ) + elif not mp.standalone_manifest_url: self._UpdateManifestProject(opt, mp, manifest_name, errors) if mp.manifest.submanifests: @@ -2041,6 +2065,53 @@ later is required to fix a server side protocol bug. if not success: print("Warning: post-sync hook reported failure.") + def _SyncToSuperprojectRev( + self, + opt: optparse.Values, + manifest, + mp: Project, + manifest_name: Optional[str], + errors: List[Exception], + ) -> None: + """Sync to a specific superproject commit.""" + if not manifest.superproject: + raise SyncError("superproject not defined in manifest") + + self._ConfigureSuperproject( + opt, manifest, revision=opt.superproject_revision + ) + + sync_result = manifest.superproject.Sync(self.git_event_log) + if not sync_result.success: + raise SyncError("failed to sync superproject") + + cmd = ["show", f"{opt.superproject_revision}:.supermanifest"] + p = GitCommand( + None, + cmd, + gitdir=manifest.superproject._work_git, + bare=True, + capture_stdout=True, + capture_stderr=True, + ) + if p.Wait() != 0: + raise SyncError( + f"failed to read .supermanifest from superproject: {p.stderr}" + ) + + try: + _, _, manifest_commit = p.stdout.strip().split() + except ValueError: + raise SyncError("could not parse .supermanifest") + + mp.SetRevision(manifest_commit) + try: + self._UpdateManifestProject(opt, mp, manifest_name, errors) + except UpdateManifestError as e: + raise SyncError( + "failed to sync manifest project", aggregate_errors=[e] + ) + def _ExecuteHelper(self, opt, args, errors): manifest = self.outer_manifest if not opt.outer_manifest: @@ -2099,7 +2170,7 @@ later is required to fix a server side protocol bug. ): mp.ConfigureCloneFilterForDepth("blob:none") - if opt.mp_update: + if opt.mp_update or opt.superproject_revision: self._UpdateAllManifestProjects(opt, mp, manifest_name, errors) else: print("Skipping update of local manifest project.") diff --git a/tests/test_subcmds_sync.py b/tests/test_subcmds_sync.py index ef162392b..785c0e629 100644 --- a/tests/test_subcmds_sync.py +++ b/tests/test_subcmds_sync.py @@ -1286,3 +1286,171 @@ class UpdateCopyLinkfileListTest(unittest.TestCase): self.assertFalse(os.path.lexists(os.path.join(llms_dir, "rules"))) self.assertTrue(os.path.exists(os.path.join(llms_dir, "my-notes.txt"))) self.assertTrue(os.path.isdir(llms_dir)) + + +class SyncToSuperprojectRevTests(unittest.TestCase): + """Tests for Sync._SyncToSuperprojectRev.""" + + def setUp(self): + self.repodir = tempfile.mkdtemp(".repo") + self.manifest = mock.MagicMock(repodir=self.repodir) + self.manifest.superproject = mock.MagicMock() + self.manifest.path_prefix = "" + + self.mp = mock.MagicMock() + self.cmd = sync.Sync(manifest=self.manifest) + self.cmd.outer_manifest = self.manifest + + self.opt = mock.Mock() + self.opt.verbose = False + self.opt.superproject_revision = "deadbeef" + self.opt.mp_update = True + + self.errors = [] + + def tearDown(self): + shutil.rmtree(self.repodir) + + @mock.patch("subcmds.sync.GitCommand") + def test_successful_sync(self, mock_git_command): + """Test successful sync to superproject rev.""" + mock_superproject = self.manifest.superproject + mock_superproject.Sync.return_value = mock.Mock(success=True) + + mock_git = mock.Mock() + mock_git.Wait.return_value = 0 + mock_git.stdout = "proj branch manifest_commit_hash\n" + mock_git_command.return_value = mock_git + + with mock.patch.object( + self.cmd, "_UpdateManifestProject" + ) as mock_update: + self.cmd._SyncToSuperprojectRev( + self.opt, self.manifest, self.mp, "name", self.errors + ) + + mock_superproject.SetRevisionId.assert_called_with("deadbeef") + mock_superproject.Sync.assert_called_once() + mock_git_command.assert_called_once() + self.mp.SetRevision.assert_called_with("manifest_commit_hash") + mock_update.assert_called_once() + self.assertEqual(self.errors, []) + + @mock.patch("subcmds.sync.GitCommand") + def test_parse_error(self, mock_git_command): + """Test error when .supermanifest cannot be parsed.""" + mock_superproject = self.manifest.superproject + mock_superproject.Sync.return_value = mock.Mock(success=True) + + mock_git = mock.Mock() + mock_git.Wait.return_value = 0 + # Invalid format (not 3 parts) + mock_git.stdout = "invalid_content\n" + mock_git_command.return_value = mock_git + + with self.assertRaises(sync.SyncError) as e: + self.cmd._SyncToSuperprojectRev( + self.opt, self.manifest, self.mp, "name", self.errors + ) + self.assertIn("could not parse .supermanifest", str(e.exception)) + + @mock.patch("subcmds.sync.GitCommand") + def test_read_error(self, mock_git_command): + """Test error when reading .supermanifest fails.""" + mock_superproject = self.manifest.superproject + mock_superproject.Sync.return_value = mock.Mock(success=True) + + mock_git = mock.Mock() + mock_git.Wait.return_value = 1 + mock_git.stderr = "git error" + mock_git_command.return_value = mock_git + + with self.assertRaises(sync.SyncError) as e: + self.cmd._SyncToSuperprojectRev( + self.opt, self.manifest, self.mp, "name", self.errors + ) + self.assertIn("failed to read .supermanifest", str(e.exception)) + + def test_no_superproject(self): + """Test error when superproject is not defined.""" + self.manifest.superproject = None + + with self.assertRaises(sync.SyncError) as e: + self.cmd._SyncToSuperprojectRev( + self.opt, self.manifest, self.mp, "name", self.errors + ) + self.assertIn("superproject not defined", str(e.exception)) + + @mock.patch("subcmds.sync.GitCommand") + def test_sync_failure(self, mock_git_command): + """Test error when superproject sync fails.""" + mock_superproject = self.manifest.superproject + mock_superproject.Sync.return_value = mock.Mock(success=False) + + with self.assertRaises(sync.SyncError) as e: + self.cmd._SyncToSuperprojectRev( + self.opt, self.manifest, self.mp, "name", self.errors + ) + self.assertIn("failed to sync superproject", str(e.exception)) + + +class UpdateAllManifestProjectsTests(unittest.TestCase): + """Tests for Sync._UpdateAllManifestProjects.""" + + def setUp(self): + self.repodir = tempfile.mkdtemp(".repo") + self.manifest = mock.MagicMock(repodir=self.repodir) + self.manifest.superproject = mock.MagicMock() + self.manifest.path_prefix = "" + self.manifest.standalone_manifest_url = None + self.manifest.submanifests = {} + + self.mp = mock.MagicMock() + self.mp.manifest = self.manifest + self.mp.standalone_manifest_url = None + self.cmd = sync.Sync(manifest=self.manifest) + self.cmd.outer_manifest = self.manifest + + self.opt = mock.Mock() + self.opt.verbose = False + self.opt.superproject_revision = None + self.opt.mp_update = True + + self.errors = [] + + def tearDown(self): + shutil.rmtree(self.repodir) + + def test_superproject_revision_outer_manifest(self): + """Test that _SyncToSuperprojectRev is called for outer manifest.""" + self.opt.superproject_revision = "deadbeef" + + with mock.patch.object( + self.cmd, "_SyncToSuperprojectRev" + ) as mock_sync_to_rev: + self.cmd._UpdateAllManifestProjects( + self.opt, self.mp, "name", self.errors + ) + mock_sync_to_rev.assert_called_once_with( + self.opt, self.manifest, self.mp, "name", self.errors + ) + + def test_superproject_revision_submanifest(self): + """Test that _SyncToSuperprojectRev is NOT called for submanifest.""" + self.opt.superproject_revision = "deadbeef" + submanifest = mock.MagicMock() + submanifest.path_prefix = "sub/" + submanifest.standalone_manifest_url = None + self.mp.manifest = submanifest + + with mock.patch.object( + self.cmd, "_SyncToSuperprojectRev" + ) as mock_sync_to_rev: + with mock.patch.object( + self.cmd, "_UpdateManifestProject" + ) as mock_update_manifest: + self.cmd._UpdateAllManifestProjects( + self.opt, self.mp, "name", self.errors + ) + mock_sync_to_rev.assert_not_called() + mock_update_manifest.assert_called_once()