Merge tag 'v2.32'

* tag 'v2.32':
  upload: Skip upload if merge branch doesn't match project revision and dest_branch.
  tests: Change docstring for CopyLinkTestCase
  tests: Rework run_tests to use pytest directly and add vpython3 file
  sync: Remove unused variable
  Handle KeyboardInterrupt during repo sync
  Update sync progress
  project: clean up error message
  Update bug tracking links
  git_superproject: Log actual error fmt instead of the entire error message.
  sync: Silence 'not found in manifest' message
  Enable use of REPO_CONFIG_DIR to customize .repoconfig location
  init: Silence the "rm -r .repo and try again" message if quiet
  Fix flake8 warnings for some files
diff --git a/README.md b/README.md
index 5519e9a..ff0a2b6 100644
--- a/README.md
+++ b/README.md
@@ -8,7 +8,7 @@
 
 * Homepage: <https://gerrit.googlesource.com/git-repo/>
 * Mailing list: [repo-discuss on Google Groups][repo-discuss]
-* Bug reports: <https://bugs.chromium.org/p/gerrit/issues/list?q=component:repo>
+* Bug reports: <https://bugs.chromium.org/p/gerrit/issues/list?q=component:Applications%3Erepo>
 * Source: <https://gerrit.googlesource.com/git-repo/>
 * Overview: <https://source.android.com/source/developing.html>
 * Docs: <https://source.android.com/source/using-repo.html>
@@ -51,5 +51,5 @@
 
 
 [new-bug]: https://bugs.chromium.org/p/gerrit/issues/entry?template=Repo+tool+issue
-[issue tracker]: https://bugs.chromium.org/p/gerrit/issues/list?q=component:repo
+[issue tracker]: https://bugs.chromium.org/p/gerrit/issues/list?q=component:Applications%3Erepo
 [repo-discuss]: https://groups.google.com/forum/#!forum/repo-discuss
diff --git a/command.py b/command.py
index 7c68ebc..68f36f0 100644
--- a/command.py
+++ b/command.py
@@ -320,7 +320,8 @@
       for arg in args:
         # We have to filter by manifest groups in case the requested project is
         # checked out multiple times or differently based on them.
