Просмотр исходного кода

Merge remote-tracking branch 'mirror/master' into qs

gaojun 5 лет назад
Родитель
Сommit
e54fee5fc0
20 измененных файлов с 858 добавлено и 202 удалено
  1. 17 0
      README.md
  2. 5 2
      docs/internal-fs-layout.md
  3. 3 3
      docs/release-process.md
  4. 2 0
      git_refs.py
  5. 13 7
      main.py
  6. 70 55
      manifest_xml.py
  7. 15 3
      platform_utils.py
  8. 52 26
      project.py
  9. 11 2
      release/sign-tag.py
  10. 136 57
      repo
  11. 4 3
      subcmds/__init__.py
  12. 3 3
      subcmds/diffmanifests.py
  13. 41 10
      subcmds/download.py
  14. 7 6
      subcmds/help.py
  15. 23 3
      subcmds/init.py
  16. 10 3
      subcmds/sync.py
  17. 47 16
      subcmds/upload.py
  18. 15 3
      subcmds/version.py
  19. 57 0
      tests/test_manifest_xml.py
  20. 327 0
      tests/test_wrapper.py

+ 17 - 0
README.md

@@ -7,6 +7,7 @@ easier to work with Git.  The repo command is an executable Python script
 that you can put anywhere in your path.
 that you can put anywhere in your path.
 
 
 * Homepage: <https://gerrit.googlesource.com/git-repo/>
 * 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:repo>
 * Source: <https://gerrit.googlesource.com/git-repo/>
 * Source: <https://gerrit.googlesource.com/git-repo/>
 * Overview: <https://source.android.com/source/developing.html>
 * Overview: <https://source.android.com/source/developing.html>
@@ -18,6 +19,17 @@ that you can put anywhere in your path.
 * GitHub mirror: <https://github.com/GerritCodeReview/git-repo>
 * GitHub mirror: <https://github.com/GerritCodeReview/git-repo>
 * Postsubmit tests: <https://github.com/GerritCodeReview/git-repo/actions>
 * Postsubmit tests: <https://github.com/GerritCodeReview/git-repo/actions>
 
 
+## Contact
+
+Please use the [repo-discuss] mailing list or [issue tracker] for questions.
+
+You can [file a new bug report][new-bug] under the "repo" component.
+
+Please do not e-mail individual developers for support.
+They do not have the bandwidth for it, and often times questions have already
+been asked on [repo-discuss] or bugs posted to the [issue tracker].
+So please search those sites first.
+
 ## Install
 ## Install
 
 
 Many distros include repo, so you might be able to install from there.
 Many distros include repo, so you might be able to install from there.
@@ -36,3 +48,8 @@ $ PATH="${HOME}/.bin:${PATH}"
 $ curl https://storage.googleapis.com/git-repo-downloads/repo > ~/.bin/repo
 $ curl https://storage.googleapis.com/git-repo-downloads/repo > ~/.bin/repo
 $ chmod a+rx ~/.bin/repo
 $ chmod a+rx ~/.bin/repo
 ```
 ```
+
+
+[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
+[repo-discuss]: https://groups.google.com/forum/#!forum/repo-discuss

+ 5 - 2
docs/internal-fs-layout.md

@@ -34,7 +34,7 @@ For example, if you want to change the manifest branch, you can simply run
 
 
     It tracks the git repository at `REPO_URL` using the `REPO_REV` branch.
     It tracks the git repository at `REPO_URL` using the `REPO_REV` branch.
     Those are specified at `repo init` time using the `--repo-url=<REPO_URL>`
     Those are specified at `repo init` time using the `--repo-url=<REPO_URL>`
-    and `--repo-branch=<REPO_REV>` options.
+    and `--repo-rev=<REPO_REV>` options.
 
 
     Any changes made to this directory will usually be automatically discarded
     Any changes made to this directory will usually be automatically discarded
     by repo itself when it checks for updates.  If you want to update to the
     by repo itself when it checks for updates.  If you want to update to the
@@ -193,7 +193,9 @@ The `[branch]` settings are updated by `repo start` and `git branch`.
 | review.\<url\>.autocopy       | upload        | Automatically add to `--cc=<value>` |
 | review.\<url\>.autocopy       | upload        | Automatically add to `--cc=<value>` |
 | review.\<url\>.autoreviewer   | upload        | Automatically add to `--reviewers=<value>` |
 | review.\<url\>.autoreviewer   | upload        | Automatically add to `--reviewers=<value>` |
 | review.\<url\>.autoupload     | upload        | Automatically answer "yes" or "no" to all prompts |
 | review.\<url\>.autoupload     | upload        | Automatically answer "yes" or "no" to all prompts |
-| review.\<url\>.uploadhashtags | upload        | Automatically add to `--hashtags=<value>` |
+| review.\<url\>.uploadhashtags | upload        | Automatically add to `--hashtag=<value>` |
+| review.\<url\>.uploadlabels   | upload        | Automatically add to `--label=<value>` |
+| review.\<url\>.uploadnotify   | upload        | [Notify setting][upload-notify] to use |
 | review.\<url\>.uploadtopic    | upload        | Default [topic] to use |
 | review.\<url\>.uploadtopic    | upload        | Default [topic] to use |
 | review.\<url\>.username       | upload        | Override username with `ssh://` review URIs |
 | review.\<url\>.username       | upload        | Override username with `ssh://` review URIs |
 | remote.\<remote\>.fetch       | sync          | Set of refs to fetch |
 | remote.\<remote\>.fetch       | sync          | Set of refs to fetch |
@@ -226,3 +228,4 @@ Repo will create & maintain a few files in the user's home directory.
 [manifest-format.md]: ./manifest-format.md
 [manifest-format.md]: ./manifest-format.md
 [local manifests]: ./manifest-format.md#Local-Manifests
 [local manifests]: ./manifest-format.md#Local-Manifests
 [topic]: https://gerrit-review.googlesource.com/Documentation/intro-user.html#topics
 [topic]: https://gerrit-review.googlesource.com/Documentation/intro-user.html#topics
+[upload-notify]: https://gerrit-review.googlesource.com/Documentation/user-upload.html#notify

+ 3 - 3
docs/release-process.md

@@ -49,11 +49,11 @@ control how repo finds updates:
 
 
 *   `--repo-url`: This tells repo where to clone the full repo project itself.
 *   `--repo-url`: This tells repo where to clone the full repo project itself.
     It defaults to the official project (`REPO_URL` in the launcher script).
     It defaults to the official project (`REPO_URL` in the launcher script).
-*   `--repo-branch`: This tells repo which branch to use for the full project.
+*   `--repo-rev`: This tells repo which branch to use for the full project.
     It defaults to the `stable` branch (`REPO_REV` in the launcher script).
     It defaults to the `stable` branch (`REPO_REV` in the launcher script).
 
 
 Whenever `repo sync` is run, repo will check to see if an update is available.
 Whenever `repo sync` is run, repo will check to see if an update is available.
-It fetches the latest repo-branch from the repo-url.
+It fetches the latest repo-rev from the repo-url.
 Then it verifies that the latest commit in the branch has a valid signed tag
 Then it verifies that the latest commit in the branch has a valid signed tag
 using `git tag -v` (which uses gpg).
 using `git tag -v` (which uses gpg).
 If the tag is valid, then repo will update its internal checkout to it.
 If the tag is valid, then repo will update its internal checkout to it.
@@ -91,7 +91,7 @@ When you want to create a new release, you'll need to select a good version and
 create a signed tag using a key registered in repo itself.
 create a signed tag using a key registered in repo itself.
 Typically we just tag the latest version of the `master` branch.
 Typically we just tag the latest version of the `master` branch.
 The tag could be pushed now, but it won't be used by clients normally (since the
 The tag could be pushed now, but it won't be used by clients normally (since the
-default `repo-branch` setting is `stable`).
+default `repo-rev` setting is `stable`).
 This would allow some early testing on systems who explicitly select `master`.
 This would allow some early testing on systems who explicitly select `master`.
 
 
 ### Creating a signed tag
 ### Creating a signed tag

+ 2 - 0
git_refs.py

@@ -23,6 +23,8 @@ R_CHANGES = 'refs/changes/'
 R_HEADS = 'refs/heads/'
 R_HEADS = 'refs/heads/'
 R_TAGS = 'refs/tags/'
 R_TAGS = 'refs/tags/'
 R_PUB = 'refs/published/'
 R_PUB = 'refs/published/'
+R_WORKTREE = 'refs/worktree/'
+R_WORKTREE_M = R_WORKTREE + 'm/'
 R_M = 'refs/remotes/m/'
 R_M = 'refs/remotes/m/'
 
 
 
 

+ 13 - 7
main.py

@@ -135,8 +135,6 @@ class _Repo(object):
   def __init__(self, repodir):
   def __init__(self, repodir):
     self.repodir = repodir
     self.repodir = repodir
     self.commands = all_commands
     self.commands = all_commands
-    # add 'branch' as an alias for 'branches'
-    all_commands['branch'] = all_commands['branches']
 
 
   def _ParseArgs(self, argv):
   def _ParseArgs(self, argv):
     """Parse the main `repo` command line options."""
     """Parse the main `repo` command line options."""
@@ -206,7 +204,7 @@ class _Repo(object):
     SetDefaultColoring(gopts.color)
     SetDefaultColoring(gopts.color)
 
 
     try:
     try:
-      cmd = self.commands[name]
+      cmd = self.commands[name]()
     except KeyError:
     except KeyError:
       print("repo: '%s' is not a repo command.  See 'repo help'." % name,
       print("repo: '%s' is not a repo command.  See 'repo help'." % name,
             file=sys.stderr)
             file=sys.stderr)
@@ -348,12 +346,20 @@ repo: error:
     sys.exit(1)
     sys.exit(1)
 
 
   if exp > ver:
   if exp > ver:
-    print("""
-... A new version of repo (%s) is available.
+    print('\n... A new version of repo (%s) is available.' % (exp_str,),
+          file=sys.stderr)
+    if os.access(repo_path, os.W_OK):
+      print("""\
 ... You should upgrade soon:
 ... You should upgrade soon:
-
     cp %s %s
     cp %s %s
-""" % (exp_str, WrapperPath(), repo_path), file=sys.stderr)
+""" % (WrapperPath(), repo_path), file=sys.stderr)
+    else:
+      print("""\
+... New version is available at: %s
+... The launcher is run from: %s
+!!! The launcher is not writable.  Please talk to your sysadmin or distro
+!!! to get an update installed.
+""" % (WrapperPath(), repo_path), file=sys.stderr)
 
 
 
 
 def _CheckRepoDir(repo_dir):
 def _CheckRepoDir(repo_dir):

+ 70 - 55
manifest_xml.py

@@ -57,6 +57,60 @@ urllib.parse.uses_netloc.extend([
     'rpc'])
     'rpc'])
 
 
 
 
