From 46e9866ca0f161604bd45a0c3b7c7887c9415dcb Mon Sep 17 00:00:00 2001 From: Insaf Latypov Date: Tue, 13 Oct 2026 15:50:11 +0000 Subject: [PATCH] sync: Provide a CLI command to opt in/out of smartsync for a user. Add a boolean `sync.smartsync` setting in the per-user repo config (~/.repoconfig/config): * true: smart sync by default, as if -s were passed. * false: never smart sync by default, even if the manifest enables it. * unset: follow the manifest default, as before. Add docs/repoconfig.md as the user facing documentation for ~/.repoconfig/config and its settings, and point smart-sync.md and the `repo sync` help at it. Bug: 569927750 Test: ./run_tests Change-Id: Ideb4119fcb5e90f8bb35c493d9e4f8bec7878b77 Reviewed-on: https://gerrit-review.googlesource.com/c/git-repo/+/643264 Reviewed-by: Gavin Mak Commit-Queue: Insaf Latypov Tested-by: Insaf Latypov --- docs/internal-fs-layout.md | 1 + docs/repoconfig.md | 103 +++++++++++++++++++++++ docs/smart-sync.md | 16 ++++ man/repo-sync.1 | 6 +- subcmds/sync.py | 27 +++++- tests/test_subcmds_sync.py | 167 +++++++++++++++++++++++++++++++++---- 6 files changed, 299 insertions(+), 21 deletions(-) create mode 100644 docs/repoconfig.md diff --git a/docs/internal-fs-layout.md b/docs/internal-fs-layout.md index ea0070bfb..64fe74cbe 100644 --- a/docs/internal-fs-layout.md +++ b/docs/internal-fs-layout.md @@ -253,6 +253,7 @@ setting `REPO_CONFIG_DIR`. * `.repoconfig/`: Repo's per-user directory for all random config files/state. * `.repoconfig/config`: Per-user settings using [git-config] file format. + See [repoconfig.md](./repoconfig.md) for the available settings. * `.repoconfig/keyring-version`: Cache file for checking if the gnupg subdir has all the same keys as the repo launcher. Used to avoid running gpg constantly as that can be quite slow. diff --git a/docs/repoconfig.md b/docs/repoconfig.md new file mode 100644 index 000000000..aef92fdc6 --- /dev/null +++ b/docs/repoconfig.md @@ -0,0 +1,103 @@ +# repoconfig: User Configuration + +Repo keeps a small number of settings that apply to the current user rather +than to one checkout. They live in the per-user configuration file +`~/.repoconfig/config`, which is separate from both git's own `~/.gitconfig` +and the per-checkout settings stored in `.repo/manifests.git/config` (see +[internal-fs-layout.md](./internal-fs-layout.md#Global-settings) for those). + +[TOC] + +## Location + +The file is `.repoconfig/config` inside the user's home directory. The parent +directory can be changed by setting the `REPO_CONFIG_DIR` environment variable, +in which case repo reads `$REPO_CONFIG_DIR/.repoconfig/config` instead. + +The file is created on demand; it's fine for it to be missing. + +## Format + +The file uses the [git-config] syntax, so it can be edited by hand: + +```ini +[sync] + smartsync = true +``` + +or managed with `git config` by pointing it at the file explicitly: + +```sh +# Set a value. +git config --file ~/.repoconfig/config sync.smartsync true + +# Read a value. +git config --file ~/.repoconfig/config sync.smartsync + +# Remove a value. +git config --file ~/.repoconfig/config --unset sync.smartsync +``` + +Boolean settings accept `true`/`yes` and `false`/`no`. + +Repo caches a parsed copy of the file in `.repoconfig/.repo_config.json`; it is +refreshed automatically whenever `config` changes and can be safely deleted. + +## Settings + +### alias.* + +`alias.` defines `repo ` as a shortcut for another subcommand, +optionally with arguments, in the same way as git aliases: + +```ini +[alias] + ss = sync -s + st = status -o +``` + +Aliases defined in a checkout's `.repo/config` take precedence over per-user +ones. Existing subcommands cannot be overridden by an alias. + +### sync + +#### smartsync + +`sync.smartsync` is a boolean that lets a user decide once, for all their +checkouts, whether `repo sync` should [Smart Sync](./smart-sync.md) by +default: + +```sh +# Always Smart Sync, as if -s/--smart-sync were passed. +git config --file ~/.repoconfig/config sync.smartsync true + +# Never Smart Sync by default, even if the manifest sets sync-smartsync="true". +git config --file ~/.repoconfig/config sync.smartsync false + +# Remove the preference and go back to following the manifest. +git config --file ~/.repoconfig/config --unset sync.smartsync +``` + +The effective behavior is resolved in this order; the first match wins: + +1. Command line: `-s`/`--smart-sync`, `--no-smart-sync`, or `-t`/`--smart-tag`. +2. User config: `sync.smartsync` in `~/.repoconfig/config`. +3. Manifest: `sync-smartsync="true"` on the `` element. +4. Otherwise, a regular ToT sync. + +So `sync.smartsync=false` turns off the manifest-driven default, but an +explicit `repo sync -s`, `repo sync -t TAG` or `repo smartsync` still works. +Likewise `sync.smartsync=true` is overridden by `--no-smart-sync`. + +A Smart Sync enabled by `sync.smartsync=true` is treated as an explicit +request: it is subject to the same restrictions as `-s` (it is skipped when +`-l`/`--local-only`, `-m`/`--manifest-name`, `-t`/`--smart-tag` or +`--superproject-revision` is used), and a failure to obtain a manifest from +the server fails the sync instead of silently falling back to ToT. + +`sync.smartsync` does not affect `-l`/`--local-only`: a local-only sync keeps +whatever `smart_sync_override.xml` the previous network sync left behind. See +[smart-sync.md](./smart-sync.md#Client-Options) for details. + + +[git-config]: https://git-scm.com/docs/git-config diff --git a/docs/smart-sync.md b/docs/smart-sync.md index 24f37461b..905a004a0 100644 --- a/docs/smart-sync.md +++ b/docs/smart-sync.md @@ -204,6 +204,10 @@ 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. +Users can override this manifest default on their workstation with the +`sync.smartsync` user setting; see +[Per-user default](#Per_user-default-sync_smartsync) below. + ### --smart-sync / -s Explicitly enables Smart Sync. Repo will call @@ -231,6 +235,18 @@ match the settings Android build environments automatically setup. Explicitly disables Smart Sync, overriding any `sync-smartsync="true"` setting declared in the manifest `` element. +### Per-user default (sync.smartsync) + +A user can decide once, for all their checkouts, whether `repo sync` should +Smart Sync by default by setting the boolean `sync.smartsync` key in their +[per-user repo config](./repoconfig.md#smartsync). + +Command line flags (`-s`, `--no-smart-sync`, `-t`) override the user setting, +which in turn overrides the manifest's `sync-smartsync` default. A Smart Sync +enabled this way is treated as an explicit request, so it is subject to the +same restrictions as `-s` and fails the sync if no manifest can be obtained +from the server instead of silently falling back to ToT. + ### --smart-tag=TAG / -t Repo will call `GetManifest(TAG)`. diff --git a/man/repo-sync.1 b/man/repo-sync.1 index b033a906f..2c7467e1f 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" "September 2026" "repo sync" "Repo Manual" +.TH REPO "1" "October 2026" "repo sync" "Repo Manual" .SH NAME repo \- repo sync - manual page for repo sync .SH SYNOPSIS @@ -188,6 +188,10 @@ The \fB\-s\fR/\-\-smart\-sync option can be used to sync to a known good build a specified by the manifest\-server element in the current manifest. The \fB\-t\fR/\-\-smart\-tag option is similar and allows you to specify a custom tag/label. .PP +Whether 'repo sync' smart syncs by default can also be controlled with the +sync.smartsync per\-user setting; see docs/repoconfig.md. Explicit +\fB\-s\fR/\-\-no\-smart\-sync/\-t flags always override it. +.PP The \fB\-u\fR/\-\-manifest\-server\-username and \fB\-p\fR/\-\-manifest\-server\-password options can be used to specify a username and password to authenticate with the manifest server when using the \fB\-s\fR or \fB\-t\fR option. diff --git a/subcmds/sync.py b/subcmds/sync.py index e83f42345..d6354d4a0 100644 --- a/subcmds/sync.py +++ b/subcmds/sync.py @@ -69,6 +69,7 @@ import event_log from git_command import git_require from git_command import GitCommand from git_config import GetUrlCookieFile +from git_config import RepoConfig from git_refs import HEAD from git_refs import R_HEADS import git_superproject @@ -491,6 +492,10 @@ build as specified by the manifest-server element in the current manifest. The -t/--smart-tag option is similar and allows you to specify a custom tag/label. +Whether '%prog' smart syncs by default can also be controlled with the +sync.smartsync per-user setting; see docs/repoconfig.md. Explicit +-s/--no-smart-sync/-t flags always override it. + The -u/--manifest-server-username and -p/--manifest-server-password options can be used to specify a username and password to authenticate with the manifest server when using the -s or -t option. @@ -2522,22 +2527,36 @@ later is required to fix a server side protocol bug. def _ResolveSmartSyncOption( self, opt: optparse.Values, manifest: XmlManifest ) -> bool: - """Resolve opt.smart_sync from the CLI flags and manifest default. + """Resolve opt.smart_sync from CLI flags, user config and manifest. + + Precedence: CLI flags > per-user settings > per-manifest settings. + See docs/repoconfig.md and docs/smart-sync.md for details. Returns: True if smart_sync was enabled implicitly by the manifest default, - False otherwise. + False otherwise. Smart sync enabled via per-user settings counts + as an explicit user request, not implicit. """ - implicit = ( + user_pref = RepoConfig.ForUser().GetBoolean("sync.smartsync") + default_allowed = ( 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 + ) + implicit = ( + default_allowed + and user_pref is None and getattr(manifest.default, "sync_smartsync", False) is True ) if opt.smart_sync is None: - opt.smart_sync = implicit + if user_pref is False: + opt.smart_sync = False + elif user_pref is True: + opt.smart_sync = default_allowed + else: + opt.smart_sync = implicit if ( opt.manifest_server_username or opt.manifest_server_password diff --git a/tests/test_subcmds_sync.py b/tests/test_subcmds_sync.py index e7e8013e0..32ff3cdd1 100644 --- a/tests/test_subcmds_sync.py +++ b/tests/test_subcmds_sync.py @@ -501,27 +501,116 @@ def test_cli_smart_sync( @pytest.mark.parametrize( - "argv, sync_smartsync_manifest, expected_exception", + "argv, user_pref, sync_smartsync_manifest, expected_smart_sync, " + "expected_implicit", [ - (["-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), + # sync.smartsync=false: never smart sync unless -s/-t is given. + ([], False, True, False, False), + ([], False, False, False, False), + (["-s"], False, True, True, False), + (["--smart-sync"], False, False, True, False), + (["-t", "tag123"], False, True, False, False), + (["--nmu"], False, True, False, False), + # sync.smartsync=true: like -s, counted as explicit, not implicit. + ([], True, False, True, False), + ([], True, True, True, False), + (["--nmu"], True, False, True, False), + (["--no-smart-sync"], True, True, False, False), + (["-t", "tag123"], True, False, False, False), + (["-m", "other.xml"], True, False, False, False), + (["-l"], True, False, False, False), + (["--superproject-revision=abc"], True, False, False, False), + # sync.smartsync unset: manifest default applies as before. + ([], None, True, True, True), + ([], None, False, False, False), + ], +) +def test_user_config_smart_sync( + argv: List[str], + user_pref: Optional[bool], + sync_smartsync_manifest: bool, + expected_smart_sync: bool, + expected_implicit: bool, +) -> None: + """Tests the sync.smartsync user setting against CLI and manifest.""" + manifest = mock.MagicMock() + manifest.default.sync_smartsync = sync_smartsync_manifest + + user_cfg = mock.MagicMock() + user_cfg.GetBoolean.return_value = user_pref + + cmd = sync.Sync(manifest=manifest) + opts, args = cmd.OptionParser.parse_args(argv) + cmd.ValidateOptions(opts, args) + with mock.patch.object(sync.RepoConfig, "ForUser", return_value=user_cfg): + implicit = cmd._ResolveSmartSyncOption(opts, manifest) + user_cfg.GetBoolean.assert_called_once_with("sync.smartsync") + assert opts.smart_sync == expected_smart_sync + assert implicit == expected_implicit + + +@pytest.mark.parametrize( + "value, expected_smart_sync", + [("true", True), ("false", False), ("yes", True), ("no", False)], +) +def test_user_config_smart_sync_from_file( + monkeypatch: pytest.MonkeyPatch, + tmp_home_dir: Path, + value: str, + expected_smart_sync: bool, +) -> None: + """Tests that sync.smartsync is read from ~/.repoconfig/config.""" + # ForUser() caches its instance process-wide; make it pick up this HOME. + monkeypatch.setattr(sync.RepoConfig, "_ForUser", None) + monkeypatch.delenv("REPO_CONFIG_DIR", raising=False) + cfg_dir = tmp_home_dir / ".repoconfig" + cfg_dir.mkdir() + (cfg_dir / "config").write_text(f"[sync]\n\tsmartsync = {value}\n") + + manifest = mock.MagicMock() + # Set the manifest default to the opposite so we know the user config won. + manifest.default.sync_smartsync = not expected_smart_sync + + cmd = sync.Sync(manifest=manifest) + opts, args = cmd.OptionParser.parse_args([]) + cmd.ValidateOptions(opts, args) + assert cmd._ResolveSmartSyncOption(opts, manifest) is False + assert opts.smart_sync == expected_smart_sync + + +@pytest.mark.parametrize( + "argv, user_pref, sync_smartsync_manifest, expected_exception", + [ + (["-u", "user", "-p", "pass"], None, False, sync.SmartSyncError), + (["-u", "user", "-p", "pass"], None, True, None), + (["-s", "-u", "user", "-p", "pass"], None, False, None), + (["-t", "tag", "-u", "user", "-p", "pass"], None, False, None), ( ["--no-smart-sync", "-u", "user", "-p", "pass"], + None, True, sync.SmartSyncError, ), - (["-l", "-u", "user", "-p", "pass"], True, sync.SmartSyncError), - (["--nmu", "-u", "user", "-p", "pass"], True, None), - (["--nmu", "-u", "user", "-p", "pass"], False, sync.SmartSyncError), - (["-s", "-l", "-u", "user", "-p", "pass"], True, None), - (["-u", "user"], False, SystemExit), - (["-p", "pass"], False, SystemExit), + (["-l", "-u", "user", "-p", "pass"], None, True, sync.SmartSyncError), + (["--nmu", "-u", "user", "-p", "pass"], None, True, None), + ( + ["--nmu", "-u", "user", "-p", "pass"], + None, + False, + sync.SmartSyncError, + ), + (["-s", "-l", "-u", "user", "-p", "pass"], None, True, None), + (["-u", "user"], None, False, SystemExit), + (["-p", "pass"], None, False, SystemExit), + # -u/-p are accepted when smart sync comes from sync.smartsync=true... + (["-u", "user", "-p", "pass"], True, False, None), + # ...and rejected when sync.smartsync=false drops the manifest default. + (["-u", "user", "-p", "pass"], False, True, sync.SmartSyncError), ], ) def test_cli_manifest_server_credentials( argv: List[str], + user_pref: Optional[bool], sync_smartsync_manifest: bool, expected_exception: Optional[Type[BaseException]], ) -> None: @@ -529,15 +618,19 @@ def test_cli_manifest_server_credentials( manifest = mock.MagicMock() manifest.default.sync_smartsync = sync_smartsync_manifest + user_cfg = mock.MagicMock() + user_cfg.GetBoolean.return_value = user_pref + cmd = sync.Sync(manifest=manifest) opts, args = cmd.OptionParser.parse_args(argv) - if expected_exception: - with pytest.raises(expected_exception): + with mock.patch.object(sync.RepoConfig, "ForUser", return_value=user_cfg): + 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) - else: - cmd.ValidateOptions(opts, args) - cmd._ResolveSmartSyncOption(opts, manifest) class LocalSyncState(unittest.TestCase): @@ -1699,6 +1792,48 @@ class SyncCommand(unittest.TestCase): mock_setup.assert_called_once() self.assertFalse(mock_setup.call_args.kwargs.get("best_effort")) + def test_user_config_smart_sync_disabled(self) -> None: + """Ensure sync.smartsync=false skips the manifest's default.""" + self.manifest.default.sync_smartsync = True + self.opt.smart_sync = None + mock.patch.object(self.cmd, "_UpdateAllManifestProjects").start() + user_cfg = mock.MagicMock() + user_cfg.GetBoolean.return_value = False + with mock.patch.object( + sync.RepoConfig, "ForUser", return_value=user_cfg + ): + with mock.patch.object(self.cmd, "_SmartSyncSetup") 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" + ): + self.cmd.Execute(self.opt, []) + mock_setup.assert_not_called() + self.assertFalse(self.opt.smart_sync) + + def test_user_config_smart_sync_enabled_error(self) -> None: + """Ensure sync.smartsync=true fails hard instead of falling back.""" + self.manifest.default.sync_smartsync = False + self.opt.smart_sync = None + self.opt.mp_update = False + user_cfg = mock.MagicMock() + user_cfg.GetBoolean.return_value = True + with mock.patch.object( + sync.RepoConfig, "ForUser", return_value=user_cfg + ): + with mock.patch.object( + self.cmd, + "_SmartSyncSetup", + side_effect=sync.SmartSyncError("unreachable"), + ) as mock_setup: + with self.assertRaises(sync.SmartSyncError): + self.cmd.Execute(self.opt, []) + mock_setup.assert_called_once() + 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