-        projects = [project for project in manifest.GetProjectsWithName(
+        projects = [project
+                    for project in manifest.GetProjectsWithName(
                         arg, all_manifests=all_manifests)
                     if project.MatchesGroups(groups)]
 
diff --git a/docs/internal-fs-layout.md b/docs/internal-fs-layout.md
index 8be6178..401ebda 100644
--- a/docs/internal-fs-layout.md
+++ b/docs/internal-fs-layout.md
@@ -243,7 +243,9 @@
 
 ## ~/ dotconfig layout
 
-Repo will create & maintain a few files in the user's home directory.
+Repo will create & maintain a few files under the `.repoconfig/` directory.
+This is placed in the user's home directory by default but can be changed by
+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.
diff --git a/git_command.py b/git_command.py
index 3a3bb34..d4d4bed 100644
--- a/git_command.py
+++ b/git_command.py
@@ -159,12 +159,12 @@
 
 
 def _build_env(
-  _kwargs_only=(),
-  bare: Optional[bool] = False,
-  disable_editor: Optional[bool] = False,
-  ssh_proxy: Optional[Any] = None,
-  gitdir: Optional[str] = None,
-  objdir: Optional[str] = None
+    _kwargs_only=(),
+    bare: Optional[bool] = False,
+    disable_editor: Optional[bool] = False,
+    ssh_proxy: Optional[Any] = None,
+    gitdir: Optional[str] = None,
+    objdir: Optional[str] = None
 ):
   """Constucts an env dict for command execution."""
 
@@ -194,8 +194,7 @@
     env['GIT_OBJECT_DIRECTORY'] = objdir
 
     alt_objects = os.path.join(gitdir, 'objects') if gitdir else None
-    if (alt_objects and
-        os.path.realpath(alt_objects) != os.path.realpath(objdir)):
+    if alt_objects and os.path.realpath(alt_objects) != os.path.realpath(objdir):
       # Allow git to search the original place in case of local or unique refs
       # that git will attempt to resolve even if we aren't fetching them.
       env['GIT_ALTERNATE_OBJECT_DIRECTORIES'] = alt_objects
@@ -236,11 +235,11 @@
         gitdir = gitdir.replace('\\', '/')
 
     env = _build_env(
-      disable_editor=disable_editor,
-      ssh_proxy=ssh_proxy,
-      objdir=objdir,
-      gitdir=gitdir,
-      bare=bare,
+        disable_editor=disable_editor,
+        ssh_proxy=ssh_proxy,
+        objdir=objdir,
+        gitdir=gitdir,
+        bare=bare,
     )
 
     command = [GIT]
@@ -279,7 +278,8 @@
       if 'GIT_OBJECT_DIRECTORY' in env:
         dbg += ': export GIT_OBJECT_DIRECTORY=%s\n' % env['GIT_OBJECT_DIRECTORY']
       if 'GIT_ALTERNATE_OBJECT_DIRECTORIES' in env:
-        dbg += ': export GIT_ALTERNATE_OBJECT_DIRECTORIES=%s\n' % env['GIT_ALTERNATE_OBJECT_DIRECTORIES']
+        dbg += ': export GIT_ALTERNATE_OBJECT_DIRECTORIES=%s\n' % (
+            env['GIT_ALTERNATE_OBJECT_DIRECTORIES'])
 
       dbg += ': '
       dbg += ' '.join(command)
@@ -295,13 +295,13 @@
     with Trace('git command %s %s with debug: %s', LAST_GITDIR, command, dbg):
       try:
         p = subprocess.Popen(command,
-                            cwd=cwd,
-                            env=env,
-                            encoding='utf-8',
-                            errors='backslashreplace',
-                            stdin=stdin,
-                            stdout=stdout,
-                            stderr=stderr)
+                             cwd=cwd,
+                             env=env,
+                             encoding='utf-8',
+                             errors='backslashreplace',
+                             stdin=stdin,
+                             stdout=stdout,
+                             stderr=stderr)
       except Exception as e:
         raise GitError('%s: %s' % (command[1], e))
 
diff --git a/git_config.py b/git_config.py
index af1a101..9ad979a 100644
--- a/git_config.py
+++ b/git_config.py
@@ -69,8 +69,6 @@
 class GitConfig(object):
   _ForUser = None
 
-  _USER_CONFIG = '~/.gitconfig'
-
   _ForSystem = None
   _SYSTEM_CONFIG = '/etc/gitconfig'
 
@@ -83,9 +81,13 @@
   @classmethod
   def ForUser(cls):
     if cls._ForUser is None:
-      cls._ForUser = cls(configfile=os.path.expanduser(cls._USER_CONFIG))
+      cls._ForUser = cls(configfile=cls._getUserConfig())
     return cls._ForUser
 
+  @staticmethod
+  def _getUserConfig():
+    return os.path.expanduser('~/.gitconfig')
+
   @classmethod
   def ForRepository(cls, gitdir, defaults=None):
     return cls(configfile=os.path.join(gitdir, 'config'),
@@ -188,7 +190,7 @@
     if v in ('false', 'no'):
       return False
     print(f"warning: expected {name} to represent a boolean, got {v} instead",
-            file=sys.stderr)
+          file=sys.stderr)
     return None
 
   def SetBoolean(self, name, value):
@@ -197,7 +199,7 @@
       value = 'true' if value else 'false'
     self.SetString(name, value)
 
-  def GetString(self, name: str, all_keys: bool = False) -> Union[str,  None]:
+  def GetString(self, name: str, all_keys: bool = False) -> Union[str, None]:
     """Get the first value for a key, or None if it is not defined.
 
        This configuration file is used first, if the key is not
@@ -415,7 +417,10 @@
 class RepoConfig(GitConfig):
   """User settings for repo itself."""
 
-  _USER_CONFIG = '~/.repoconfig/config'
+  @staticmethod
+  def _getUserConfig():
+    repo_config_dir = os.getenv('REPO_CONFIG_DIR', os.path.expanduser('~'))
+    return os.path.join(repo_config_dir, '.repoconfig/config')
 
 
 class RefSpec(object):
diff --git a/git_superproject.py b/git_superproject.py
index b5c262b..69a4d1f 100644
--- a/git_superproject.py
+++ b/git_superproject.py
@@ -125,23 +125,24 @@
     """Returns the manifest path if the path exists or None."""
     return self._manifest_path if os.path.exists(self._manifest_path) else None
 
-  def _LogMessage(self, message):
+  def _LogMessage(self, fmt, *inputs):
     """Logs message to stderr and _git_event_log."""
+    message = f'{self._LogMessagePrefix()} {fmt.format(*inputs)}'
     if self._print_messages:
       print(message, file=sys.stderr)
-    self._git_event_log.ErrorEvent(message, f'{message}')
+    self._git_event_log.ErrorEvent(message, fmt)
 
   def _LogMessagePrefix(self):
     """Returns the prefix string to be logged in each log message"""
     return f'repo superproject branch: {self._branch} url: {self._remote_url}'
 
-  def _LogError(self, message):
+  def _LogError(self, fmt, *inputs):
     """Logs error message to stderr and _git_event_log."""
-    self._LogMessage(f'{self._LogMessagePrefix()} error: {message}')
+    self._LogMessage(f'error: {fmt}', *inputs)
 
-  def _LogWarning(self, message):
+  def _LogWarning(self, fmt, *inputs):
     """Logs warning message to stderr and _git_event_log."""
-    self._LogMessage(f'{self._LogMessagePrefix()} warning: {message}')
+    self._LogMessage(f'warning: {fmt}', *inputs)
 
   def _Init(self):
     """Sets up a local Git repository to get a copy of a superproject.
@@ -162,8 +163,8 @@
                    capture_stderr=True)
     retval = p.Wait()
     if retval:
-      self._LogWarning(f'git init call failed, command: git {cmd}, '
-                       f'return code: {retval}, stderr: {p.stderr}')
+      self._LogWarning('git init call failed, command: git {}, '
+                       'return code: {}, stderr: {}', cmd, retval, p.stderr)
       return False
     return True
 
@@ -174,7 +175,7 @@
       True if fetch is successful, or False.
     """
     if not os.path.exists(self._work_git):
-      self._LogWarning(f'git fetch missing directory: {self._work_git}')
+      self._LogWarning('git fetch missing directory: {}', self._work_git)
       return False
     if not git_require((2, 28, 0)):
       self._LogWarning('superproject requires a git version 2.28 or later')
@@ -200,8 +201,8 @@
                    capture_stderr=True)
     retval = p.Wait()
     if retval:
-      self._LogWarning(f'git fetch call failed, command: git {cmd}, '
-                       f'return code: {retval}, stderr: {p.stderr}')
+      self._LogWarning('git fetch call failed, command: git {}, '
+                       'return code: {}, stderr: {}', cmd, retval, p.stderr)
       return False
     return True
 
@@ -214,7 +215,7 @@
       data: data returned from 'git ls-tree ...' instead of None.
     """
     if not os.path.exists(self._work_git):
-      self._LogWarning(f'git ls-tree missing directory: {self._work_git}')
+      self._LogWarning('git ls-tree missing directory: {}', self._work_git)
       return None
     data = None
     branch = 'HEAD' if not self._branch else self._branch
@@ -229,8 +230,8 @@
     if retval == 0:
       data = p.stdout
     else:
-      self._LogWarning(f'git ls-tree call failed, command: git {cmd}, '
-                       f'return code: {retval}, stderr: {p.stderr}')
+      self._LogWarning('git ls-tree call failed, command: git {}, '
+                       'return code: {}, stderr: {}', cmd, retval, p.stderr)
     return data
 
   def Sync(self, git_event_log):