+def XmlBool(node, attr, default=None):
+  """Determine boolean value of |node|'s |attr|.
+
+  Invalid values will issue a non-fatal warning.
+
+  Args:
+    node: XML node whose attributes we access.
+    attr: The attribute to access.
+    default: If the attribute is not set (value is empty), then use this.
+
+  Returns:
+    True if the attribute is a valid string representing true.
+    False if the attribute is a valid string representing false.
+    |default| otherwise.
+  """
+  value = node.getAttribute(attr)
+  s = value.lower()
+  if s == '':
+    return default
+  elif s in {'yes', 'true', '1'}:
+    return True
+  elif s in {'no', 'false', '0'}:
+    return False
+  else:
+    print('warning: manifest: %s="%s": ignoring invalid XML boolean' %
+          (attr, value), file=sys.stderr)
+    return default
+
+
+def XmlInt(node, attr, default=None):
+  """Determine integer value of |node|'s |attr|.
+
+  Args:
+    node: XML node whose attributes we access.
+    attr: The attribute to access.
+    default: If the attribute is not set (value is empty), then use this.
+
+  Returns:
+    The number if the attribute is a valid number.
+
+  Raises:
+    ManifestParseError: The number is invalid.
+  """
+  value = node.getAttribute(attr)
+  if not value:
+    return default
+
+  try:
+    return int(value)
+  except ValueError:
+    raise ManifestParseError('manifest: invalid %s="%s" integer' %
+                             (attr, value))
+
+
 class _Default(object):
 class _Default(object):
   """Project defaults within the manifest."""
   """Project defaults within the manifest."""
 
 
@@ -757,29 +811,14 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
     d.destBranchExpr = node.getAttribute('dest-branch') or None
     d.destBranchExpr = node.getAttribute('dest-branch') or None
     d.upstreamExpr = node.getAttribute('upstream') or None
     d.upstreamExpr = node.getAttribute('upstream') or None
 
 
-    sync_j = node.getAttribute('sync-j')
-    if sync_j == '' or sync_j is None:
-      d.sync_j = 1
-    else:
-      d.sync_j = int(sync_j)
-
-    sync_c = node.getAttribute('sync-c')
-    if not sync_c:
-      d.sync_c = False
-    else:
-      d.sync_c = sync_c.lower() in ("yes", "true", "1")
-
-    sync_s = node.getAttribute('sync-s')
-    if not sync_s:
-      d.sync_s = False
-    else:
-      d.sync_s = sync_s.lower() in ("yes", "true", "1")
+    d.sync_j = XmlInt(node, 'sync-j', 1)
+    if d.sync_j <= 0:
+      raise ManifestParseError('%s: sync-j must be greater than 0, not "%s"' %
+                               (self.manifestFile, d.sync_j))
 
 
-    sync_tags = node.getAttribute('sync-tags')
-    if not sync_tags:
-      d.sync_tags = True
-    else:
-      d.sync_tags = sync_tags.lower() in ("yes", "true", "1")
+    d.sync_c = XmlBool(node, 'sync-c', False)
+    d.sync_s = XmlBool(node, 'sync-s', False)
+    d.sync_tags = XmlBool(node, 'sync-tags', True)
     return d
     return d
 
 
   def _ParseNotice(self, node):
   def _ParseNotice(self, node):
@@ -856,39 +895,15 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
       raise ManifestParseError("project %s path cannot be absolute in %s" %
       raise ManifestParseError("project %s path cannot be absolute in %s" %
                                (name, self.manifestFile))
                                (name, self.manifestFile))
 
 
-    rebase = node.getAttribute('rebase')
-    if not rebase:
-      rebase = True
-    else:
-      rebase = rebase.lower() in ("yes", "true", "1")
-
-    sync_c = node.getAttribute('sync-c')
-    if not sync_c:
-      sync_c = False
-    else:
-      sync_c = sync_c.lower() in ("yes", "true", "1")
-
-    sync_s = node.getAttribute('sync-s')
-    if not sync_s:
-      sync_s = self._default.sync_s
-    else:
-      sync_s = sync_s.lower() in ("yes", "true", "1")
-
-    sync_tags = node.getAttribute('sync-tags')
-    if not sync_tags:
-      sync_tags = self._default.sync_tags
-    else:
-      sync_tags = sync_tags.lower() in ("yes", "true", "1")
+    rebase = XmlBool(node, 'rebase', True)
+    sync_c = XmlBool(node, 'sync-c', False)
+    sync_s = XmlBool(node, 'sync-s', self._default.sync_s)
+    sync_tags = XmlBool(node, 'sync-tags', self._default.sync_tags)
 
 
-    clone_depth = node.getAttribute('clone-depth')
-    if clone_depth:
-      try:
-        clone_depth = int(clone_depth)
-        if clone_depth <= 0:
-          raise ValueError()
-      except ValueError:
-        raise ManifestParseError('invalid clone-depth %s in %s' %
-                                 (clone_depth, self.manifestFile))
+    clone_depth = XmlInt(node, 'clone-depth')
+    if clone_depth is not None and clone_depth <= 0:
+      raise ManifestParseError('%s: clone-depth must be greater than 0, not "%s"' %
+                               (self.manifestFile, clone_depth))
 
 
     dest_branch = node.getAttribute('dest-branch') or self._default.destBranchExpr
     dest_branch = node.getAttribute('dest-branch') or self._default.destBranchExpr
 
 
@@ -911,7 +926,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
     groups.extend(set(default_groups).difference(groups))
     groups.extend(set(default_groups).difference(groups))
 
 
     if self.IsMirror and node.hasAttribute('force-path'):
     if self.IsMirror and node.hasAttribute('force-path'):
