sync: Support manifest-driven default smart sync Add support for enabling smart sync by default via the sync-smartsync="true" attribute on the manifest <default> element. When sync-smartsync is set to true in the manifest defaults: - Plain `repo sync` triggers Smart Sync automatically. - If that implicit Smart Sync fails (manifest server unreachable, or the returned manifest cannot be parsed), repo silently falls back to a regular ToT sync. The reason is logged with --verbose. - `--no-smart-sync` CLI flag overrides the manifest default and syncs to ToT. - Passing explicit `-s` / `--smart-sync` forces Smart Sync as before, and its failures are still fatal. - `-t`, `--smart-tag`, `-m`, `--manifest-name`, and `--superproject-revision` disable the default smart sync. - `-l` / `--local-only` and `--no-manifest-update` skip the manifest server and reuse the existing smart_sync_override.xml, if any. Update docs, completion, regenerate man pages, and add tests for XML parsing, serialization, CLI option resolution, and the fallback and reuse paths. Bug: 545137090 Change-Id: If75569c58dd525d170e9801e70ccbf02206a52f2 Reviewed-on: https://gerrit-review.googlesource.com/c/git-repo/+/617641 Tested-by: Rahul Yadav <yadavrah@google.com> Commit-Queue: Rahul Yadav <yadavrah@google.com> Reviewed-by: Gavin Mak <gavinmak@google.com>
diff --git a/completion.zsh b/completion.zsh index ac82ce7..23b7db1 100644 --- a/completion.zsh +++ b/completion.zsh
@@ -337,6 +337,7 @@ '--auto-gc[Run auto gc]' \ '--no-auto-gc[Do not run auto gc]' \ '(-s --smart-sync)'{-s,--smart-sync}'[Smart sync]' \ + '--no-smart-sync[Do not smart sync]' \ '(-t --smart-tag)'{-t,--smart-tag=}'[Smart tag]:tag:' \ '--no-repo-verify[Do not verify repo]' \ '--no-verify[Do not verify]' \
diff --git a/docs/manifest-format.md b/docs/manifest-format.md index e128a65..18420b8 100644 --- a/docs/manifest-format.md +++ b/docs/manifest-format.md
@@ -46,18 +46,19 @@ <!ATTLIST remote revision CDATA #IMPLIED> <!ELEMENT default EMPTY> - <!ATTLIST default remote IDREF #IMPLIED> - <!ATTLIST default revision CDATA #IMPLIED> - <!ATTLIST default dest-branch CDATA #IMPLIED> - <!ATTLIST default upstream CDATA #IMPLIED> - <!ATTLIST default sync-j CDATA #IMPLIED> - <!ATTLIST default sync-j-max CDATA #IMPLIED> - <!ATTLIST default sync-c CDATA #IMPLIED> - <!ATTLIST default sync-s CDATA #IMPLIED> - <!ATTLIST default sync-tags CDATA #IMPLIED> + <!ATTLIST default remote IDREF #IMPLIED> + <!ATTLIST default revision CDATA #IMPLIED> + <!ATTLIST default dest-branch CDATA #IMPLIED> + <!ATTLIST default upstream CDATA #IMPLIED> + <!ATTLIST default sync-j CDATA #IMPLIED> + <!ATTLIST default sync-j-max CDATA #IMPLIED> + <!ATTLIST default sync-c CDATA #IMPLIED> + <!ATTLIST default sync-s CDATA #IMPLIED> + <!ATTLIST default sync-tags CDATA #IMPLIED> + <!ATTLIST default sync-smartsync CDATA #IMPLIED> <!ELEMENT manifest-server EMPTY> - <!ATTLIST manifest-server url CDATA #REQUIRED> + <!ATTLIST manifest-server url CDATA #REQUIRED> <!ATTLIST manifest-server helper CDATA #IMPLIED> <!ELEMENT submanifest EMPTY> @@ -231,6 +232,8 @@ branch (specified in the `revision` attribute) rather than the other ref tags. +Attribute `sync-smartsync`: Set to true to enable smart sync by default. + ### Element manifest-server
diff --git a/docs/smart-sync.md b/docs/smart-sync.md index 9f65945..99270a5 100644 --- a/docs/smart-sync.md +++ b/docs/smart-sync.md
@@ -138,9 +138,36 @@ The next time `repo sync` is run, this file is automatically replaced or removed based on the current set of options. -### --smart-sync +### sync-smartsync (Manifest Attribute) -Repo will call `GetApprovedManifest(branch[, target])`. +The manifest can enable Smart Sync by default for all `repo sync` invocations +by setting `sync-smartsync="true"` on the `<default>` element: + +```xml + <default sync-smartsync="true" ... /> +``` + +When enabled in the manifest, `repo sync` will automatically perform a Smart +Sync unless explicitly overridden on the command line. Since the user did not +ask for a Smart Sync, any failure to obtain a manifest from the manifest server +(e.g. the server is unreachable, or it returns a manifest that cannot be parsed) +is silently ignored and `repo sync` falls back to a regular ToT sync; pass +`--verbose` to see the reason. This is only a fallback for the manifest-driven +default: an explicit `-s`/`--smart-sync` or `-t`/`--smart-tag` still fails the +sync, as before. Options that specify an explicit target or manifest source +(such as `-t`/`--smart-tag`, `-m`/`--manifest-name` or +`--superproject-revision`) will also disable the default Smart Sync behavior. + +With `-l`/`--local-only` or `--no-manifest-update`, the default Smart Sync does +not contact the manifest server. Instead, repo keeps using the +`smart_sync_override.xml` from the last Smart Sync, if there is one. Pass +`--no-smart-sync` to discard it and use the default manifest instead. These +options do not change the behavior of an explicit `-s`/`--smart-sync`. + +### --smart-sync / -s + +Explicitly enables Smart Sync. Repo will call +`GetApprovedManifest(branch[, target])`. The `branch` is determined by the current manifest branch as specified by `--manifest-branch=BRANCH` when running `repo init`. @@ -155,6 +182,11 @@ 3. `${TARGET_PRODUCT}-${TARGET_BUILD_VARIANT}`: If these variables are all defined, then they are merged with `-` and used. -### --smart-tag=TAG +### --no-smart-sync + +Explicitly disables Smart Sync, overriding any `sync-smartsync="true"` setting +declared in the manifest `<default>` element. + +### --smart-tag=TAG / -t Repo will call `GetManifest(TAG)`.
diff --git a/man/repo-manifest.1 b/man/repo-manifest.1 index 903be25..49923b9 100644 --- a/man/repo-manifest.1 +++ b/man/repo-manifest.1
@@ -1,5 +1,5 @@ .\" DO NOT MODIFY THIS FILE! It was generated by help2man. -.TH REPO "1" "June 2026" "repo manifest" "Repo Manual" +.TH REPO "1" "August 2026" "repo manifest" "Repo Manual" .SH NAME repo \- repo manifest - manual page for repo manifest .SH SYNOPSIS @@ -121,18 +121,19 @@ <!ATTLIST remote revision CDATA #IMPLIED> .IP <!ELEMENT default EMPTY> -<!ATTLIST default remote IDREF #IMPLIED> -<!ATTLIST default revision CDATA #IMPLIED> -<!ATTLIST default dest\-branch CDATA #IMPLIED> -<!ATTLIST default upstream CDATA #IMPLIED> -<!ATTLIST default sync\-j CDATA #IMPLIED> -<!ATTLIST default sync\-j\-max CDATA #IMPLIED> -<!ATTLIST default sync\-c CDATA #IMPLIED> -<!ATTLIST default sync\-s CDATA #IMPLIED> -<!ATTLIST default sync\-tags CDATA #IMPLIED> +<!ATTLIST default remote IDREF #IMPLIED> +<!ATTLIST default revision CDATA #IMPLIED> +<!ATTLIST default dest\-branch CDATA #IMPLIED> +<!ATTLIST default upstream CDATA #IMPLIED> +<!ATTLIST default sync\-j CDATA #IMPLIED> +<!ATTLIST default sync\-j\-max CDATA #IMPLIED> +<!ATTLIST default sync\-c CDATA #IMPLIED> +<!ATTLIST default sync\-s CDATA #IMPLIED> +<!ATTLIST default sync\-tags CDATA #IMPLIED> +<!ATTLIST default sync\-smartsync CDATA #IMPLIED> .IP <!ELEMENT manifest\-server EMPTY> -<!ATTLIST manifest\-server url CDATA #REQUIRED> +<!ATTLIST manifest\-server url CDATA #REQUIRED> <!ATTLIST manifest\-server helper CDATA #IMPLIED> .IP <!ELEMENT submanifest EMPTY> @@ -336,6 +337,8 @@ Attribute `sync\-tags`: Set to false to only sync the given Git branch (specified in the `revision` attribute) rather than the other ref tags. .PP +Attribute `sync\-smartsync`: Set to true to enable smart sync by default. +.PP Element manifest\-server .PP At most one manifest\-server may be specified. The url attribute is used to
diff --git a/man/repo-sync.1 b/man/repo-sync.1 index 2767ac3..b033a90 100644 --- a/man/repo-sync.1 +++ b/man/repo-sync.1
@@ -125,6 +125,9 @@ \fB\-s\fR, \fB\-\-smart\-sync\fR smart sync using manifest from the latest known good build .TP +\fB\-\-no\-smart\-sync\fR +disable smart sync and sync to ToT instead +.TP \fB\-t\fR SMART_TAG, \fB\-\-smart\-tag\fR=\fI\,SMART_TAG\/\fR smart sync using manifest from a known tag .SS Logging options:
diff --git a/manifest_xml.py b/manifest_xml.py index 0e80435..c3d6fe0 100644 --- a/manifest_xml.py +++ b/manifest_xml.py
@@ -159,6 +159,7 @@ sync_c = False sync_s = False sync_tags = True + sync_smartsync = False def __eq__(self, other): if not isinstance(other, _Default): @@ -483,6 +484,17 @@ self.Unload() self._Load() + def ClearOverride(self) -> None: + """Stop overriding the manifest, reverting to the default one. + + This is the inverse of Override(), and is a no-op if no override is in + effect. It is useful to recover from a failed Override(), which + registers the override before parsing it. + """ + self._outer_client.manifestFileOverrides.pop(self.path_prefix, None) + self._load_local_manifests = True + self.Unload() + def Link(self, name): """Update the repo metadata to use a different manifest.""" self.Override(name) @@ -644,6 +656,9 @@ if not d.sync_tags: have_default = True e.setAttribute("sync-tags", "false") + if d.sync_smartsync: + have_default = True + e.setAttribute("sync-smartsync", "true") if have_default: root.appendChild(e) root.appendChild(doc.createTextNode("")) @@ -1789,6 +1804,7 @@ d.sync_c = XmlBool(node, "sync-c", False) d.sync_s = XmlBool(node, "sync-s", False) d.sync_tags = XmlBool(node, "sync-tags", True) + d.sync_smartsync = XmlBool(node, "sync-smartsync", False) return d def _ParseNotice(self, node):
diff --git a/subcmds/sync.py b/subcmds/sync.py index 0739c82..96fb485 100644 --- a/subcmds/sync.py +++ b/subcmds/sync.py
@@ -58,6 +58,7 @@ from command import MirrorSafeCommand from command import WORKER_BATCH_SIZE from error import GitError +from error import ManifestParseError from error import RepoChangedException from error import RepoError from error import RepoExitError @@ -764,10 +765,17 @@ "-s", "--smart-sync", action="store_true", + default=None, help="smart sync using manifest from the latest known good " "build", ) p.add_option( + "--no-smart-sync", + dest="smart_sync", + action="store_false", + help="disable smart sync and sync to ToT instead", + ) + p.add_option( "-t", "--smart-tag", action="store", @@ -2249,10 +2257,6 @@ if opt.manifest_name and opt.smart_tag: self.OptionParser.error("cannot combine -m and -t") if opt.manifest_server_username or opt.manifest_server_password: - if not (opt.smart_sync or opt.smart_tag): - self.OptionParser.error( - "-u and -p may only be combined with -s or -t" - ) if None in [ opt.manifest_server_username, opt.manifest_server_password, @@ -2413,6 +2417,34 @@ "failed to sync manifest project", aggregate_errors=[e] ) + def _ResolveSmartSyncOption( + self, opt: optparse.Values, manifest: XmlManifest + ) -> bool: + """Resolve opt.smart_sync from the CLI flags and manifest default. + + Returns: + True if smart_sync was enabled implicitly by the manifest default, + False otherwise. + """ + implicit = ( + opt.smart_sync is None + and not opt.smart_tag + and not opt.manifest_name + and not opt.superproject_revision + and not opt.local_only + and opt.mp_update + and getattr(manifest.default, "sync_smartsync", False) is True + ) + if opt.smart_sync is None: + opt.smart_sync = implicit + + if ( + opt.manifest_server_username or opt.manifest_server_password + ) and not (opt.smart_sync or opt.smart_tag): + raise SmartSyncError("-u and -p may only be combined with -s or -t") + + return implicit + def _ExecuteHelper(self, opt, args, errors): manifest = self.outer_manifest if not opt.outer_manifest: @@ -2427,11 +2459,58 @@ if opt.clone_bundle is None: opt.clone_bundle = manifest.CloneBundle + # An onboarded client run with -l or --no-manifest-update keeps the + # smart sync manifest it last synced to instead of contacting the + # manifest server. Computed before _ResolveSmartSyncOption, which + # makes an unset opt.smart_sync indistinguishable from --no-smart-sync. + reuse_smart_sync_override = ( + (opt.local_only or not opt.mp_update) + and opt.smart_sync is None + and not opt.smart_tag + and not opt.manifest_name + and not opt.superproject_revision + and getattr(manifest.default, "sync_smartsync", False) is True + and os.path.isfile(smart_sync_manifest_path) + ) + + implicit_smart_sync = self._ResolveSmartSyncOption(opt, manifest) + if opt.smart_sync or opt.smart_tag: - manifest_name = self._SmartSyncSetup( - opt, smart_sync_manifest_path, manifest - ) - else: + try: + manifest_name = self._SmartSyncSetup( + opt, smart_sync_manifest_path, manifest + ) + except (SmartSyncError, ManifestParseError) as e: + if not implicit_smart_sync: + raise + # The user did not ask for a smart sync, so failing to get a + # usable manifest from the server should not fail the sync. + # Fall back to a regular ToT sync instead. + opt.smart_sync = False + # Override() records the override before parsing it, so an + # unparsable manifest would otherwise leave the client pinned + # to a file it cannot load. + manifest.ClearOverride() + if opt.verbose: + logger.warning( + "warning: smart sync failed; falling back to ToT: %s", e + ) + elif reuse_smart_sync_override: + manifest_name = os.path.basename(smart_sync_manifest_path) + try: + self._ReloadManifest(manifest_name, manifest) + except ManifestParseError as e: + reuse_smart_sync_override = False + manifest_name = None + manifest.ClearOverride() + if opt.verbose: + logger.warning( + "warning: failed to load existing smart sync manifest; " + "falling back to ToT: %s", + e, + ) + + if not (opt.smart_sync or opt.smart_tag or reuse_smart_sync_override): if os.path.isfile(smart_sync_manifest_path): try: platform_utils.remove(smart_sync_manifest_path)
diff --git a/tests/test_manifest_xml.py b/tests/test_manifest_xml.py index 3f3f50a..a04972a 100644 --- a/tests/test_manifest_xml.py +++ b/tests/test_manifest_xml.py
@@ -420,6 +420,62 @@ ) manifest.ToXml() + def test_sync_smartsync(self, repo_client: RepoClient) -> None: + """Check sync-smartsync handling.""" + manifest = repo_client.get_xml_manifest( + "<manifest><default /></manifest>" + ) + assert manifest.default.sync_smartsync is False + + manifest = repo_client.get_xml_manifest( + '<manifest><default sync-smartsync="true" /></manifest>' + ) + assert manifest.default.sync_smartsync is True + assert ( + manifest.ToXml().toxml() == '<?xml version="1.0" ?>' + '<manifest><default sync-smartsync="true"/></manifest>' + ) + + manifest = repo_client.get_xml_manifest( + '<manifest><default sync-smartsync="false" /></manifest>' + ) + assert manifest.default.sync_smartsync is False + assert manifest.ToXml().toxml() == '<?xml version="1.0" ?><manifest/>' + + def test_clear_override(self, repo_client: RepoClient) -> None: + """Check ClearOverride reverts to the default manifest.""" + manifest_xml_fmt = ( + '<manifest><remote name="r" fetch="." />' + '<default remote="r" revision="main" />' + '<project name="%s" /></manifest>' + ) + manifest = repo_client.get_xml_manifest(manifest_xml_fmt % "default") + (repo_client.manifest_dir / "good.xml").write_text( + manifest_xml_fmt % "override", encoding="utf-8" + ) + (repo_client.manifest_dir / "bad.xml").write_text( + "<manifest>", encoding="utf-8" + ) + + # No override in effect: a no-op. + manifest.ClearOverride() + assert list(manifest.paths) == ["default"] + + manifest.Override("good.xml") + assert list(manifest.paths) == ["override"] + manifest.ClearOverride() + assert not manifest.manifestFileOverrides + assert list(manifest.paths) == ["default"] + + # Override() registers the override before parsing it, so a failed + # Override() leaves the client pinned until ClearOverride(). + with pytest.raises(error.ManifestParseError): + manifest.Override("bad.xml") + with pytest.raises(error.ManifestParseError): + manifest.Load() + manifest.ClearOverride() + assert list(manifest.paths) == ["default"] + class TestIncludeElement: """Tests for <include>."""
diff --git a/tests/test_subcmds_sync.py b/tests/test_subcmds_sync.py index efd9ffe..9d079bb 100644 --- a/tests/test_subcmds_sync.py +++ b/tests/test_subcmds_sync.py
@@ -21,7 +21,7 @@ import shutil import tempfile import time -from typing import Dict, List, Optional, Tuple +from typing import Dict, List, Optional, Tuple, Type import unittest from unittest import mock @@ -29,6 +29,7 @@ import command from error import GitError +from error import ManifestParseError from error import RepoExitError import manifest_xml from project import SyncNetworkHalfResult @@ -390,6 +391,90 @@ assert opts.jobs_checkout == jobs_check +@pytest.mark.parametrize( + "argv, sync_smartsync_manifest, expected_smart_sync, expected_implicit", + [ + ([], False, False, False), + ([], None, False, False), + ([], True, True, True), + (["-s"], False, True, False), + (["--smart-sync"], False, True, False), + (["--smart-sync"], True, True, False), + (["--no-smart-sync"], True, False, False), + (["--no-smart-sync"], False, False, False), + (["-t", "tag123"], True, False, False), + (["--smart-tag=tag123"], True, False, False), + (["-m", "other.xml"], True, False, False), + (["--manifest-name=other.xml"], True, False, False), + (["-l"], True, False, False), + (["--local-only"], True, False, False), + (["--nmu"], True, False, False), + (["--no-manifest-update"], True, False, False), + (["--superproject-revision=abc"], True, False, False), + (["-s", "-l"], True, True, False), + (["-s", "--nmu"], True, True, False), + (["-s", "--superproject-revision=abc"], True, True, False), + ], +) +def test_cli_smart_sync( + argv: List[str], + sync_smartsync_manifest: Optional[bool], + expected_smart_sync: bool, + expected_implicit: bool, +) -> None: + """Tests --smart-sync and --no-smart-sync option behavior with manifest + default. + """ + manifest = mock.MagicMock() + manifest.default.sync_smartsync = sync_smartsync_manifest + + cmd = sync.Sync(manifest=manifest) + opts, args = cmd.OptionParser.parse_args(argv) + cmd.ValidateOptions(opts, args) + implicit = cmd._ResolveSmartSyncOption(opts, manifest) + assert opts.smart_sync == expected_smart_sync + assert implicit == expected_implicit + + +@pytest.mark.parametrize( + "argv, sync_smartsync_manifest, expected_exception", + [ + (["-u", "user", "-p", "pass"], False, sync.SmartSyncError), + (["-u", "user", "-p", "pass"], True, None), + (["-s", "-u", "user", "-p", "pass"], False, None), + (["-t", "tag", "-u", "user", "-p", "pass"], False, None), + ( + ["--no-smart-sync", "-u", "user", "-p", "pass"], + True, + sync.SmartSyncError, + ), + (["-l", "-u", "user", "-p", "pass"], True, sync.SmartSyncError), + (["--nmu", "-u", "user", "-p", "pass"], True, sync.SmartSyncError), + (["-s", "-l", "-u", "user", "-p", "pass"], True, None), + (["-u", "user"], False, SystemExit), + (["-p", "pass"], False, SystemExit), + ], +) +def test_cli_manifest_server_credentials( + argv: List[str], + sync_smartsync_manifest: bool, + expected_exception: Optional[Type[BaseException]], +) -> None: + """Tests -u and -p validation rules.""" + manifest = mock.MagicMock() + manifest.default.sync_smartsync = sync_smartsync_manifest + + cmd = sync.Sync(manifest=manifest) + opts, args = cmd.OptionParser.parse_args(argv) + if expected_exception: + with pytest.raises(expected_exception): + cmd.ValidateOptions(opts, args) + cmd._ResolveSmartSyncOption(opts, manifest) + else: + cmd.ValidateOptions(opts, args) + cmd._ResolveSmartSyncOption(opts, manifest) + + class LocalSyncState(unittest.TestCase): """Tests for LocalSyncState.""" @@ -1484,6 +1569,193 @@ phased, interleaved = self._ExecuteUntilSync([]) self.assertTrue(phased.called or interleaved.called) + def test_implicit_smart_sync_fallback(self) -> None: + """Ensure implicit smart sync silently falls back to ToT.""" + self.manifest.default.sync_smartsync = True + self.opt.smart_sync = None + mock.patch.object(self.cmd, "_UpdateAllManifestProjects").start() + self.opt.verbose = False + with mock.patch.object( + self.cmd, + "_SmartSyncSetup", + side_effect=sync.SmartSyncError("unreachable"), + ) as mock_setup: + 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"): + with mock.patch.object( + sync.logger, "warning" + ) as mock_warn: + self.cmd.Execute(self.opt, []) + mock_setup.assert_called_once() + self.assertFalse(self.opt.smart_sync) + self.assertFalse(mock_warn.called) + + def test_implicit_smart_sync_fallback_verbose(self) -> None: + """Ensure the fallback reason is logged when --verbose is given.""" + self.manifest.default.sync_smartsync = True + self.opt.smart_sync = None + mock.patch.object(self.cmd, "_UpdateAllManifestProjects").start() + self.opt.verbose = True + with mock.patch.object( + self.cmd, + "_SmartSyncSetup", + side_effect=sync.SmartSyncError("unreachable"), + ): + 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"): + with mock.patch.object( + sync.logger, "warning" + ) as mock_warn: + self.cmd.Execute(self.opt, []) + self.assertFalse(self.opt.smart_sync) + self.assertTrue(mock_warn.called) + + def test_explicit_smart_sync_error(self) -> None: + """Ensure explicit smart sync raises SmartSyncError on failure.""" + self.manifest.default.sync_smartsync = True + self.opt.smart_sync = True + self.opt.mp_update = False + with mock.patch.object( + self.cmd, + "_SmartSyncSetup", + side_effect=sync.SmartSyncError("unreachable"), + ): + with self.assertRaises(sync.SmartSyncError): + self.cmd.Execute(self.opt, []) + + def test_implicit_smart_sync_unparsable_manifest(self) -> None: + """Ensure an unparsable smart sync manifest falls back to ToT.""" + self.manifest.default.sync_smartsync = True + self.opt.smart_sync = None + mock.patch.object(self.cmd, "_UpdateAllManifestProjects").start() + self.opt.verbose = False + with mock.patch.object( + self.cmd, + "_SmartSyncSetup", + side_effect=ManifestParseError("mismatched tag"), + ): + 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.assertFalse(self.opt.smart_sync) + # A half-applied override would pin the client to a broken manifest. + self.manifest.ClearOverride.assert_called_once_with() + + def test_explicit_smart_sync_unparsable_manifest(self) -> None: + """Ensure explicit smart sync still reports the parse error.""" + self.manifest.default.sync_smartsync = True + self.opt.smart_sync = True + self.opt.mp_update = False + with mock.patch.object( + self.cmd, + "_SmartSyncSetup", + side_effect=ManifestParseError("mismatched tag"), + ): + with self.assertRaises(ManifestParseError): + self.cmd.Execute(self.opt, []) + + def _ExecuteWithSmartSyncOverride( + self, reload_error: Optional[Exception] = None + ) -> Tuple[str, mock.MagicMock, mock.MagicMock, mock.MagicMock]: + """Run Execute with an existing smart sync override on disk. + + Returns: + The override path, and the _SmartSyncSetup, _ReloadManifest and + _UpdateAllManifestProjects mocks. + """ + worktree = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, worktree) + self.manifest.manifestProject.worktree = worktree + # -l reloads the superproject manifest; keep that out of the way. + self.manifest.superproject = None + self.opt.verbose = False + override_path = os.path.join(worktree, "smart_sync_override.xml") + with open(override_path, "w") as f: + f.write("<manifest />") + + with contextlib.ExitStack() as stack: + for name in ( + "_UpdateRepoProject", + "_ValidateOptionsWithManifest", + "_SyncInterleaved", + "_RunPostSyncHook", + ): + stack.enter_context(mock.patch.object(self.cmd, name)) + setup = stack.enter_context( + mock.patch.object(self.cmd, "_SmartSyncSetup") + ) + reload = stack.enter_context( + mock.patch.object( + self.cmd, "_ReloadManifest", side_effect=reload_error + ) + ) + update_mps = stack.enter_context( + mock.patch.object(self.cmd, "_UpdateAllManifestProjects") + ) + self.cmd.Execute(self.opt, []) + return override_path, setup, reload, update_mps + + def test_smart_sync_override_reused_offline(self) -> None: + """Ensure -l/--nmu reuse an onboarded client's smart sync manifest.""" + for local_only, mp_update in ((True, True), (False, False)): + with self.subTest(local_only=local_only, mp_update=mp_update): + self.manifest.default.sync_smartsync = True + self.opt.smart_sync = None + self.opt.local_only = local_only + self.opt.mp_update = mp_update + path, setup, reload, update_mps = ( + self._ExecuteWithSmartSyncOverride() + ) + setup.assert_not_called() + reload.assert_called_once_with( + "smart_sync_override.xml", self.manifest + ) + self.assertTrue(os.path.isfile(path)) + if mp_update: + self.assertEqual( + update_mps.call_args[0][2], "smart_sync_override.xml" + ) + + def test_smart_sync_override_removed_offline(self) -> None: + """Ensure -l still drops the override when reuse does not apply.""" + cases = ( + ("--no-smart-sync", False, True), + ("manifest without sync-smartsync", None, False), + ) + for name, smart_sync, sync_smartsync in cases: + with self.subTest(name): + self.manifest.default.sync_smartsync = sync_smartsync + self.opt.smart_sync = smart_sync + self.opt.local_only = True + path, setup, reload, _ = self._ExecuteWithSmartSyncOverride() + setup.assert_not_called() + reload.assert_not_called() + self.assertFalse(os.path.isfile(path)) + + def test_smart_sync_override_unparsable_offline(self) -> None: + """Ensure an unloadable reused override falls back to ToT.""" + self.manifest.default.sync_smartsync = True + self.opt.smart_sync = None + self.opt.local_only = True + path, _, _, update_mps = self._ExecuteWithSmartSyncOverride( + reload_error=ManifestParseError("mismatched tag") + ) + self.manifest.ClearOverride.assert_called_once_with() + self.assertFalse(os.path.isfile(path)) + self.assertIsNone(update_mps.call_args[0][2]) + class SyncUpdateRepoProject(unittest.TestCase): """Tests for Sync._UpdateRepoProject.""" @@ -2501,6 +2773,35 @@ self.assertEqual(manifest_name, "manifest.xml") + @mock.patch("xmlrpc.client.Server") + def test_smart_sync_setup_unparsable_manifest( + self, mock_server_class: mock.MagicMock + ) -> None: + """Test _SmartSyncSetup when the server returns a bad manifest. + + The parse error is left alone so that an explicit smart sync keeps + reporting it as-is; recovering from it is up to the caller. + """ + self.manifest.manifest_server = ( + "http://android-smartsync.corp.google.com/manifestserver" + ) + self.manifest.manifest_server_helper = None + + mock_server = mock.MagicMock() + mock_server.GetApprovedManifest.return_value = [True, "<manifest>"] + mock_server_class.return_value = mock_server + + self.cmd._GetBranch = mock.MagicMock(return_value="main") + self.cmd._ReloadManifest = mock.MagicMock( + side_effect=ManifestParseError("mismatched tag") + ) + + with mock.patch("builtins.open", mock.mock_open()): + with self.assertRaises(ManifestParseError): + self.cmd._SmartSyncSetup( + self.opt, self.smart_sync_manifest_path, self.manifest + ) + @mock.patch("shutil.which") @mock.patch("subprocess.Popen") def test_smart_sync_setup_helper_error(self, mock_popen, mock_which):