@@ -244,16 +245,16 @@
     """
     self._git_event_log = git_event_log
     if not self._manifest.superproject:
-      self._LogWarning(f'superproject tag is not defined in manifest: '
-                       f'{self._manifest.manifestFile}')
+      self._LogWarning('superproject tag is not defined in manifest: {}',
+                       self._manifest.manifestFile)
       return SyncResult(False, False)
 
     _PrintBetaNotice()
 
     should_exit = True
     if not self._remote_url:
-      self._LogWarning(f'superproject URL is not defined in manifest: '
-                       f'{self._manifest.manifestFile}')
+      self._LogWarning('superproject URL is not defined in manifest: {}',
+                       self._manifest.manifestFile)
       return SyncResult(False, should_exit)
 
     if not self._Init():
@@ -276,8 +277,8 @@
 
     data = self._LsTree()
     if not data:
-      self._LogWarning(f'git ls-tree failed to return data for manifest: '
-                       f'{self._manifest.manifestFile}')
+      self._LogWarning('git ls-tree failed to return data for manifest: {}',
+                       self._manifest.manifestFile)
       return CommitIdsResult(None, True)
 
     # Parse lines like the following to select lines starting with '160000' and
@@ -303,7 +304,7 @@
       manifest_path: Path name of the file into which manifest is written instead of None.
     """
     if not os.path.exists(self._superproject_path):
-      self._LogWarning(f'missing superproject directory: {self._superproject_path}')
+      self._LogWarning('missing superproject directory: {}', self._superproject_path)
       return None
     manifest_str = self._manifest.ToXml(groups=self._manifest.GetGroupsStr(),
                                         omit_local=True).toxml()
@@ -312,7 +313,8 @@
       with open(manifest_path, 'w', encoding='utf-8') as fp:
         fp.write(manifest_str)
     except IOError as e:
-      self._LogError(f'cannot write manifest to : {manifest_path} {e}')
+      self._LogError('cannot write manifest to : {} {}',
+                     manifest_path, e)
       return None
     return manifest_path
 
@@ -364,8 +366,9 @@
     # If superproject doesn't have a commit id for a project, then report an
     # error event and continue as if do not use superproject is specified.
     if projects_missing_commit_ids:
-      self._LogWarning(f'please file a bug using {self._manifest.contactinfo.bugurl} '
-                       f'to report missing commit_ids for: {projects_missing_commit_ids}')
+      self._LogWarning('please file a bug using {} to report missing '
+                       'commit_ids for: {}', self._manifest.contactinfo.bugurl,
+                       projects_missing_commit_ids)
       return UpdateProjectsResult(None, False)
 
     for project in projects:
diff --git a/project.py b/project.py
index ae89204..df627d4 100644
--- a/project.py
+++ b/project.py
@@ -55,6 +55,7 @@
   # commit already present.
   remote_fetched: bool
 
+
 # Maximum sleep time allowed during retries.
 MAXIMUM_RETRY_SLEEP_SEC = 3600.0
 # +-10% random jitter is added to each Fetches retry sleep duration.
@@ -64,6 +65,7 @@
 # TODO(vapier): Remove knob once behavior is verified.
 _ALTERNATES = os.environ.get('REPO_USE_ALTERNATES') == '1'
 
+
 def _lwrite(path, content):
   lock = '%s.lock' % path
 
@@ -3494,6 +3496,7 @@
     except OSError:
       return 0
 
+
 class ManifestProject(MetaProject):
   """The MetaProject for manifests."""
 
@@ -3924,11 +3927,12 @@
       self.config.SetBoolean('repo.superproject', use_superproject)
 
     if not standalone_manifest:
-      if not self.Sync_NetworkHalf(
+      success = self.Sync_NetworkHalf(
           is_new=is_new, quiet=not verbose, verbose=verbose,
           clone_bundle=clone_bundle, current_branch_only=current_branch_only,
           tags=tags, submodules=submodules, clone_filter=clone_filter,
-          partial_clone_exclude=self.manifest.PartialCloneExclude).success:
+          partial_clone_exclude=self.manifest.PartialCloneExclude).success
+      if not success:
         r = self.GetRemote()
         print('fatal: cannot obtain manifest %s' % r.url, file=sys.stderr)
 