-      if node.getAttribute('force-path').lower() in ("yes", "true", "1"):
+      if XmlBool(node, 'force-path', False):
         gitdir = os.path.join(self.topdir, '%s.git' % path)
         gitdir = os.path.join(self.topdir, '%s.git' % path)
 
 
     project = Project(manifest=self,
     project = Project(manifest=self,

+ 15 - 3
platform_utils.py

@@ -90,6 +90,11 @@ class _FileDescriptorStreamsNonBlocking(FileDescriptorStreams):
   """ Implementation of FileDescriptorStreams for platforms that support
   """ Implementation of FileDescriptorStreams for platforms that support
   non blocking I/O.
   non blocking I/O.
   """
   """
+  def __init__(self):
+    super(_FileDescriptorStreamsNonBlocking, self).__init__()
+    self._poll = select.poll()
+    self._fd_to_stream = {}
+
   class Stream(object):
   class Stream(object):
     """ Encapsulates a file descriptor """
     """ Encapsulates a file descriptor """
 
 
@@ -114,11 +119,18 @@ class _FileDescriptorStreamsNonBlocking(FileDescriptorStreams):
       self.fd.close()
       self.fd.close()
 
 
   def _create_stream(self, fd, dest, std_name):
   def _create_stream(self, fd, dest, std_name):
-    return self.Stream(fd, dest, std_name)
+    stream = self.Stream(fd, dest, std_name)
+    self._fd_to_stream[stream.fileno()] = stream
+    self._poll.register(stream, select.POLLIN)
+    return stream
+
+  def remove(self, stream):
+    self._poll.unregister(stream)
+    del self._fd_to_stream[stream.fileno()]
+    super(_FileDescriptorStreamsNonBlocking, self).remove(stream)
 
 
   def select(self):
   def select(self):
-    ready_streams, _, _ = select.select(self.streams, [], [])
-    return ready_streams
+    return [self._fd_to_stream[fd] for fd, _ in self._poll.poll()]
 
 
 
 
 class _FileDescriptorStreamsThreads(FileDescriptorStreams):
 class _FileDescriptorStreamsThreads(FileDescriptorStreams):

+ 52 - 26
project.py

@@ -42,7 +42,7 @@ import platform_utils
 import progress
 import progress
 from repo_trace import IsTrace, Trace
 from repo_trace import IsTrace, Trace
 
 
-from git_refs import GitRefs, HEAD, R_HEADS, R_TAGS, R_PUB, R_M
+from git_refs import GitRefs, HEAD, R_HEADS, R_TAGS, R_PUB, R_M, R_WORKTREE_M
 
 
 from pyversion import is_python3
 from pyversion import is_python3
 if is_python3():
 if is_python3():
@@ -201,7 +201,7 @@ class ReviewableBranch(object):
                       dryrun=False,
                       dryrun=False,
                       auto_topic=False,
                       auto_topic=False,
                       hashtags=(),
                       hashtags=(),
-                      draft=False,
+                      labels=(),
                       private=False,
                       private=False,
                       notify=None,
                       notify=None,
                       wip=False,
                       wip=False,
@@ -213,7 +213,7 @@ class ReviewableBranch(object):
                                  dryrun=dryrun,
                                  dryrun=dryrun,
                                  auto_topic=auto_topic,
                                  auto_topic=auto_topic,
                                  hashtags=hashtags,
                                  hashtags=hashtags,
-                                 draft=draft,
+                                 labels=labels,
                                  private=private,
                                  private=private,
                                  notify=notify,
                                  notify=notify,
                                  wip=wip,
                                  wip=wip,
@@ -1346,7 +1346,7 @@ class Project(object):
                       dryrun=False,
                       dryrun=False,
                       auto_topic=False,
                       auto_topic=False,
                       hashtags=(),
                       hashtags=(),
-                      draft=False,
+                      labels=(),
                       private=False,
                       private=False,
                       notify=None,
                       notify=None,
                       wip=False,
                       wip=False,
@@ -1396,16 +1396,12 @@ class Project(object):
     if dest_branch.startswith(R_HEADS):
     if dest_branch.startswith(R_HEADS):
       dest_branch = dest_branch[len(R_HEADS):]
       dest_branch = dest_branch[len(R_HEADS):]
 
 
-    upload_type = 'for'
-    if draft:
-      upload_type = 'drafts'
-
-    ref_spec = '%s:refs/%s/%s' % (R_HEADS + branch.name, upload_type,
-                                  dest_branch)
+    ref_spec = '%s:refs/for/%s' % (R_HEADS + branch.name, dest_branch)
     opts = []
     opts = []
     if auto_topic:
     if auto_topic:
       opts += ['topic=' + branch.name]
       opts += ['topic=' + branch.name]
     opts += ['t=%s' % p for p in hashtags]
     opts += ['t=%s' % p for p in hashtags]
+    opts += ['l=%s' % p for p in labels]
 
 
     opts += ['r=%s' % p for p in people[0]]
     opts += ['r=%s' % p for p in people[0]]
     opts += ['cc=%s' % p for p in people[1]]
     opts += ['cc=%s' % p for p in people[1]]
@@ -2444,8 +2440,10 @@ class Project(object):
       if os.path.exists(os.path.join(self.gitdir, 'shallow')):
       if os.path.exists(os.path.join(self.gitdir, 'shallow')):
         cmd.append('--depth=2147483647')
         cmd.append('--depth=2147483647')
 
 
-    if quiet:
+    if not verbose:
       cmd.append('--quiet')
       cmd.append('--quiet')
+    if not quiet and sys.stdout.isatty():
+      cmd.append('--progress')
     if not self.worktree:
     if not self.worktree:
       cmd.append('--update-head-ok')
       cmd.append('--update-head-ok')
     cmd.append(name)
     cmd.append(name)
@@ -2502,7 +2500,7 @@ class Project(object):
     ok = False
     ok = False
     for _i in range(2):
     for _i in range(2):
       gitcmd = GitCommand(self, cmd, bare=True, ssh_proxy=ssh_proxy,
       gitcmd = GitCommand(self, cmd, bare=True, ssh_proxy=ssh_proxy,
-                          merge_output=True, capture_stdout=not verbose)
+                          merge_output=True, capture_stdout=quiet)
       ret = gitcmd.Wait()
       ret = gitcmd.Wait()
       if ret == 0:
       if ret == 0:
         ok = True
         ok = True
@@ -2582,8 +2580,10 @@ class Project(object):
       return False
       return False
 
 
     cmd = ['fetch']
     cmd = ['fetch']
-    if quiet:
+    if not verbose:
       cmd.append('--quiet')
       cmd.append('--quiet')
+    if not quiet and sys.stdout.isatty():
+      cmd.append('--progress')
     if not self.worktree:
     if not self.worktree:
       cmd.append('--update-head-ok')
       cmd.append('--update-head-ok')
     cmd.append(bundle_dst)
     cmd.append(bundle_dst)
@@ -2643,9 +2643,8 @@ class Project(object):
         # 22: HTTP page not retrieved. The requested url was not found or
         # 22: HTTP page not retrieved. The requested url was not found or
         # returned another error with the HTTP error code being 400 or above.
         # returned another error with the HTTP error code being 400 or above.
         # This return code only appears if -f, --fail is used.
         # This return code only appears if -f, --fail is used.
-        if not quiet:
-          print("Server does not provide clone.bundle; ignoring.",
-                file=sys.stderr)
+        if verbose:
+          print('Server does not provide clone.bundle; ignoring.')
         return False
         return False
       elif curlret and not verbose and output:
       elif curlret and not verbose and output:
         print('%s' % output, file=sys.stderr)
         print('%s' % output, file=sys.stderr)
@@ -2682,8 +2681,12 @@ class Project(object):
       if self._allrefs:
       if self._allrefs:
         raise GitError('%s checkout %s ' % (self.name, rev))
         raise GitError('%s checkout %s ' % (self.name, rev))
 
 
-  def _CherryPick(self, rev):
+  def _CherryPick(self, rev, ffonly=False, record_origin=False):
     cmd = ['cherry-pick']
     cmd = ['cherry-pick']
+    if ffonly:
+      cmd.append('--ff')
+    if record_origin:
+      cmd.append('-x')
     cmd.append(rev)
     cmd.append(rev)
     cmd.append('--')
     cmd.append('--')
     if GitCommand(self, cmd).Wait() != 0:
     if GitCommand(self, cmd).Wait() != 0:
@@ -2745,10 +2748,19 @@ class Project(object):
         os.makedirs(self.objdir)
         os.makedirs(self.objdir)
         self.bare_objdir.init()
         self.bare_objdir.init()
 
 
-        # Enable per-worktree config file support if possible.  This is more a
-        # nice-to-have feature for users rather than a hard requirement.
-        if self.use_git_worktrees and git_require((2, 19, 0)):
-          self.EnableRepositoryExtension('worktreeConfig')
+        if self.use_git_worktrees:
+          # Set up the m/ space to point to the worktree-specific ref space.
+          # We'll update the worktree-specific ref space on each checkout.
+          if self.manifest.branch:
+            self.bare_git.symbolic_ref(
+                '-m', 'redirecting to worktree scope',
+                R_M + self.manifest.branch,
+                R_WORKTREE_M + self.manifest.branch)
+
+          # Enable per-worktree config file support if possible.  This is more a
+          # nice-to-have feature for users rather than a hard requirement.
+          if git_require((2, 19, 0)):
+            self.EnableRepositoryExtension('worktreeConfig')
 
 
       # If we have a separate directory to hold refs, initialize it as well.
       # If we have a separate directory to hold refs, initialize it as well.
       if self.objdir != self.gitdir:
       if self.objdir != self.gitdir:
@@ -2883,25 +2895,37 @@ class Project(object):
 
 
   def _InitMRef(self):
   def _InitMRef(self):
     if self.manifest.branch:
     if self.manifest.branch:
-      self._InitAnyMRef(R_M + self.manifest.branch)
+      if self.use_git_worktrees:
+        # We can't update this ref with git worktrees until it exists.
+        # We'll wait until the initial checkout to set it.
+        if not os.path.exists(self.worktree):
+          return
+
+        base = R_WORKTREE_M
+        active_git = self.work_git
+      else:
+        base = R_M
+        active_git = self.bare_git
+
+      self._InitAnyMRef(base + self.manifest.branch, active_git)
 
 
   def _InitMirrorHead(self):
   def _InitMirrorHead(self):
-    self._InitAnyMRef(HEAD)
+    self._InitAnyMRef(HEAD, self.bare_git)
 
 
-  def _InitAnyMRef(self, ref):
+  def _InitAnyMRef(self, ref, active_git):
     cur = self.bare_ref.symref(ref)
     cur = self.bare_ref.symref(ref)
 
 
     if self.revisionId:
     if self.revisionId:
       if cur != '' or self.bare_ref.get(ref) != self.revisionId:
       if cur != '' or self.bare_ref.get(ref) != self.revisionId:
         msg = 'manifest set to %s' % self.revisionId
         msg = 'manifest set to %s' % self.revisionId
         dst = self.revisionId + '^0'
         dst = self.revisionId + '^0'
-        self.bare_git.UpdateRef(ref, dst, message=msg, detach=True)
+        active_git.UpdateRef(ref, dst, message=msg, detach=True)
     else:
     else:
       remote = self.GetRemote(self.remote.name)
       remote = self.GetRemote(self.remote.name)
       dst = remote.ToLocal(self.revisionExpr)
       dst = remote.ToLocal(self.revisionExpr)
       if cur != dst:
       if cur != dst:
         msg = 'manifest set to %s' % self.revisionExpr
         msg = 'manifest set to %s' % self.revisionExpr
-        self.bare_git.symbolic_ref('-m', msg, ref, dst)
+        active_git.symbolic_ref('-m', msg, ref, dst)
 
 
   def _CheckDirReference(self, srcdir, destdir, share_refs):
   def _CheckDirReference(self, srcdir, destdir, share_refs):
     # Git worktrees don't use symlinks to share at all.
     # Git worktrees don't use symlinks to share at all.
@@ -3032,6 +3056,8 @@ class Project(object):
     with open(os.path.join(git_worktree_path, 'gitdir'), 'w') as fp:
     with open(os.path.join(git_worktree_path, 'gitdir'), 'w') as fp:
       print(os.path.relpath(dotgit, git_worktree_path), file=fp)
       print(os.path.relpath(dotgit, git_worktree_path), file=fp)
 
 
+    self._InitMRef()
+
   def _InitWorkTree(self, force_sync=False, submodules=False):
   def _InitWorkTree(self, force_sync=False, submodules=False):
     realdotgit = os.path.join(self.worktree, '.git')
     realdotgit = os.path.join(self.worktree, '.git')
     tmpdotgit = realdotgit + '.tmp'
     tmpdotgit = realdotgit + '.tmp'

+ 11 - 2
release/sign-tag.py

@@ -15,7 +15,14 @@
 
 
 """Helper tool for signing repo release tags correctly.
 """Helper tool for signing repo release tags correctly.
 
 
-This is intended to be run only by the official Repo release managers.
+This is intended to be run only by the official Repo release managers, but it
+could be run by people maintaining their own fork of the project.
+
+NB: Avoid new releases on off-hours.  If something goes wrong, staff/oncall need
+to be active in order to respond quickly & effectively.  Recommend sticking to:
+* Mon - Thu, 9:00 - 14:00 PT (i.e. MTV time)
+* Avoid US holidays (and large international ones if possible)
+* Follow the normal Google production freeze schedule
 """
 """
 
 
 import argparse
 import argparse
@@ -86,7 +93,9 @@ To roll back a release:
 
 
 def get_parser():
 def get_parser():
   """Get a CLI parser."""
   """Get a CLI parser."""
-  parser = argparse.ArgumentParser(description=__doc__)
+  parser = argparse.ArgumentParser(
+      description=__doc__,
+      formatter_class=argparse.RawDescriptionHelpFormatter)
   parser.add_argument('-n', '--dry-run',
   parser.add_argument('-n', '--dry-run',
                       dest='dryrun', action='store_true',
                       dest='dryrun', action='store_true',
                       help='show everything that would be done')
                       help='show everything that would be done')

+ 136 - 57
repo

@@ -135,7 +135,7 @@ if not REPO_REV:
   REPO_REV = 'qs'
   REPO_REV = 'qs'
 
 
 # increment this whenever we make important changes to this script
 # increment this whenever we make important changes to this script
-VERSION = (2, 4)
+VERSION = (2, 5)
 
 
 # increment this if the MAINTAINER_KEYS block is modified
 # increment this if the MAINTAINER_KEYS block is modified
 KEYRING_VERSION = (2, 3)
 KEYRING_VERSION = (2, 3)
@@ -253,8 +253,6 @@ else:
 home_dot_repo = os.path.expanduser('~/.repoconfig')
 home_dot_repo = os.path.expanduser('~/.repoconfig')
 gpg_dir = os.path.join(home_dot_repo, 'gnupg')
 gpg_dir = os.path.join(home_dot_repo, 'gnupg')
 
 
-extra_args = []
-
 
 
 def GetParser(gitc_init=False):
 def GetParser(gitc_init=False):
   """Setup the CLI parser."""
   """Setup the CLI parser."""
@@ -332,8 +330,10 @@ def GetParser(gitc_init=False):
   group = parser.add_option_group('repo Version options')
   group = parser.add_option_group('repo Version options')
   group.add_option('--repo-url', metavar='URL',
   group.add_option('--repo-url', metavar='URL',
                    help='repo repository location ($REPO_URL)')
                    help='repo repository location ($REPO_URL)')
-  group.add_option('--repo-branch', metavar='REVISION',
+  group.add_option('--repo-rev', metavar='REV',
                    help='repo branch or revision ($REPO_REV)')
                    help='repo branch or revision ($REPO_REV)')
+  group.add_option('--repo-branch', dest='repo_rev',
+                   help=optparse.SUPPRESS_HELP)
   group.add_option('--no-repo-verify',
   group.add_option('--no-repo-verify',
                    dest='repo_verify', default=True, action='store_false',
                    dest='repo_verify', default=True, action='store_false',
                    help='do not verify repo source code')
                    help='do not verify repo source code')
@@ -465,6 +465,34 @@ class CloneFailure(Exception):
   """
   """
 
 
 
 
+def check_repo_verify(repo_verify, quiet=False):
+  """Check the --repo-verify state."""
+  if not repo_verify:
+    print('repo: warning: verification of repo code has been disabled;\n'
+          'repo will not be able to verify the integrity of itself.\n',
+          file=sys.stderr)
+    return False
+
+  if NeedSetupGnuPG():
+    return SetupGnuPG(quiet)
+
+  return True
+
+
+def check_repo_rev(dst, rev, repo_verify=True, quiet=False):
+  """Check that |rev| is valid."""
+  do_verify = check_repo_verify(repo_verify, quiet=quiet)
+  remote_ref, local_rev = resolve_repo_rev(dst, rev)
+  if not quiet and not remote_ref.startswith('refs/heads/'):
+    print('warning: repo is not tracking a remote branch, so it will not '
+          'receive updates', file=sys.stderr)
+  if do_verify:
+    rev = verify_rev(dst, remote_ref, local_rev, quiet)
+  else:
+    rev = local_rev
+  return (remote_ref, rev)
+
+
 def _Init(args, gitc_init=False):
 def _Init(args, gitc_init=False):
   """Installs repo by cloning it over the network.
   """Installs repo by cloning it over the network.
   """
   """
@@ -476,21 +504,8 @@ def _Init(args, gitc_init=False):
   opt.quiet = opt.output_mode is False
   opt.quiet = opt.output_mode is False
   opt.verbose = opt.output_mode is True
   opt.verbose = opt.output_mode is True
 
 
-  url = opt.repo_url
-  if not url:
-    url = REPO_URL
-    extra_args.append('--repo-url=%s' % url)
-
-  branch = opt.repo_branch
-  if not branch:
-    branch = REPO_REV
-    extra_args.append('--repo-branch=%s' % branch)
-
-  if branch.startswith('refs/heads/'):
-    branch = branch[len('refs/heads/'):]
-  if branch.startswith('refs/'):
-    print("fatal: invalid branch name '%s'" % branch, file=sys.stderr)
-    raise CloneFailure()
+  url = opt.repo_url or REPO_URL
+  rev = opt.repo_rev or REPO_REV
 
 
   try:
   try:
     if gitc_init:
     if gitc_init:
@@ -525,25 +540,13 @@ def _Init(args, gitc_init=False):
 
 
   _CheckGitVersion()
   _CheckGitVersion()
   try:
   try:
-    if not opt.repo_verify:
-      do_verify = False
-    else:
-      if NeedSetupGnuPG():
-        do_verify = SetupGnuPG(opt.quiet)
-      else:
-        do_verify = True
-
     if not opt.quiet:
     if not opt.quiet:
       print('Downloading Repo source from', url)
       print('Downloading Repo source from', url)
     dst = os.path.abspath(os.path.join(repodir, S_repo))
     dst = os.path.abspath(os.path.join(repodir, S_repo))
     _Clone(url, dst, opt.clone_bundle, opt.quiet, opt.verbose)
     _Clone(url, dst, opt.clone_bundle, opt.quiet, opt.verbose)
 
 
-    if do_verify:
-      rev = _Verify(dst, branch, opt.quiet)
-    else:
-      rev = 'refs/remotes/origin/%s^0' % branch
-
-    _Checkout(dst, branch, rev, opt.quiet)
+    remote_ref, rev = check_repo_rev(dst, rev, opt.repo_verify, quiet=opt.quiet)
+    _Checkout(dst, remote_ref, rev, opt.quiet)
 
 
     if not os.path.isfile(os.path.join(dst, 'repo')):
     if not os.path.isfile(os.path.join(dst, 'repo')):
       print("warning: '%s' does not look like a git-repo repository, is "
       print("warning: '%s' does not look like a git-repo repository, is "
@@ -608,7 +611,8 @@ def _CheckGitVersion():
 
 
   if ver_act < MIN_GIT_VERSION:
   if ver_act < MIN_GIT_VERSION:
     need = '.'.join(map(str, MIN_GIT_VERSION))
     need = '.'.join(map(str, MIN_GIT_VERSION))
-    print('fatal: git %s or later required' % need, file=sys.stderr)
+    print('fatal: git %s or later required; found %s' % (need, ver_act.full),
+          file=sys.stderr)
     raise CloneFailure()
     raise CloneFailure()
 
 
 
 
@@ -757,15 +761,17 @@ def _InitHttp():
 
 
 def _Fetch(url, cwd, src, quiet, verbose):
 def _Fetch(url, cwd, src, quiet, verbose):
   cmd = ['fetch']
   cmd = ['fetch']
-  if quiet:
+  if not verbose:
     cmd.append('--quiet')
     cmd.append('--quiet')
+  err = None
+  if not quiet and sys.stdout.isatty():
+    cmd.append('--progress')
+  elif not verbose:
     err = subprocess.PIPE
     err = subprocess.PIPE
-  else:
-    err = None
   cmd.append(src)
   cmd.append(src)
   cmd.append('+refs/heads/*:refs/remotes/origin/*')
   cmd.append('+refs/heads/*:refs/remotes/origin/*')
   cmd.append('+refs/tags/*:refs/tags/*')
   cmd.append('+refs/tags/*:refs/tags/*')
-  run_git(*cmd, stderr=err, cwd=cwd)
+  run_git(*cmd, stderr=err, capture_output=False, cwd=cwd)
 
 
 
 
 def _DownloadBundle(url, cwd, quiet, verbose):
 def _DownloadBundle(url, cwd, quiet, verbose):
@@ -848,23 +854,83 @@ def _Clone(url, cwd, clone_bundle, quiet, verbose):
   _Fetch(url, cwd, 'origin', quiet, verbose)
   _Fetch(url, cwd, 'origin', quiet, verbose)
 
 
 
 
-def _Verify(cwd, branch, quiet):
-  """Verify the branch has been signed by a tag.
+def resolve_repo_rev(cwd, committish):
+  """Figure out what REPO_REV represents.
+
+  We support:
+  * refs/heads/xxx: Branch.
+  * refs/tags/xxx: Tag.
+  * xxx: Branch or tag or commit.
+
+  Args:
+    cwd: The git checkout to run in.
+    committish: The REPO_REV argument to resolve.
+
+  Returns:
+    A tuple of (remote ref, commit) as makes sense for the committish.
+    For branches, this will look like ('refs/heads/stable', <revision>).
+    For tags, this will look like ('refs/tags/v1.0', <revision>).
+    For commits, this will be (<revision>, <revision>).
   """
   """
-  try:
-    ret = run_git('describe', 'origin/%s' % branch, cwd=cwd)
-    cur = ret.stdout.strip()
-  except CloneFailure:
-    print("fatal: branch '%s' has not been signed" % branch, file=sys.stderr)
-    raise
+  def resolve(committish):
+    ret = run_git('rev-parse', '--verify', '%s^{commit}' % (committish,),
+                  cwd=cwd, check=False)
+    return None if ret.returncode else ret.stdout.strip()
+
+  # An explicit branch.
+  if committish.startswith('refs/heads/'):
+    remote_ref = committish
+    committish = committish[len('refs/heads/'):]
+    rev = resolve('refs/remotes/origin/%s' % committish)
+    if rev is None:
+      print('repo: error: unknown branch "%s"' % (committish,),
+            file=sys.stderr)
+      raise CloneFailure()
+    return (remote_ref, rev)
+
+  # An explicit tag.
+  if committish.startswith('refs/tags/'):
+    remote_ref = committish
+    committish = committish[len('refs/tags/'):]
+    rev = resolve(remote_ref)
+    if rev is None:
+      print('repo: error: unknown tag "%s"' % (committish,),
+            file=sys.stderr)
+      raise CloneFailure()
+    return (remote_ref, rev)
+
+  # See if it's a short branch name.
+  rev = resolve('refs/remotes/origin/%s' % committish)
+  if rev:
+    return ('refs/heads/%s' % (committish,), rev)
+
+  # See if it's a tag.
+  rev = resolve('refs/tags/%s' % committish)
+  if rev:
+    return ('refs/tags/%s' % (committish,), rev)
+
+  # See if it's a commit.
+  rev = resolve(committish)
+  if rev and rev.lower().startswith(committish.lower()):
+    return (rev, rev)
+
+  # Give up!
+  print('repo: error: unable to resolve "%s"' % (committish,), file=sys.stderr)
+  raise CloneFailure()
+
+
+def verify_rev(cwd, remote_ref, rev, quiet):
+  """Verify the commit has been signed by a tag."""
+  ret = run_git('describe', rev, cwd=cwd)
+  cur = ret.stdout.strip()
 
 
   m = re.compile(r'^(.*)-[0-9]{1,}-g[0-9a-f]{1,}$').match(cur)
   m = re.compile(r'^(.*)-[0-9]{1,}-g[0-9a-f]{1,}$').match(cur)
   if m:
   if m:
     cur = m.group(1)
     cur = m.group(1)
     if not quiet:
     if not quiet:
       print(file=sys.stderr)
       print(file=sys.stderr)
-      print("info: Ignoring branch '%s'; using tagged release '%s'"
-            % (branch, cur), file=sys.stderr)
+      print("warning: '%s' is not signed; falling back to signed release '%s'"
+            % (remote_ref, cur), file=sys.stderr)
       print(file=sys.stderr)
       print(file=sys.stderr)
 
 
   env = os.environ.copy()
   env = os.environ.copy()
@@ -873,13 +939,13 @@ def _Verify(cwd, branch, quiet):
   return '%s^0' % cur
   return '%s^0' % cur
 
 
 
 
-def _Checkout(cwd, branch, rev, quiet):
+def _Checkout(cwd, remote_ref, rev, quiet):
   """Checkout an upstream branch into the repository and track it.
   """Checkout an upstream branch into the repository and track it.
   """
   """
   run_git('update-ref', 'refs/heads/default', rev, cwd=cwd)
   run_git('update-ref', 'refs/heads/default', rev, cwd=cwd)
 
 
   _SetConfig(cwd, 'branch.default.remote', 'origin')
   _SetConfig(cwd, 'branch.default.remote', 'origin')
-  _SetConfig(cwd, 'branch.default.merge', 'refs/heads/%s' % branch)
+  _SetConfig(cwd, 'branch.default.merge', remote_ref)
 
 
   run_git('symbolic-ref', 'HEAD', 'refs/heads/default', cwd=cwd)
   run_git('symbolic-ref', 'HEAD', 'refs/heads/default', cwd=cwd)
 
 
@@ -995,6 +1061,14 @@ def _Version():
   print('       (from %s)' % (__file__,))
   print('       (from %s)' % (__file__,))
   print('git %s' % (ParseGitVersion().full,))
   print('git %s' % (ParseGitVersion().full,))
   print('Python %s' % sys.version)
   print('Python %s' % sys.version)
+  uname = platform.uname()
+  if sys.version_info.major < 3:
+    # Python 3 returns a named tuple, but Python 2 is simpler.
+    print(uname)
+  else:
+    print('OS %s %s (%s)' % (uname.system, uname.release, uname.version))
+    print('CPU %s (%s)' %
+          (uname.machine, uname.processor if uname.processor else 'unknown'))
   sys.exit(0)
   sys.exit(0)
 
 
 
 
@@ -1030,12 +1104,18 @@ def _SetDefaultsTo(gitdir):
   global REPO_REV
   global REPO_REV
 
 
   REPO_URL = gitdir
   REPO_URL = gitdir
-  try:
-    ret = run_git('--git-dir=%s' % gitdir, 'symbolic-ref', 'HEAD')
-    REPO_REV = ret.stdout.strip()
-  except CloneFailure:
-    print('fatal: %s has no current branch' % gitdir, file=sys.stderr)
-    sys.exit(1)
+  ret = run_git('--git-dir=%s' % gitdir, 'symbolic-ref', 'HEAD', check=False)
+  if ret.returncode:
+    # If we're not tracking a branch (bisect/etc...), then fall back to commit.
+    print('repo: warning: %s has no current branch; using HEAD' % gitdir,
+          file=sys.stderr)
+    try:
+      ret = run_git('rev-parse', 'HEAD', cwd=gitdir)
+    except CloneFailure:
+      print('fatal: %s has invalid HEAD' % gitdir, file=sys.stderr)
+      sys.exit(1)
+
+  REPO_REV = ret.stdout.strip()
 
 
 
 
 def main(orig_args):
 def main(orig_args):
@@ -1098,7 +1178,6 @@ def main(orig_args):
         '--wrapper-path=%s' % wrapper_path,
         '--wrapper-path=%s' % wrapper_path,
         '--']
         '--']
   me.extend(orig_args)
   me.extend(orig_args)
-  me.extend(extra_args)
   exec_command(me)
   exec_command(me)
   print("fatal: unable to start %s" % repo_main, file=sys.stderr)
   print("fatal: unable to start %s" % repo_main, file=sys.stderr)
   sys.exit(148)
   sys.exit(148)

+ 4 - 3
subcmds/__init__.py

@@ -16,6 +16,7 @@
 
 
 import os
 import os
 
 
+# A mapping of the subcommand name to the class that implements it.
 all_commands = {}
 all_commands = {}
 
 
 my_dir = os.path.dirname(__file__)
 my_dir = os.path.dirname(__file__)
@@ -37,7 +38,7 @@ for py in os.listdir(my_dir):
                      ['%s' % name])
                      ['%s' % name])
     mod = getattr(mod, name)
     mod = getattr(mod, name)
     try:
     try:
