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 <gavinmak@google.com>
Commit-Queue: Insaf Latypov <saferif@google.com>
Tested-by: Insaf Latypov <saferif@google.com>
diff --git a/docs/internal-fs-layout.md b/docs/internal-fs-layout.md
index ea0070b..64fe74c 100644
--- a/docs/internal-fs-layout.md
+++ b/docs/internal-fs-layout.md
@@ -253,6 +253,7 @@
 
 *   `.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 0000000..aef92fd
--- /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.<name>` defines `repo <name>` 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 `<default>` 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 24f3746..905a004 100644
--- a/docs/smart-sync.md
+++ b/docs/smart-sync.md
@@ -204,6 +204,10 @@
 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 @@
 Explicitly disables Smart Sync, overriding any `sync-smartsync="true"` setting
 declared in the manifest `<default>` 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 b033a90..2c7467e 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 @@
 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 e83f423..d6354d4 100644
--- a/subcmds/sync.py
+++ b/subcmds/sync.py
@@ -69,6 +69,7 @@
 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 @@
 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 @@
     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 e7e8013..32ff3cd 100644
--- a/tests/test_subcmds_sync.py
+++ b/tests/test_subcmds_sync.py
@@ -501,27 +501,116 @@
 
 
 @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 @@
     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 @@
             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