@@ -4007,12 +4011,14 @@
     if git_superproject.UseSuperproject(use_superproject, self.manifest):
       sync_result = self.manifest.superproject.Sync(git_event_log)
       if not sync_result.success:
-        print('warning: git update of superproject for '
-              f'{self.manifest.path_prefix} failed, repo sync will not use '
-              'superproject to fetch source; while this error is not fatal, '
-              'and you can continue to run repo sync, please run repo init '
-              'with the --no-use-superproject option to stop seeing this '
-              'warning', file=sys.stderr)
+        submanifest = ''
+        if self.manifest.path_prefix:
+          submanifest = f'for {self.manifest.path_prefix} '
+        print(f'warning: git update of superproject {submanifest}failed, repo '
+              'sync will not use superproject to fetch source; while this '
+              'error is not fatal, and you can continue to run repo sync, '
+              'please run repo init with the --no-use-superproject option to '
+              'stop seeing this warning', file=sys.stderr)
         if sync_result.fatal and use_superproject is not None:
           return False
 
diff --git a/repo b/repo
index cdf4fab..80d69d8 100755
--- a/repo
+++ b/repo
@@ -149,7 +149,7 @@
 BUG_URL = 'https://bugs.chromium.org/p/gerrit/issues/entry?template=Repo+tool+issue'
 
 # increment this whenever we make important changes to this script
-VERSION = (2, 30)
+VERSION = (2, 32)
 
 # increment this if the MAINTAINER_KEYS block is modified
 KEYRING_VERSION = (2, 3)
@@ -265,7 +265,8 @@
   urllib.error = urllib2
 
 
-home_dot_repo = os.path.expanduser('~/.repoconfig')
+repo_config_dir = os.getenv('REPO_CONFIG_DIR', os.path.expanduser('~'))
+home_dot_repo = os.path.join(repo_config_dir, '.repoconfig')
 gpg_dir = os.path.join(home_dot_repo, 'gnupg')
 
 
diff --git a/run_tests b/run_tests
index 5de51cf..0ea098a 100755
--- a/run_tests
+++ b/run_tests
@@ -15,46 +15,8 @@
 
 """Wrapper to run pytest with the right settings."""
 
-import os
-import shutil
-import subprocess
 import sys
-
-
-def find_pytest():
-  """Try to locate a good version of pytest."""
-  # If we're in a virtualenv, assume that it's provided the right pytest.
-  if 'VIRTUAL_ENV' in os.environ:
-    return 'pytest'
-
-  # Use the Python 3 version if available.
-  ret = shutil.which('pytest-3')
-  if ret:
-    return ret
-
-  # Hopefully this is a Python 3 version.
-  ret = shutil.which('pytest')
-  if ret:
-    return ret
-
-  print('%s: unable to find pytest.' % (__file__,), file=sys.stderr)
-  print('%s: Try installing: sudo apt-get install python-pytest' % (__file__,),
-        file=sys.stderr)
-
-
-def main(argv):
-  """The main entry."""
-  # Add the repo tree to PYTHONPATH as the tests expect to be able to import
-  # modules directly.
-  pythonpath = os.path.dirname(os.path.realpath(__file__))
-  oldpythonpath = os.environ.get('PYTHONPATH', None)
-  if oldpythonpath is not None:
-    pythonpath += os.pathsep + oldpythonpath
-  os.environ['PYTHONPATH'] = pythonpath
-
-  pytest = find_pytest()
-  return subprocess.run([pytest] + argv, check=False).returncode
-
+import pytest
 
 if __name__ == '__main__':