-      cmd = getattr(mod, clsn)()
+      cmd = getattr(mod, clsn)
     except AttributeError:
     except AttributeError:
       raise SyntaxError('%s/%s does not define class %s' % (
       raise SyntaxError('%s/%s does not define class %s' % (
           __name__, py, clsn))
           __name__, py, clsn))
@@ -46,5 +47,5 @@ for py in os.listdir(my_dir):
     cmd.NAME = name
     cmd.NAME = name
     all_commands[name] = cmd
     all_commands[name] = cmd
 
 
-if 'help' in all_commands:
-  all_commands['help'].commands = all_commands
+# Add 'branch' as an alias for 'branches'.
+all_commands['branch'] = all_commands['branches']

+ 3 - 3
subcmds/diffmanifests.py

@@ -79,7 +79,7 @@ synced and their revisions won't be found.
                  metavar='<FORMAT>',
                  metavar='<FORMAT>',
                  help='print the log using a custom git pretty format string')
                  help='print the log using a custom git pretty format string')
 
 
-  def _printRawDiff(self, diff):
+  def _printRawDiff(self, diff, pretty_format=None):
     for project in diff['added']:
     for project in diff['added']:
       self.printText("A %s %s" % (project.relpath, project.revisionExpr))
       self.printText("A %s %s" % (project.relpath, project.revisionExpr))
       self.out.nl()
       self.out.nl()
