Compare commits

..
2 Commits
Author SHA1 Message Date
Insaf Latypov 46e9866ca0 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>
2026-10-19 12:20:06 -07:00
Rahul Yadav 404aec7b53 upload: reject --fix with --ignore-hooks or --no-verify
--fix asks the pre-upload hook to apply its fixups, but:
* --no-verify skips the hook entirely, so --fix silently does nothing.
* --ignore-hooks uploads the original commits even if the hook fails,
  so any fixups applied to the working tree are not part of the
  upload. Whether --fix should then apply fixups or be ignored is
  ambiguous.

Reject both combinations up front, before any hook runs, instead of
picking an interpretation.

Bug: 562921767
Test: python3 -m pytest tests/test_subcmds_upload.py
Change-Id: Ib00ae69004642f2e763212b82e7c39f3b4da52eb
Reviewed-on: https://gerrit-review.googlesource.com/c/git-repo/+/642664
Tested-by: Rahul Yadav <yadavrah@google.com>
Reviewed-by: Mike Frysinger <vapier@google.com>
Commit-Queue: Rahul Yadav <yadavrah@google.com>
2026-10-16 00:18:33 -07:00
8 changed files with 357 additions and 21 deletions

No files matched your search

+1
View File
@@ -253,6 +253,7 @@ setting `REPO_CONFIG_DIR`.
* `.repoconfig/`: Repo's per-user directory for all random config files/state. * `.repoconfig/`: Repo's per-user directory for all random config files/state.
* `.repoconfig/config`: Per-user settings using [git-config] file format. * `.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 * `.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 has all the same keys as the repo launcher. Used to avoid running gpg
constantly as that can be quite slow. constantly as that can be quite slow.
+103
View File
@@ -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
+16
View File
@@ -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 manifest source (such as `-t`/`--smart-tag`, `-m`/`--manifest-name` or
`--superproject-revision`) will also disable the default Smart Sync behavior. `--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 ### --smart-sync / -s
Explicitly enables Smart Sync. Repo will call 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 Explicitly disables Smart Sync, overriding any `sync-smartsync="true"` setting
declared in the manifest `<default>` element. 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 ### --smart-tag=TAG / -t
Repo will call `GetManifest(TAG)`. Repo will call `GetManifest(TAG)`.
+5 -1
View File
@@ -1,5 +1,5 @@
.\" DO NOT MODIFY THIS FILE! It was generated by help2man. .\" 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 .SH NAME
repo \- repo sync - manual page for repo sync repo \- repo sync - manual page for repo sync
.SH SYNOPSIS .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 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. \fB\-t\fR/\-\-smart\-tag option is similar and allows you to specify a custom tag/label.
.PP .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 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 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. server when using the \fB\-s\fR or \fB\-t\fR option.
+23 -4
View File
@@ -69,6 +69,7 @@ import event_log
from git_command import git_require from git_command import git_require
from git_command import GitCommand from git_command import GitCommand
from git_config import GetUrlCookieFile from git_config import GetUrlCookieFile
from git_config import RepoConfig
from git_refs import HEAD from git_refs import HEAD
from git_refs import R_HEADS from git_refs import R_HEADS
import git_superproject 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 manifest. The -t/--smart-tag option is similar and allows you to
specify a custom tag/label. 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 The -u/--manifest-server-username and -p/--manifest-server-password
options can be used to specify a username and password to authenticate options can be used to specify a username and password to authenticate
with the manifest server when using the -s or -t option. 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( def _ResolveSmartSyncOption(
self, opt: optparse.Values, manifest: XmlManifest self, opt: optparse.Values, manifest: XmlManifest
) -> bool: ) -> 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: Returns:
True if smart_sync was enabled implicitly by the manifest default, 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 opt.smart_sync is None
and not opt.smart_tag and not opt.smart_tag
and not opt.manifest_name and not opt.manifest_name
and not opt.superproject_revision and not opt.superproject_revision
and not opt.local_only and not opt.local_only
)
implicit = (
default_allowed
and user_pref is None
and getattr(manifest.default, "sync_smartsync", False) is True and getattr(manifest.default, "sync_smartsync", False) is True
) )
if opt.smart_sync is None: 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 ( if (
opt.manifest_server_username or opt.manifest_server_password opt.manifest_server_username or opt.manifest_server_password
+16
View File
@@ -742,6 +742,22 @@ Gerrit Code Review: https://www.gerritcodereview.com/
avail = project.GetUploadableBranches(opt.branch) avail = project.GetUploadableBranches(opt.branch)
return (project_idx, avail, cbr) return (project_idx, avail, cbr)
def ValidateOptions(self, opt: optparse.Values, args: List[str]) -> None:
if opt.fix:
if opt.ignore_hooks:
self.OptionParser.error(
"--fix and --ignore-hooks are mutually exclusive options: "
"with --ignore-hooks, the original commits are uploaded, "
"so fixups would only modify the working tree and would "
"not be part of the upload"
)
if opt.bypass_hooks:
self.OptionParser.error(
"--fix and --no-verify are mutually exclusive options: "
"--no-verify skips the pre-upload hook, so --fix would "
"have no effect"
)
def Execute(self, opt, args): def Execute(self, opt, args):
projects = self.GetProjects( projects = self.GetProjects(
args, all_manifests=not opt.this_manifest_only args, all_manifests=not opt.this_manifest_only
+151 -16
View File
@@ -501,27 +501,116 @@ def test_cli_smart_sync(
@pytest.mark.parametrize( @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), # sync.smartsync=false: never smart sync unless -s/-t is given.
(["-u", "user", "-p", "pass"], True, None), ([], False, True, False, False),
(["-s", "-u", "user", "-p", "pass"], False, None), ([], False, False, False, False),
(["-t", "tag", "-u", "user", "-p", "pass"], False, None), (["-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"], ["--no-smart-sync", "-u", "user", "-p", "pass"],
None,
True, True,
sync.SmartSyncError, sync.SmartSyncError,
), ),
(["-l", "-u", "user", "-p", "pass"], True, sync.SmartSyncError), (["-l", "-u", "user", "-p", "pass"], None, True, sync.SmartSyncError),
(["--nmu", "-u", "user", "-p", "pass"], True, None), (["--nmu", "-u", "user", "-p", "pass"], None, True, None),
(["--nmu", "-u", "user", "-p", "pass"], False, sync.SmartSyncError), (
(["-s", "-l", "-u", "user", "-p", "pass"], True, None), ["--nmu", "-u", "user", "-p", "pass"],
(["-u", "user"], False, SystemExit), None,
(["-p", "pass"], False, SystemExit), 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( def test_cli_manifest_server_credentials(
argv: List[str], argv: List[str],
user_pref: Optional[bool],
sync_smartsync_manifest: bool, sync_smartsync_manifest: bool,
expected_exception: Optional[Type[BaseException]], expected_exception: Optional[Type[BaseException]],
) -> None: ) -> None:
@@ -529,15 +618,19 @@ def test_cli_manifest_server_credentials(
manifest = mock.MagicMock() manifest = mock.MagicMock()
manifest.default.sync_smartsync = sync_smartsync_manifest manifest.default.sync_smartsync = sync_smartsync_manifest
user_cfg = mock.MagicMock()
user_cfg.GetBoolean.return_value = user_pref
cmd = sync.Sync(manifest=manifest) cmd = sync.Sync(manifest=manifest)
opts, args = cmd.OptionParser.parse_args(argv) opts, args = cmd.OptionParser.parse_args(argv)
if expected_exception: with mock.patch.object(sync.RepoConfig, "ForUser", return_value=user_cfg):
with pytest.raises(expected_exception): if expected_exception:
with pytest.raises(expected_exception):
cmd.ValidateOptions(opts, args)
cmd._ResolveSmartSyncOption(opts, manifest)
else:
cmd.ValidateOptions(opts, args) cmd.ValidateOptions(opts, args)
cmd._ResolveSmartSyncOption(opts, manifest) cmd._ResolveSmartSyncOption(opts, manifest)
else:
cmd.ValidateOptions(opts, args)
cmd._ResolveSmartSyncOption(opts, manifest)
class LocalSyncState(unittest.TestCase): class LocalSyncState(unittest.TestCase):
@@ -1699,6 +1792,48 @@ class SyncCommand(unittest.TestCase):
mock_setup.assert_called_once() mock_setup.assert_called_once()
self.assertFalse(mock_setup.call_args.kwargs.get("best_effort")) 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: def test_implicit_smart_sync_unparsable_manifest(self) -> None:
"""Ensure an unparsable smart sync manifest falls back to ToT.""" """Ensure an unparsable smart sync manifest falls back to ToT."""
self.manifest.default.sync_smartsync = True self.manifest.default.sync_smartsync = True
+42
View File
@@ -233,3 +233,45 @@ def test_MultipleBranches_yes_without_branch_or_cbr_uses_editor(
cmd._MultipleBranches(opt, pending, _STUB_PEOPLE) cmd._MultipleBranches(opt, pending, _STUB_PEOPLE)
mock_edit.assert_called_once() mock_edit.assert_called_once()
mock_upload.assert_called_once_with(opt, [branch1], _STUB_PEOPLE) mock_upload.assert_called_once_with(opt, [branch1], _STUB_PEOPLE)
@pytest.mark.parametrize(
"argv, expected",
(
(["--fix", "--ignore-hooks"], "not be part of the upload"),
(["--fix", "--no-verify"], "--fix would have no effect"),
(
["--fix", "--ignore-hooks", "--no-verify"],
"--fix and --ignore-hooks are mutually exclusive",
),
),
)
def test_ValidateOptions_fix_conflicts(
cmd: upload.Upload,
capsys: pytest.CaptureFixture,
argv: List[str],
expected: str,
) -> None:
"""Check --fix is rejected with --ignore-hooks or --no-verify."""
opt, args = cmd.OptionParser.parse_args(argv)
with pytest.raises(SystemExit):
cmd.ValidateOptions(opt, args)
assert expected in capsys.readouterr().err
@pytest.mark.parametrize(
"argv",
(
[],
["--fix"],
["--fix", "--verify"],
["--fix", "-y"],
["--ignore-hooks"],
["--no-verify"],
["--ignore-hooks", "--no-verify"],
),
)
def test_ValidateOptions_allowed(cmd: upload.Upload, argv: List[str]) -> None:
"""Check valid hook option combinations are accepted."""
opt, args = cmd.OptionParser.parse_args(argv)
cmd.ValidateOptions(opt, args)