-  sys.exit(main(sys.argv[1:]))
+  sys.exit(pytest.main(sys.argv[1:]))
diff --git a/run_tests.vpython3 b/run_tests.vpython3
new file mode 100644
index 0000000..d0e821d
--- /dev/null
+++ b/run_tests.vpython3
@@ -0,0 +1,61 @@
+# This is a vpython "spec" file.
+#
+# Read more about `vpython` and how to modify this file here:
+#   https://chromium.googlesource.com/infra/infra/+/main/doc/users/vpython.md
+# List of available wheels:
+#   https://chromium.googlesource.com/infra/infra/+/main/infra/tools/dockerbuild/wheels.md
+
+python_version: "3.8"
+
+wheel: <
+  name: "infra/python/wheels/pytest-py3"
+  version: "version:6.2.2"
+>
+
+# Required by pytest==6.2.2
+wheel: <
+  name: "infra/python/wheels/py-py2_py3"
+  version: "version:1.10.0"
+>
+
+# Required by pytest==6.2.2
+wheel: <
+  name: "infra/python/wheels/iniconfig-py3"
+  version: "version:1.1.1"
+>
+
+# Required by pytest==6.2.2
+wheel: <
+  name: "infra/python/wheels/packaging-py2_py3"
+  version: "version:16.8"
+>
+
+# Required by pytest==6.2.2
+wheel: <
+  name: "infra/python/wheels/pluggy-py3"
+  version: "version:0.13.1"
+>
+
+# Required by pytest==6.2.2
+wheel: <
+  name: "infra/python/wheels/toml-py3"
+  version: "version:0.10.1"
+>
+
+# Required by pytest==6.2.2
+wheel: <
+  name: "infra/python/wheels/pyparsing-py3"
+  version: "version:3.0.7"
+>
+
+# Required by pytest==6.2.2
+wheel: <
+  name: "infra/python/wheels/attrs-py2_py3"
+  version: "version:21.4.0"
+>
+
+# Required by packaging==16.8
+wheel: <
+  name: "infra/python/wheels/six-py2_py3"
+  version: "version:1.16.0"
+>
diff --git a/setup.py b/setup.py
index 17aeae2..848b3f6 100755
--- a/setup.py
+++ b/setup.py
@@ -40,7 +40,7 @@
     long_description_content_type='text/plain',
     url='https://gerrit.googlesource.com/git-repo/',
     project_urls={
-        'Bug Tracker': 'https://bugs.chromium.org/p/gerrit/issues/list?q=component:repo',
+        'Bug Tracker': 'https://bugs.chromium.org/p/gerrit/issues/list?q=component:Applications%3Erepo',
     },
     # https://pypi.org/classifiers/
     classifiers=[
diff --git a/subcmds/init.py b/subcmds/init.py
index 26cac62..813fa59 100644
--- a/subcmds/init.py
+++ b/subcmds/init.py
@@ -218,16 +218,14 @@
     if a in ('y', 'yes', 't', 'true', 'on'):
       gc.SetString('color.ui', 'auto')
 
-  def _DisplayResult(self, opt):
+  def _DisplayResult(self):
     if self.manifest.IsMirror:
       init_type = 'mirror '
     else:
       init_type = ''
 
-    if not opt.quiet:
-      print()
-      print('repo %shas been initialized in %s' %
-            (init_type, self.manifest.topdir))
+    print()
+    print('repo %shas been initialized in %s' % (init_type, self.manifest.topdir))
 
     current_dir = os.getcwd()
     if current_dir != self.manifest.topdir:
@@ -317,4 +315,5 @@
         self._ConfigureUser(opt)
       self._ConfigureColor()
 
-    self._DisplayResult(opt)
+    if not opt.quiet:
+      self._DisplayResult()
diff --git a/subcmds/sync.py b/subcmds/sync.py
index 0c6a8a0..d3f97aa 100644
--- a/subcmds/sync.py
+++ b/subcmds/sync.py
@@ -504,6 +504,8 @@
         print('error: Cannot fetch %s from %s'
               % (project.name, project.remote.url),
               file=sys.stderr)
+    except KeyboardInterrupt:
+      print(f'Keyboard interrupt while processing {project.name}')
     except GitError as e:
       print('error.GitError: Cannot fetch %s' % str(e), file=sys.stderr)
     except Exception as e:
@@ -550,7 +552,7 @@
             ret = False
           else:
             fetched.add(project.gitdir)
-          pm.update(msg=project.name)
+          pm.update(msg=f'Last synced: {project.name}')
         if not ret and opt.fail_fast:
           break
       return ret
@@ -778,7 +780,7 @@
       # We do not support switching between the options.  The environment
       # variable is present for testing and migration only.
       return not project.UseAlternates
-    print(f'\r{relpath}: project not found in manifest.', file=sys.stderr)
+
     return False
 
   def _SetPreciousObjectsState(self, project: Project, opt):
diff --git a/subcmds/upload.py b/subcmds/upload.py
index 5b17fb5..9c27923 100644
--- a/subcmds/upload.py
+++ b/subcmds/upload.py
@@ -484,19 +484,24 @@
 
         destination = opt.dest_branch or branch.project.dest_branch
 
-        # Make sure our local branch is not setup to track a different remote branch
-        merge_branch = self._GetMergeBranch(branch.project)
-        if destination:
+        if branch.project.dest_branch and not opt.dest_branch:
+
+          merge_branch = self._GetMergeBranch(
+            branch.project, local_branch=branch.name)
+
           full_dest = destination
           if not full_dest.startswith(R_HEADS):
             full_dest = R_HEADS + full_dest
 
-          if not opt.dest_branch and merge_branch and merge_branch != full_dest:
-            print('merge branch %s does not match destination branch %s'
-                  % (merge_branch, full_dest))
+          # If the merge branch of the local branch is different from the
+          # project's revision AND destination, this might not be intentional.
+          if (merge_branch and merge_branch != branch.project.revisionExpr
+              and merge_branch != full_dest):
+            print(f'For local branch {branch.name}: merge branch '
+                  f'{merge_branch} does not match destination branch '
+                  f'{destination}')
             print('skipping upload.')
-            print('Please use `--destination %s` if this is intentional'
-                  % destination)
+            print(f'Please use `--destination {destination}` if this is intentional')
             branch.uploaded = False
             continue
 
@@ -546,13 +551,14 @@
     if have_errors:
       sys.exit(1)
 
-  def _GetMergeBranch(self, project):
-    p = GitCommand(project,
-                   ['rev-parse', '--abbrev-ref', 'HEAD'],
-                   capture_stdout=True,
-                   capture_stderr=True)
-    p.Wait()
-    local_branch = p.stdout.strip()
+  def _GetMergeBranch(self, project, local_branch=None):
+    if local_branch is None:
+      p = GitCommand(project,
+                     ['rev-parse', '--abbrev-ref', 'HEAD'],
+                     capture_stdout=True,
+                     capture_stderr=True)
+      p.Wait()
+      local_branch = p.stdout.strip()
     p = GitCommand(project,
                    ['config', '--get', 'branch.%s.merge' % local_branch],
                    capture_stdout=True,
@@ -615,9 +621,8 @@
       hook = RepoHook.FromSubcmd(
           hook_type='pre-upload', manifest=manifest,
           opt=opt, abort_if_user_denies=True)
-      if not hook.Run(
-          project_list=pending_proj_names,
-          worktree_list=pending_worktrees):
+      if not hook.Run(project_list=pending_proj_names,
+                      worktree_list=pending_worktrees):
         ret = 1
     if ret:
       return ret
diff --git a/tests/test_git_superproject.py b/tests/test_git_superproject.py
index 0bdf1a4..b9b597a 100644
--- a/tests/test_git_superproject.py
+++ b/tests/test_git_superproject.py
@@ -163,6 +163,7 @@
       sync_result = self._superproject.Sync(self.git_event_log)
       self.assertFalse(sync_result.success)
       self.assertTrue(sync_result.fatal)
+      self.verifyErrorEvent()
 
   def test_superproject_get_superproject_mock_init(self):
     """Test with _Init failing."""
diff --git a/tests/test_project.py b/tests/test_project.py
index 66c05f6..c50d994 100644
--- a/tests/test_project.py
+++ b/tests/test_project.py
@@ -106,7 +106,7 @@
 class CopyLinkTestCase(unittest.TestCase):
   """TestCase for stub repo client checkouts.
 
-  It'll have a layout like:
+  It'll have a layout like this:
     tempdir/          # self.tempdir
       checkout/       # self.topdir
         git-project/  # self.worktree