@@ -92,7 +92,7 @@ synced and their revisions won't be found.
       self.printText("C %s %s %s" % (project.relpath, project.revisionExpr,
       self.printText("C %s %s %s" % (project.relpath, project.revisionExpr,
                                      otherProject.revisionExpr))
                                      otherProject.revisionExpr))
       self.out.nl()
       self.out.nl()
-      self._printLogs(project, otherProject, raw=True, color=False)
+      self._printLogs(project, otherProject, raw=True, color=False, pretty_format=pretty_format)
 
 
     for project, otherProject in diff['unreachable']:
     for project, otherProject in diff['unreachable']:
       self.printText("U %s %s %s" % (project.relpath, project.revisionExpr,
       self.printText("U %s %s %s" % (project.relpath, project.revisionExpr,
@@ -203,6 +203,6 @@ synced and their revisions won't be found.
 
 
     diff = manifest1.projectsDiff(manifest2)
     diff = manifest1.projectsDiff(manifest2)
     if opt.raw:
     if opt.raw:
-      self._printRawDiff(diff)
+      self._printRawDiff(diff, pretty_format=opt.pretty_format)
     else:
     else:
       self._printDiff(diff, color=opt.color, pretty_format=opt.pretty_format)
       self._printDiff(diff, color=opt.color, pretty_format=opt.pretty_format)

+ 41 - 10
subcmds/download.py

@@ -37,9 +37,13 @@ If no project is specified try to use current directory as a project.
 """
 """
 
 
   def _Options(self, p):
   def _Options(self, p):
+    p.add_option('-b', '--branch',
+                 help='create a new branch first')
     p.add_option('-c', '--cherry-pick',
     p.add_option('-c', '--cherry-pick',
                  dest='cherrypick', action='store_true',
                  dest='cherrypick', action='store_true',
                  help="cherry-pick instead of checkout")
                  help="cherry-pick instead of checkout")
+    p.add_option('-x', '--record-origin', action='store_true',
+                 help='pass -x when cherry-picking')
     p.add_option('-r', '--revert',
     p.add_option('-r', '--revert',
                  dest='revert', action='store_true',
                  dest='revert', action='store_true',
                  help="revert instead of checkout")
                  help="revert instead of checkout")
@@ -78,6 +82,14 @@ If no project is specified try to use current directory as a project.
         project = self.GetProjects([a])[0]
         project = self.GetProjects([a])[0]
     return to_get
     return to_get
 
 
+  def ValidateOptions(self, opt, args):
+    if opt.record_origin:
+      if not opt.cherrypick:
+        self.OptionParser.error('-x only makes sense with --cherry-pick')
+
+      if opt.ffonly:
+        self.OptionParser.error('-x and --ff are mutually exclusive options')
+
   def Execute(self, opt, args):
   def Execute(self, opt, args):
     for project, change_id, ps_id in self._ParseChangeIds(args):
     for project, change_id, ps_id in self._ParseChangeIds(args):
       dl = project.DownloadPatchSet(change_id, ps_id)
       dl = project.DownloadPatchSet(change_id, ps_id)
@@ -99,17 +111,36 @@ If no project is specified try to use current directory as a project.
               file=sys.stderr)
               file=sys.stderr)
         for c in dl.commits:
         for c in dl.commits:
           print('  %s' % (c), file=sys.stderr)
           print('  %s' % (c), file=sys.stderr)
-      if opt.cherrypick:
-        try:
-          project._CherryPick(dl.commit)
-        except GitError:
-          print('[%s] Could not complete the cherry-pick of %s'
-                % (project.name, dl.commit), file=sys.stderr)
-          sys.exit(1)
 
 
+      if opt.cherrypick:
+        mode = 'cherry-pick'
       elif opt.revert:
       elif opt.revert:
-        project._Revert(dl.commit)
+        mode = 'revert'
       elif opt.ffonly:
       elif opt.ffonly:
-        project._FastForward(dl.commit, ffonly=True)
+        mode = 'fast-forward merge'
       else:
       else:
-        project._Checkout(dl.commit)
+        mode = 'checkout'
+
+      # We'll combine the branch+checkout operation, but all the rest need a
+      # dedicated branch start.
+      if opt.branch and mode != 'checkout':
+        project.StartBranch(opt.branch)
+
+      try:
+        if opt.cherrypick:
+          project._CherryPick(dl.commit, ffonly=opt.ffonly,
+                              record_origin=opt.record_origin)
+        elif opt.revert:
+          project._Revert(dl.commit)
+        elif opt.ffonly:
+          project._FastForward(dl.commit, ffonly=True)
+        else:
+          if opt.branch:
+            project.StartBranch(opt.branch, revision=dl.commit)
+          else:
+            project._Checkout(dl.commit)
+
+      except GitError:
+        print('[%s] Could not complete the %s of %s'
+              % (project.name, mode, dl.commit), file=sys.stderr)
+        sys.exit(1)

+ 7 - 6
subcmds/help.py

@@ -19,6 +19,7 @@ import re
 import sys
 import sys
 from formatter import AbstractFormatter, DumbWriter
 from formatter import AbstractFormatter, DumbWriter
 
 
+from subcmds import all_commands
 from color import Coloring
 from color import Coloring
 from command import PagedCommand, MirrorSafeCommand, GitcAvailableCommand, GitcClientCommand
 from command import PagedCommand, MirrorSafeCommand, GitcAvailableCommand, GitcClientCommand
 import gitc_utils
 import gitc_utils
@@ -42,7 +43,7 @@ Displays detailed usage information about a command.
     fmt = '  %%-%ds  %%s' % maxlen
     fmt = '  %%-%ds  %%s' % maxlen
 
 
     for name in commandNames:
     for name in commandNames:
-      command = self.commands[name]
+      command = all_commands[name]()
       try:
       try:
         summary = command.helpSummary.strip()
         summary = command.helpSummary.strip()
       except AttributeError:
       except AttributeError:
@@ -52,7 +53,7 @@ Displays detailed usage information about a command.
   def _PrintAllCommands(self):
   def _PrintAllCommands(self):
     print('usage: repo COMMAND [ARGS]')
     print('usage: repo COMMAND [ARGS]')
     print('The complete list of recognized repo commands are:')
     print('The complete list of recognized repo commands are:')
-    commandNames = list(sorted(self.commands))
+    commandNames = list(sorted(all_commands))
     self._PrintCommands(commandNames)
     self._PrintCommands(commandNames)
     print("See 'repo help <command>' for more information on a "
     print("See 'repo help <command>' for more information on a "
           'specific command.')
           'specific command.')
@@ -73,7 +74,7 @@ Displays detailed usage information about a command.
       return False
       return False
 
 
     commandNames = list(sorted([name
     commandNames = list(sorted([name
-                                for name, command in self.commands.items()
+                                for name, command in all_commands.items()
                                 if command.common and gitc_supported(command)]))
                                 if command.common and gitc_supported(command)]))
     self._PrintCommands(commandNames)
     self._PrintCommands(commandNames)
 
 
@@ -132,8 +133,8 @@ Displays detailed usage information about a command.
     out._PrintSection('Description', 'helpDescription')
     out._PrintSection('Description', 'helpDescription')
 
 
   def _PrintAllCommandHelp(self):
   def _PrintAllCommandHelp(self):
-    for name in sorted(self.commands):
-      cmd = self.commands[name]
+    for name in sorted(all_commands):
+      cmd = all_commands[name]()
       cmd.manifest = self.manifest
       cmd.manifest = self.manifest
       self._PrintCommandHelp(cmd, header_prefix='[%s] ' % (name,))
       self._PrintCommandHelp(cmd, header_prefix='[%s] ' % (name,))
 
 
@@ -158,7 +159,7 @@ Displays detailed usage information about a command.
       name = args[0]
       name = args[0]
 
 
       try:
       try:
-        cmd = self.commands[name]
+        cmd = all_commands[name]()
       except KeyError:
       except KeyError:
         print("repo: '%s' is not a repo command." % name, file=sys.stderr)
         print("repo: '%s' is not a repo command." % name, file=sys.stderr)
         sys.exit(1)
         sys.exit(1)

+ 23 - 3
subcmds/init.py

@@ -38,6 +38,7 @@ from project import SyncBuffer
 from git_config import GitConfig
 from git_config import GitConfig
 from git_command import git_require, MIN_GIT_VERSION_SOFT, MIN_GIT_VERSION_HARD
 from git_command import git_require, MIN_GIT_VERSION_SOFT, MIN_GIT_VERSION_HARD
 import platform_utils
 import platform_utils
+from wrapper import Wrapper
 
 
 
 
 class Init(InteractiveCommand, MirrorSafeCommand):
 class Init(InteractiveCommand, MirrorSafeCommand):
@@ -166,9 +167,10 @@ to update the working directory files.
     g.add_option('--repo-url',
     g.add_option('--repo-url',
                  dest='repo_url',
                  dest='repo_url',
                  help='repo repository location', metavar='URL')
                  help='repo repository location', metavar='URL')
-    g.add_option('--repo-branch',
-                 dest='repo_branch',
-                 help='repo branch or revision', metavar='REVISION')
+    g.add_option('--repo-rev', metavar='REV',
+                 help='repo branch or revision')
+    g.add_option('--repo-branch', dest='repo_rev',
+                 help=optparse.SUPPRESS_HELP)
     g.add_option('--no-repo-verify',
     g.add_option('--no-repo-verify',
                  dest='repo_verify', default=True, action='store_false',
                  dest='repo_verify', default=True, action='store_false',
                  help='do not verify repo source code')
                  help='do not verify repo source code')
@@ -490,6 +492,24 @@ to update the working directory files.
     opt.quiet = opt.output_mode is False
     opt.quiet = opt.output_mode is False
     opt.verbose = opt.output_mode is True
     opt.verbose = opt.output_mode is True
 
 
+    rp = self.manifest.repoProject
+
+    # Handle new --repo-url requests.
+    if opt.repo_url:
+      remote = rp.GetRemote('origin')
+      remote.url = opt.repo_url
+      remote.Save()
+
+    # Handle new --repo-rev requests.
+    if opt.repo_rev:
+      wrapper = Wrapper()
+      remote_ref, rev = wrapper.check_repo_rev(
+          rp.gitdir, opt.repo_rev, repo_verify=opt.repo_verify, quiet=opt.quiet)
+      branch = rp.GetBranch('default')
+      branch.merge = remote_ref
+      rp.work_git.update_ref('refs/heads/default', rev)
+      branch.Save()
+
     if opt.worktree:
     if opt.worktree:
       # Older versions of git supported worktree, but had dangerous gc bugs.
       # Older versions of git supported worktree, but had dangerous gc bugs.
       git_require((2, 15, 0), fail=True, msg='git gc worktree corruption')
       git_require((2, 15, 0), fail=True, msg='git gc worktree corruption')

+ 10 - 3
subcmds/sync.py

@@ -729,12 +729,12 @@ later is required to fix a server side protocol bug.
           branch = branch[len(R_HEADS):]
           branch = branch[len(R_HEADS):]
 
 
         if 'SYNC_TARGET' in os.environ:
         if 'SYNC_TARGET' in os.environ:
-          target = os.environ('SYNC_TARGET')
+          target = os.environ['SYNC_TARGET']
           [success, manifest_str] = server.GetApprovedManifest(branch, target)
           [success, manifest_str] = server.GetApprovedManifest(branch, target)
         elif ('TARGET_PRODUCT' in os.environ and
         elif ('TARGET_PRODUCT' in os.environ and
               'TARGET_BUILD_VARIANT' in os.environ):
               'TARGET_BUILD_VARIANT' in os.environ):
-          target = '%s-%s' % (os.environ('TARGET_PRODUCT'),
-                              os.environ('TARGET_BUILD_VARIANT'))
+          target = '%s-%s' % (os.environ['TARGET_PRODUCT'],
+                              os.environ['TARGET_BUILD_VARIANT'])
           [success, manifest_str] = server.GetApprovedManifest(branch, target)
           [success, manifest_str] = server.GetApprovedManifest(branch, target)
         else:
         else:
           [success, manifest_str] = server.GetApprovedManifest(branch)
           [success, manifest_str] = server.GetApprovedManifest(branch)
@@ -845,6 +845,13 @@ later is required to fix a server side protocol bug.
 
 
     rp = self.manifest.repoProject
     rp = self.manifest.repoProject
     rp.PreSync()
     rp.PreSync()
+    cb = rp.CurrentBranch
+    if cb:
+      base = rp.GetBranch(cb).merge
+      if not base or not base.startswith('refs/heads/'):
+        print('warning: repo is not tracking a remote branch, so it will not '
+              'receive updates; run `repo init --repo-rev=stable` to fix.',
+              file=sys.stderr)
 
 
     mp = self.manifest.manifestProject
     mp = self.manifest.manifestProject
     mp.PreSync()
     mp.PreSync()

+ 47 - 16
subcmds/upload.py

@@ -134,7 +134,18 @@ review.URL.uploadhashtags:
 
 
 To add hashtags whenever uploading a commit, you can set a per-project
 To add hashtags whenever uploading a commit, you can set a per-project
 or global Git option to do so. The value of review.URL.uploadhashtags
 or global Git option to do so. The value of review.URL.uploadhashtags
-will be used as comma delimited hashtags like the --hashtags option.
+will be used as comma delimited hashtags like the --hashtag option.
+
+review.URL.uploadlabels:
+
+To add labels whenever uploading a commit, you can set a per-project
+or global Git option to do so. The value of review.URL.uploadlabels
+will be used as comma delimited labels like the --label option.
+
+review.URL.uploadnotify:
+
+Control e-mail notifications when uploading.
+https://gerrit-review.googlesource.com/Documentation/user-upload.html#notify
 
 
 # References
 # References
 
 
@@ -152,6 +163,9 @@ Gerrit Code Review:  https://www.gerritcodereview.com/
     p.add_option('--hashtag-branch', '--htb',
     p.add_option('--hashtag-branch', '--htb',
                  action='store_true',
                  action='store_true',
                  help='Add local branch name as a hashtag.')
                  help='Add local branch name as a hashtag.')
+    p.add_option('-l', '--label',
+                 dest='labels', action='append', default=[],
+                 help='Add a label when uploading.')
     p.add_option('--re', '--reviewers',
     p.add_option('--re', '--reviewers',
                  type='string', action='append', dest='reviewers',
                  type='string', action='append', dest='reviewers',
                  help='Request reviews from these people.')
                  help='Request reviews from these people.')
@@ -164,9 +178,6 @@ Gerrit Code Review:  https://www.gerritcodereview.com/
     p.add_option('--cbr', '--current-branch',
     p.add_option('--cbr', '--current-branch',
                  dest='current_branch', action='store_true',
                  dest='current_branch', action='store_true',
                  help='Upload current git branch.')
                  help='Upload current git branch.')
-    p.add_option('-d', '--draft',
-                 action='store_true', dest='draft', default=False,
-                 help='If specified, upload as a draft.')
     p.add_option('--ne', '--no-emails',
     p.add_option('--ne', '--no-emails',
                  action='store_false', dest='notify', default=True,
                  action='store_false', dest='notify', default=True,
                  help='If specified, do not send emails on upload.')
                  help='If specified, do not send emails on upload.')
@@ -238,7 +249,7 @@ Gerrit Code Review:  https://www.gerritcodereview.com/
 
 
       destination = opt.dest_branch or project.dest_branch or project.revisionExpr
       destination = opt.dest_branch or project.dest_branch or project.revisionExpr
       print('Upload project %s/ to remote branch %s%s:' %
       print('Upload project %s/ to remote branch %s%s:' %
-            (project.relpath, destination, ' (draft)' if opt.draft else ''))
+            (project.relpath, destination, ' (private)' if opt.private else ''))
       print('  branch %s (%2d commit%s, %s):' % (
       print('  branch %s (%2d commit%s, %s):' % (
           name,
           name,
           len(commit_list),
           len(commit_list),
@@ -410,22 +421,42 @@ Gerrit Code Review:  https://www.gerritcodereview.com/
           key = 'review.%s.uploadtopic' % branch.project.remote.review
           key = 'review.%s.uploadtopic' % branch.project.remote.review
           opt.auto_topic = branch.project.config.GetBoolean(key)
           opt.auto_topic = branch.project.config.GetBoolean(key)
 
 
-        # Check if hashtags should be included.
-        def _ExpandHashtag(value):
-          """Split |value| up into comma delimited tags."""
+        def _ExpandCommaList(value):
+          """Split |value| up into comma delimited entries."""
           if not value:
           if not value:
             return
             return
-          for tag in value.split(','):
-            tag = tag.strip()
-            if tag:
-              yield tag
+          for ret in value.split(','):
+            ret = ret.strip()
+            if ret:
+              yield ret
+
+        # Check if hashtags should be included.
         key = 'review.%s.uploadhashtags' % branch.project.remote.review
         key = 'review.%s.uploadhashtags' % branch.project.remote.review
-        hashtags = set(_ExpandHashtag(branch.project.config.GetString(key)))
+        hashtags = set(_ExpandCommaList(branch.project.config.GetString(key)))
         for tag in opt.hashtags:
         for tag in opt.hashtags:
-          hashtags.update(_ExpandHashtag(tag))
+          hashtags.update(_ExpandCommaList(tag))
         if opt.hashtag_branch:
         if opt.hashtag_branch:
           hashtags.add(branch.name)
           hashtags.add(branch.name)
 
 
+        # Check if labels should be included.
+        key = 'review.%s.uploadlabels' % branch.project.remote.review
+        labels = set(_ExpandCommaList(branch.project.config.GetString(key)))
+        for label in opt.labels:
+          labels.update(_ExpandCommaList(label))
+        # Basic sanity check on label syntax.
+        for label in labels:
+          if not re.match(r'^.+[+-][0-9]+$', label):
+            print('repo: error: invalid label syntax "%s": labels use forms '
+                  'like CodeReview+1 or Verified-1' % (label,), file=sys.stderr)
+            sys.exit(1)
+
+        # Handle e-mail notifications.
+        if opt.notify is False:
+          notify = 'NONE'
+        else:
+          key = 'review.%s.uploadnotify' % branch.project.remote.review
+          notify = branch.project.config.GetString(key)
+
         destination = opt.dest_branch or branch.project.dest_branch
         destination = opt.dest_branch or branch.project.dest_branch
 
 
         # Make sure our local branch is not setup to track a different remote branch
         # Make sure our local branch is not setup to track a different remote branch
@@ -445,9 +476,9 @@ Gerrit Code Review:  https://www.gerritcodereview.com/
                                dryrun=opt.dryrun,
                                dryrun=opt.dryrun,
                                auto_topic=opt.auto_topic,
                                auto_topic=opt.auto_topic,
                                hashtags=hashtags,
                                hashtags=hashtags,
-                               draft=opt.draft,
+                               labels=labels,
                                private=opt.private,
                                private=opt.private,
-                               notify=None if opt.notify else 'NONE',
+                               notify=notify,
                                wip=opt.wip,
                                wip=opt.wip,
                                dest_branch=destination,
                                dest_branch=destination,
                                validate_certs=opt.validate_certs,
                                validate_certs=opt.validate_certs,

+ 15 - 3
subcmds/version.py

@@ -15,7 +15,10 @@
 # limitations under the License.
 # limitations under the License.
 
 
 from __future__ import print_function
 from __future__ import print_function
+
+import platform
 import sys
 import sys
+
 from command import Command, MirrorSafeCommand
 from command import Command, MirrorSafeCommand
 from git_command import git, RepoSourceVersion, user_agent
 from git_command import git, RepoSourceVersion, user_agent
 from git_refs import HEAD
 from git_refs import HEAD
@@ -40,10 +43,11 @@ class Version(Command, MirrorSafeCommand):
     rp_ver = rp.bare_git.describe(HEAD)
     rp_ver = rp.bare_git.describe(HEAD)
     print('repo version %s' % rp_ver)
     print('repo version %s' % rp_ver)
     print('       (from %s)' % rem.url)
     print('       (from %s)' % rem.url)
+    print('       (%s)' % rp.bare_git.log('-1', '--format=%cD', HEAD))
 
 
-    if Version.wrapper_path is not None:
-      print('repo launcher version %s' % Version.wrapper_version)
-      print('       (from %s)' % Version.wrapper_path)
+    if self.wrapper_path is not None:
+      print('repo launcher version %s' % self.wrapper_version)
+      print('       (from %s)' % self.wrapper_path)
 
 
       if src_ver != rp_ver:
       if src_ver != rp_ver:
         print('       (currently at %s)' % src_ver)
         print('       (currently at %s)' % src_ver)
@@ -52,3 +56,11 @@ class Version(Command, MirrorSafeCommand):
     print('git %s' % git.version_tuple().full)
     print('git %s' % git.version_tuple().full)
     print('git User-Agent %s' % user_agent.git)
     print('git User-Agent %s' % user_agent.git)
     print('Python %s' % sys.version)
     print('Python %s' % sys.version)
+    uname = platform.uname()
+    if sys.version_info.major < 3:
+      # Python 3 returns a named tuple, but Python 2 is simpler.
+      print(uname)
+    else:
+      print('OS %s %s (%s)' % (uname.system, uname.release, uname.version))
+      print('CPU %s (%s)' %
+            (uname.machine, uname.processor if uname.processor else 'unknown'))

+ 57 - 0
tests/test_manifest_xml.py

@@ -20,6 +20,7 @@ from __future__ import print_function
 
 
 import os
 import os
 import unittest
 import unittest
+import xml.dom.minidom
 
 
 import error
 import error
 import manifest_xml
 import manifest_xml
@@ -89,3 +90,59 @@ class ManifestValidateFilePaths(unittest.TestCase):
           error.ManifestInvalidPathError, self.check_both, path, 'a')
           error.ManifestInvalidPathError, self.check_both, path, 'a')
       self.assertRaises(
       self.assertRaises(
           error.ManifestInvalidPathError, self.check_both, 'a', path)
           error.ManifestInvalidPathError, self.check_both, 'a', path)
+
+
+class ValueTests(unittest.TestCase):
+  """Check utility parsing code."""
+
+  def _get_node(self, text):
+    return xml.dom.minidom.parseString(text).firstChild
+
+  def test_bool_default(self):
+    """Check XmlBool default handling."""
+    node = self._get_node('<node/>')
+    self.assertIsNone(manifest_xml.XmlBool(node, 'a'))
+    self.assertIsNone(manifest_xml.XmlBool(node, 'a', None))
+    self.assertEqual(123, manifest_xml.XmlBool(node, 'a', 123))
+
+    node = self._get_node('<node a=""/>')
+    self.assertIsNone(manifest_xml.XmlBool(node, 'a'))
+
+  def test_bool_invalid(self):
+    """Check XmlBool invalid handling."""
+    node = self._get_node('<node a="moo"/>')
+    self.assertEqual(123, manifest_xml.XmlBool(node, 'a', 123))
+
+  def test_bool_true(self):
+    """Check XmlBool true values."""
+    for value in ('yes', 'true', '1'):
+      node = self._get_node('<node a="%s"/>' % (value,))
+      self.assertTrue(manifest_xml.XmlBool(node, 'a'))
+
+  def test_bool_false(self):
+    """Check XmlBool false values."""
+    for value in ('no', 'false', '0'):
+      node = self._get_node('<node a="%s"/>' % (value,))
+      self.assertFalse(manifest_xml.XmlBool(node, 'a'))
+
+  def test_int_default(self):
+    """Check XmlInt default handling."""
+    node = self._get_node('<node/>')
+    self.assertIsNone(manifest_xml.XmlInt(node, 'a'))
+    self.assertIsNone(manifest_xml.XmlInt(node, 'a', None))
+    self.assertEqual(123, manifest_xml.XmlInt(node, 'a', 123))
+
+    node = self._get_node('<node a=""/>')
+    self.assertIsNone(manifest_xml.XmlInt(node, 'a'))
+
+  def test_int_good(self):
+    """Check XmlInt numeric handling."""
+    for value in (-1, 0, 1, 50000):
+      node = self._get_node('<node a="%s"/>' % (value,))
+      self.assertEqual(value, manifest_xml.XmlInt(node, 'a'))
+
+  def test_int_invalid(self):
+    """Check XmlInt invalid handling."""
+    with self.assertRaises(error.ManifestParseError):
+      node = self._get_node('<node a="xx"/>')
+      manifest_xml.XmlInt(node, 'a')

+ 327 - 0
tests/test_wrapper.py

@@ -18,10 +18,14 @@
 
 
 from __future__ import print_function
 from __future__ import print_function
 
 
+import contextlib
 import os
 import os
 import re
 import re
+import shutil
+import tempfile
 import unittest
 import unittest
 
 
+import platform_utils
 from pyversion import is_python3
 from pyversion import is_python3
 import wrapper
 import wrapper
 
 
@@ -34,6 +38,18 @@ else:
   from StringIO import StringIO
   from StringIO import StringIO
 
 
 
 
+@contextlib.contextmanager
+def TemporaryDirectory():
+  """Create a new empty git checkout for testing."""
+  # TODO(vapier): Convert this to tempfile.TemporaryDirectory once we drop
+  # Python 2 support entirely.
+  try:
+    tempdir = tempfile.mkdtemp(prefix='repo-tests')
+    yield tempdir
+  finally:
+    platform_utils.rmtree(tempdir)
+
+
 def fixture(*paths):
 def fixture(*paths):
   """Return a path relative to tests/fixtures.
   """Return a path relative to tests/fixtures.
   """
   """
@@ -153,5 +169,316 @@ class SetGitTrace2ParentSid(RepoWrapperTestCase):
     self.assertRegex(value, self.VALID_FORMAT)
     self.assertRegex(value, self.VALID_FORMAT)
 
 
 
 
+class RunCommand(RepoWrapperTestCase):
+  """Check run_command behavior."""
+
+  def test_capture(self):
+    """Check capture_output handling."""
+    ret = self.wrapper.run_command(['echo', 'hi'], capture_output=True)
+    self.assertEqual(ret.stdout, 'hi\n')
+
+  def test_check(self):
+    """Check check handling."""
+    self.wrapper.run_command(['true'], check=False)
+    self.wrapper.run_command(['true'], check=True)
+    self.wrapper.run_command(['false'], check=False)
+    with self.assertRaises(self.wrapper.RunError):
+      self.wrapper.run_command(['false'], check=True)
+
+
+class RunGit(RepoWrapperTestCase):
+  """Check run_git behavior."""
+
+  def test_capture(self):
+    """Check capture_output handling."""
+    ret = self.wrapper.run_git('--version')
+    self.assertIn('git', ret.stdout)
+
+  def test_check(self):
+    """Check check handling."""
+    with self.assertRaises(self.wrapper.CloneFailure):
+      self.wrapper.run_git('--version-asdfasdf')
+    self.wrapper.run_git('--version-asdfasdf', check=False)
+
+
+class ParseGitVersion(RepoWrapperTestCase):
+  """Check ParseGitVersion behavior."""
+
+  def test_autoload(self):
+    """Check we can load the version from the live git."""
+    ret = self.wrapper.ParseGitVersion()
+    self.assertIsNotNone(ret)
+
+  def test_bad_ver(self):
+    """Check handling of bad git versions."""
+    ret = self.wrapper.ParseGitVersion(ver_str='asdf')
+    self.assertIsNone(ret)
+
+  def test_normal_ver(self):
+    """Check handling of normal git versions."""
+    ret = self.wrapper.ParseGitVersion(ver_str='git version 2.25.1')
+    self.assertEqual(2, ret.major)
+    self.assertEqual(25, ret.minor)
+    self.assertEqual(1, ret.micro)
+    self.assertEqual('2.25.1', ret.full)
+
+  def test_extended_ver(self):
+    """Check handling of extended distro git versions."""
+    ret = self.wrapper.ParseGitVersion(
+        ver_str='git version 1.30.50.696.g5e7596f4ac-goog')
+    self.assertEqual(1, ret.major)
+    self.assertEqual(30, ret.minor)
+    self.assertEqual(50, ret.micro)
+    self.assertEqual('1.30.50.696.g5e7596f4ac-goog', ret.full)
+
+
+class CheckGitVersion(RepoWrapperTestCase):
+  """Check _CheckGitVersion behavior."""
+
+  def test_unknown(self):
+    """Unknown versions should abort."""
+    with mock.patch.object(self.wrapper, 'ParseGitVersion', return_value=None):
+      with self.assertRaises(self.wrapper.CloneFailure):
+        self.wrapper._CheckGitVersion()
+
+  def test_old(self):
+    """Old versions should abort."""
+    with mock.patch.object(
+        self.wrapper, 'ParseGitVersion',
+        return_value=self.wrapper.GitVersion(1, 0, 0, '1.0.0')):
+      with self.assertRaises(self.wrapper.CloneFailure):
+        self.wrapper._CheckGitVersion()
+
+  def test_new(self):
+    """Newer versions should run fine."""
+    with mock.patch.object(
+        self.wrapper, 'ParseGitVersion',
+        return_value=self.wrapper.GitVersion(100, 0, 0, '100.0.0')):
+      self.wrapper._CheckGitVersion()
+
+
+class NeedSetupGnuPG(RepoWrapperTestCase):
+  """Check NeedSetupGnuPG behavior."""
+
+  def test_missing_dir(self):
+    """The ~/.repoconfig tree doesn't exist yet."""
+    with TemporaryDirectory() as tempdir:
+      self.wrapper.home_dot_repo = os.path.join(tempdir, 'foo')
+      self.assertTrue(self.wrapper.NeedSetupGnuPG())
+
+  def test_missing_keyring(self):
+    """The keyring-version file doesn't exist yet."""
+    with TemporaryDirectory() as tempdir:
+      self.wrapper.home_dot_repo = tempdir
+      self.assertTrue(self.wrapper.NeedSetupGnuPG())
+
+  def test_empty_keyring(self):
+    """The keyring-version file exists, but is empty."""
+    with TemporaryDirectory() as tempdir:
+      self.wrapper.home_dot_repo = tempdir
+      with open(os.path.join(tempdir, 'keyring-version'), 'w'):
+        pass
+      self.assertTrue(self.wrapper.NeedSetupGnuPG())
+
+  def test_old_keyring(self):
+    """The keyring-version file exists, but it's old."""
+    with TemporaryDirectory() as tempdir:
+      self.wrapper.home_dot_repo = tempdir
+      with open(os.path.join(tempdir, 'keyring-version'), 'w') as fp:
+        fp.write('1.0\n')
+      self.assertTrue(self.wrapper.NeedSetupGnuPG())
+
+  def test_new_keyring(self):
+    """The keyring-version file exists, and is up-to-date."""
+    with TemporaryDirectory() as tempdir:
+      self.wrapper.home_dot_repo = tempdir
+      with open(os.path.join(tempdir, 'keyring-version'), 'w') as fp:
+        fp.write('1000.0\n')
+      self.assertFalse(self.wrapper.NeedSetupGnuPG())
+
+
+class SetupGnuPG(RepoWrapperTestCase):
+  """Check SetupGnuPG behavior."""
+
+  def test_full(self):
+    """Make sure it works completely."""
+    with TemporaryDirectory() as tempdir:
+      self.wrapper.home_dot_repo = tempdir
+      self.assertTrue(self.wrapper.SetupGnuPG(True))
+      with open(os.path.join(tempdir, 'keyring-version'), 'r') as fp:
+        data = fp.read()
+      self.assertEqual('.'.join(str(x) for x in self.wrapper.KEYRING_VERSION),
+                       data.strip())
+
+
+class VerifyRev(RepoWrapperTestCase):
+  """Check verify_rev behavior."""
+
+  def test_verify_passes(self):
+    """Check when we have a valid signed tag."""
+    desc_result = self.wrapper.RunResult(0, 'v1.0\n', '')
+    gpg_result = self.wrapper.RunResult(0, '', '')
+    with mock.patch.object(self.wrapper, 'run_git',
+                           side_effect=(desc_result, gpg_result)):
+      ret = self.wrapper.verify_rev('/', 'refs/heads/stable', '1234', True)
+      self.assertEqual('v1.0^0', ret)
+
+  def test_unsigned_commit(self):
+    """Check we fall back to signed tag when we have an unsigned commit."""
+    desc_result = self.wrapper.RunResult(0, 'v1.0-10-g1234\n', '')
+    gpg_result = self.wrapper.RunResult(0, '', '')
+    with mock.patch.object(self.wrapper, 'run_git',
+                           side_effect=(desc_result, gpg_result)):
+      ret = self.wrapper.verify_rev('/', 'refs/heads/stable', '1234', True)
+      self.assertEqual('v1.0^0', ret)
+
+  def test_verify_fails(self):
+    """Check we fall back to signed tag when we have an unsigned commit."""
+    desc_result = self.wrapper.RunResult(0, 'v1.0-10-g1234\n', '')
+    gpg_result = Exception
+    with mock.patch.object(self.wrapper, 'run_git',
+                           side_effect=(desc_result, gpg_result)):
+      with self.assertRaises(Exception):
+        self.wrapper.verify_rev('/', 'refs/heads/stable', '1234', True)
+
+
+class GitCheckoutTestCase(RepoWrapperTestCase):
+  """Tests that use a real/small git checkout."""
+
+  GIT_DIR = None
+  REV_LIST = None
+
+  @classmethod
+  def setUpClass(cls):
+    # Create a repo to operate on, but do it once per-class.
+    cls.GIT_DIR = tempfile.mkdtemp(prefix='repo-rev-tests')
+    run_git = wrapper.Wrapper().run_git
+
+    remote = os.path.join(cls.GIT_DIR, 'remote')
+    os.mkdir(remote)
+    run_git('init', cwd=remote)
+    run_git('commit', '--allow-empty', '-minit', cwd=remote)
+    run_git('branch', 'stable', cwd=remote)
+    run_git('tag', 'v1.0', cwd=remote)
+    run_git('commit', '--allow-empty', '-m2nd commit', cwd=remote)
+    cls.REV_LIST = run_git('rev-list', 'HEAD', cwd=remote).stdout.splitlines()
+
+    run_git('init', cwd=cls.GIT_DIR)
+    run_git('fetch', remote, '+refs/heads/*:refs/remotes/origin/*', cwd=cls.GIT_DIR)
+
+  @classmethod
+  def tearDownClass(cls):
+    if not cls.GIT_DIR:
+      return
+
+    shutil.rmtree(cls.GIT_DIR)
+
+
+class ResolveRepoRev(GitCheckoutTestCase):
+  """Check resolve_repo_rev behavior."""
+
+  def test_explicit_branch(self):
+    """Check refs/heads/branch argument."""
+    rrev, lrev = self.wrapper.resolve_repo_rev(self.GIT_DIR, 'refs/heads/stable')
+    self.assertEqual('refs/heads/stable', rrev)
+    self.assertEqual(self.REV_LIST[1], lrev)
+
+    with self.assertRaises(wrapper.CloneFailure):
+      self.wrapper.resolve_repo_rev(self.GIT_DIR, 'refs/heads/unknown')
+
+  def test_explicit_tag(self):
+    """Check refs/tags/tag argument."""
+    rrev, lrev = self.wrapper.resolve_repo_rev(self.GIT_DIR, 'refs/tags/v1.0')
+    self.assertEqual('refs/tags/v1.0', rrev)
+    self.assertEqual(self.REV_LIST[1], lrev)
+
+    with self.assertRaises(wrapper.CloneFailure):
+      self.wrapper.resolve_repo_rev(self.GIT_DIR, 'refs/tags/unknown')
+
+  def test_branch_name(self):
+    """Check branch argument."""
+    rrev, lrev = self.wrapper.resolve_repo_rev(self.GIT_DIR, 'stable')
+    self.assertEqual('refs/heads/stable', rrev)
+    self.assertEqual(self.REV_LIST[1], lrev)
+
+    rrev, lrev = self.wrapper.resolve_repo_rev(self.GIT_DIR, 'master')
+    self.assertEqual('refs/heads/master', rrev)
+    self.assertEqual(self.REV_LIST[0], lrev)
+
+  def test_tag_name(self):
+    """Check tag argument."""
+    rrev, lrev = self.wrapper.resolve_repo_rev(self.GIT_DIR, 'v1.0')
+    self.assertEqual('refs/tags/v1.0', rrev)
+    self.assertEqual(self.REV_LIST[1], lrev)
+
+  def test_full_commit(self):
+    """Check specific commit argument."""
+    commit = self.REV_LIST[0]
+    rrev, lrev = self.wrapper.resolve_repo_rev(self.GIT_DIR, commit)
+    self.assertEqual(commit, rrev)
+    self.assertEqual(commit, lrev)
+
+  def test_partial_commit(self):
+    """Check specific (partial) commit argument."""
+    commit = self.REV_LIST[0][0:20]
+    rrev, lrev = self.wrapper.resolve_repo_rev(self.GIT_DIR, commit)
+    self.assertEqual(self.REV_LIST[0], rrev)
+    self.assertEqual(self.REV_LIST[0], lrev)
+
+  def test_unknown(self):
+    """Check unknown ref/commit argument."""
+    with self.assertRaises(wrapper.CloneFailure):
+      self.wrapper.resolve_repo_rev(self.GIT_DIR, 'boooooooya')
+
+
+class CheckRepoVerify(RepoWrapperTestCase):
+  """Check check_repo_verify behavior."""
+
+  def test_no_verify(self):
+    """Always fail with --no-repo-verify."""
+    self.assertFalse(self.wrapper.check_repo_verify(False))
+
+  def test_gpg_initialized(self):
+    """Should pass if gpg is setup already."""
+    with mock.patch.object(self.wrapper, 'NeedSetupGnuPG', return_value=False):
+      self.assertTrue(self.wrapper.check_repo_verify(True))
+
+  def test_need_gpg_setup(self):
+    """Should pass/fail based on gpg setup."""
+    with mock.patch.object(self.wrapper, 'NeedSetupGnuPG', return_value=True):
+      with mock.patch.object(self.wrapper, 'SetupGnuPG') as m:
+        m.return_value = True
+        self.assertTrue(self.wrapper.check_repo_verify(True))
+
+        m.return_value = False
+        self.assertFalse(self.wrapper.check_repo_verify(True))
+
+
+class CheckRepoRev(GitCheckoutTestCase):
+  """Check check_repo_rev behavior."""
+
+  def test_verify_works(self):
+    """Should pass when verification passes."""
+    with mock.patch.object(self.wrapper, 'check_repo_verify', return_value=True):
+      with mock.patch.object(self.wrapper, 'verify_rev', return_value='12345'):
+        rrev, lrev = self.wrapper.check_repo_rev(self.GIT_DIR, 'stable')
+    self.assertEqual('refs/heads/stable', rrev)
+    self.assertEqual('12345', lrev)
+
+  def test_verify_fails(self):
+    """Should fail when verification fails."""
+    with mock.patch.object(self.wrapper, 'check_repo_verify', return_value=True):
+      with mock.patch.object(self.wrapper, 'verify_rev', side_effect=Exception):
+        with self.assertRaises(Exception):
+          self.wrapper.check_repo_rev(self.GIT_DIR, 'stable')
+
+  def test_verify_ignore(self):
+    """Should pass when verification is disabled."""
+    with mock.patch.object(self.wrapper, 'verify_rev', side_effect=Exception):
+      rrev, lrev = self.wrapper.check_repo_rev(self.GIT_DIR, 'stable', repo_verify=False)
+    self.assertEqual('refs/heads/stable', rrev)
+    self.assertEqual(self.REV_LIST[1], lrev)
+
+
 if __name__ == '__main__':
 if __name__ == '__main__':
   unittest.main()
   unittest.main()