Explorar el Código

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

gaojun hace 5 años
padre
commit
e54fee5fc0

+ 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.
 
 * 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>
 * Source: <https://gerrit.googlesource.com/git-repo/>
 * 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>
 * 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
 
 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
 $ 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.
     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
     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\>.autoreviewer   | upload        | Automatically add to `--reviewers=<value>` |
 | 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\>.username       | upload        | Override username with `ssh://` review URIs |
 | 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
 [local manifests]: ./manifest-format.md#Local-Manifests
 [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.
     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).
 
 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
 using `git tag -v` (which uses gpg).
 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.
 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
-default `repo-branch` setting is `stable`).
+default `repo-rev` setting is `stable`).
 This would allow some early testing on systems who explicitly select `master`.
 
 ### Creating a signed tag

+ 2 - 0
git_refs.py

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

+ 13 - 7
main.py

@@ -135,8 +135,6 @@ class _Repo(object):
   def __init__(self, repodir):
     self.repodir = repodir
     self.commands = all_commands
-    # add 'branch' as an alias for 'branches'
-    all_commands['branch'] = all_commands['branches']
 
   def _ParseArgs(self, argv):
     """Parse the main `repo` command line options."""
@@ -206,7 +204,7 @@ class _Repo(object):
     SetDefaultColoring(gopts.color)
 
     try:
-      cmd = self.commands[name]
+      cmd = self.commands[name]()
     except KeyError:
       print("repo: '%s' is not a repo command.  See 'repo help'." % name,
             file=sys.stderr)
@@ -348,12 +346,20 @@ repo: error:
     sys.exit(1)
 
   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:
-
     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):

+ 70 - 55
manifest_xml.py

@@ -57,6 +57,60 @@ urllib.parse.uses_netloc.extend([
     '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):
   """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.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
 
   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" %
                                (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
 
@@ -911,7 +926,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
     groups.extend(set(default_groups).difference(groups))
 
     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)
 
     project = Project(manifest=self,

+ 15 - 3
platform_utils.py

@@ -90,6 +90,11 @@ class _FileDescriptorStreamsNonBlocking(FileDescriptorStreams):
   """ Implementation of FileDescriptorStreams for platforms that support
   non blocking I/O.
   """
+  def __init__(self):
+    super(_FileDescriptorStreamsNonBlocking, self).__init__()
+    self._poll = select.poll()
+    self._fd_to_stream = {}
+
   class Stream(object):
     """ Encapsulates a file descriptor """
 
@@ -114,11 +119,18 @@ class _FileDescriptorStreamsNonBlocking(FileDescriptorStreams):
       self.fd.close()
 
   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):
-    ready_streams, _, _ = select.select(self.streams, [], [])
-    return ready_streams
+    return [self._fd_to_stream[fd] for fd, _ in self._poll.poll()]
 
 
 class _FileDescriptorStreamsThreads(FileDescriptorStreams):

+ 52 - 26
project.py

@@ -42,7 +42,7 @@ import platform_utils
 import progress
 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
 if is_python3():
@@ -201,7 +201,7 @@ class ReviewableBranch(object):
                       dryrun=False,
                       auto_topic=False,
                       hashtags=(),
-                      draft=False,
+                      labels=(),
                       private=False,
                       notify=None,
                       wip=False,
@@ -213,7 +213,7 @@ class ReviewableBranch(object):
                                  dryrun=dryrun,
                                  auto_topic=auto_topic,
                                  hashtags=hashtags,
-                                 draft=draft,
+                                 labels=labels,
                                  private=private,
                                  notify=notify,
                                  wip=wip,
@@ -1346,7 +1346,7 @@ class Project(object):
                       dryrun=False,
                       auto_topic=False,
                       hashtags=(),
-                      draft=False,
+                      labels=(),
                       private=False,
                       notify=None,
                       wip=False,
@@ -1396,16 +1396,12 @@ class Project(object):
     if dest_branch.startswith(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 = []
     if auto_topic:
       opts += ['topic=' + branch.name]
     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 += ['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')):
         cmd.append('--depth=2147483647')
 
-    if quiet:
+    if not verbose:
       cmd.append('--quiet')
+    if not quiet and sys.stdout.isatty():
+      cmd.append('--progress')
     if not self.worktree:
       cmd.append('--update-head-ok')
     cmd.append(name)
@@ -2502,7 +2500,7 @@ class Project(object):
     ok = False
     for _i in range(2):
       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()
       if ret == 0:
         ok = True
@@ -2582,8 +2580,10 @@ class Project(object):
       return False
 
     cmd = ['fetch']
-    if quiet:
+    if not verbose:
       cmd.append('--quiet')
+    if not quiet and sys.stdout.isatty():
+      cmd.append('--progress')
     if not self.worktree:
       cmd.append('--update-head-ok')
     cmd.append(bundle_dst)
@@ -2643,9 +2643,8 @@ class Project(object):
         # 22: HTTP page not retrieved. The requested url was not found or
         # returned another error with the HTTP error code being 400 or above.
         # 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
       elif curlret and not verbose and output:
         print('%s' % output, file=sys.stderr)
@@ -2682,8 +2681,12 @@ class Project(object):
       if self._allrefs:
         raise GitError('%s checkout %s ' % (self.name, rev))
 
-  def _CherryPick(self, rev):
+  def _CherryPick(self, rev, ffonly=False, record_origin=False):
     cmd = ['cherry-pick']
+    if ffonly:
+      cmd.append('--ff')
+    if record_origin:
+      cmd.append('-x')
     cmd.append(rev)
     cmd.append('--')
     if GitCommand(self, cmd).Wait() != 0:
@@ -2745,10 +2748,19 @@ class Project(object):
         os.makedirs(self.objdir)
         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 self.objdir != self.gitdir:
@@ -2883,25 +2895,37 @@ class Project(object):
 
   def _InitMRef(self):
     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):
-    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)
 
     if self.revisionId:
       if cur != '' or self.bare_ref.get(ref) != self.revisionId:
         msg = 'manifest set to %s' % self.revisionId
         dst = self.revisionId + '^0'
-        self.bare_git.UpdateRef(ref, dst, message=msg, detach=True)
+        active_git.UpdateRef(ref, dst, message=msg, detach=True)
     else:
       remote = self.GetRemote(self.remote.name)
       dst = remote.ToLocal(self.revisionExpr)
       if cur != dst:
         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):
     # 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:
       print(os.path.relpath(dotgit, git_worktree_path), file=fp)
 
+    self._InitMRef()
+
   def _InitWorkTree(self, force_sync=False, submodules=False):
     realdotgit = os.path.join(self.worktree, '.git')
     tmpdotgit = realdotgit + '.tmp'

+ 11 - 2
release/sign-tag.py

@@ -15,7 +15,14 @@
 
 """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
@@ -86,7 +93,9 @@ To roll back a release:
 
 def get_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',
                       dest='dryrun', action='store_true',
                       help='show everything that would be done')

+ 136 - 57
repo

@@ -135,7 +135,7 @@ if not REPO_REV:
   REPO_REV = 'qs'
 
 # 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
 KEYRING_VERSION = (2, 3)
@@ -253,8 +253,6 @@ else:
 home_dot_repo = os.path.expanduser('~/.repoconfig')
 gpg_dir = os.path.join(home_dot_repo, 'gnupg')
 
-extra_args = []
-
 
 def GetParser(gitc_init=False):
   """Setup the CLI parser."""
@@ -332,8 +330,10 @@ def GetParser(gitc_init=False):
   group = parser.add_option_group('repo Version options')
   group.add_option('--repo-url', metavar='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)')
+  group.add_option('--repo-branch', dest='repo_rev',
+                   help=optparse.SUPPRESS_HELP)
   group.add_option('--no-repo-verify',
                    dest='repo_verify', default=True, action='store_false',
                    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):
   """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.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:
     if gitc_init:
@@ -525,25 +540,13 @@ def _Init(args, gitc_init=False):
 
   _CheckGitVersion()
   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:
       print('Downloading Repo source from', url)
     dst = os.path.abspath(os.path.join(repodir, S_repo))
     _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')):
       print("warning: '%s' does not look like a git-repo repository, is "
@@ -608,7 +611,8 @@ def _CheckGitVersion():
 
   if ver_act < 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()
 
 
@@ -757,15 +761,17 @@ def _InitHttp():
 
 def _Fetch(url, cwd, src, quiet, verbose):
   cmd = ['fetch']
-  if quiet:
+  if not verbose:
     cmd.append('--quiet')
+  err = None
+  if not quiet and sys.stdout.isatty():
+    cmd.append('--progress')
+  elif not verbose:
     err = subprocess.PIPE
-  else:
-    err = None
   cmd.append(src)
   cmd.append('+refs/heads/*:refs/remotes/origin/*')
   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):
@@ -848,23 +854,83 @@ def _Clone(url, cwd, clone_bundle, 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)
   if m:
     cur = m.group(1)
     if not quiet:
       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)
 
   env = os.environ.copy()
@@ -873,13 +939,13 @@ def _Verify(cwd, branch, quiet):
   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.
   """
   run_git('update-ref', 'refs/heads/default', rev, cwd=cwd)
 
   _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)
 
@@ -995,6 +1061,14 @@ def _Version():
   print('       (from %s)' % (__file__,))
   print('git %s' % (ParseGitVersion().full,))
   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)
 
 
@@ -1030,12 +1104,18 @@ def _SetDefaultsTo(gitdir):
   global REPO_REV
 
   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):
@@ -1098,7 +1178,6 @@ def main(orig_args):
         '--wrapper-path=%s' % wrapper_path,
         '--']
   me.extend(orig_args)
-  me.extend(extra_args)
   exec_command(me)
   print("fatal: unable to start %s" % repo_main, file=sys.stderr)
   sys.exit(148)

+ 4 - 3
subcmds/__init__.py

@@ -16,6 +16,7 @@
 
 import os
 
+# A mapping of the subcommand name to the class that implements it.
 all_commands = {}
 
 my_dir = os.path.dirname(__file__)
@@ -37,7 +38,7 @@ for py in os.listdir(my_dir):
                      ['%s' % name])
     mod = getattr(mod, name)
     try:
-      cmd = getattr(mod, clsn)()
+      cmd = getattr(mod, clsn)
     except AttributeError:
       raise SyntaxError('%s/%s does not define class %s' % (
           __name__, py, clsn))
@@ -46,5 +47,5 @@ for py in os.listdir(my_dir):
     cmd.NAME = name
     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>',
                  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']:
       self.printText("A %s %s" % (project.relpath, project.revisionExpr))
       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,
                                      otherProject.revisionExpr))
       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']:
       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)
     if opt.raw:
-      self._printRawDiff(diff)
+      self._printRawDiff(diff, pretty_format=opt.pretty_format)
     else:
       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):
+    p.add_option('-b', '--branch',
+                 help='create a new branch first')
     p.add_option('-c', '--cherry-pick',
                  dest='cherrypick', action='store_true',
                  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',
                  dest='revert', action='store_true',
                  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]
     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):
     for project, change_id, ps_id in self._ParseChangeIds(args):
       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)
         for c in dl.commits:
           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:
-        project._Revert(dl.commit)
+        mode = 'revert'
       elif opt.ffonly:
-        project._FastForward(dl.commit, ffonly=True)
+        mode = 'fast-forward merge'
       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
 from formatter import AbstractFormatter, DumbWriter
 
+from subcmds import all_commands
 from color import Coloring
 from command import PagedCommand, MirrorSafeCommand, GitcAvailableCommand, GitcClientCommand
 import gitc_utils
@@ -42,7 +43,7 @@ Displays detailed usage information about a command.
     fmt = '  %%-%ds  %%s' % maxlen
 
     for name in commandNames:
-      command = self.commands[name]
+      command = all_commands[name]()
       try:
         summary = command.helpSummary.strip()
       except AttributeError:
@@ -52,7 +53,7 @@ Displays detailed usage information about a command.
   def _PrintAllCommands(self):
     print('usage: repo COMMAND [ARGS]')
     print('The complete list of recognized repo commands are:')
-    commandNames = list(sorted(self.commands))
+    commandNames = list(sorted(all_commands))
     self._PrintCommands(commandNames)
     print("See 'repo help <command>' for more information on a "
           'specific command.')
@@ -73,7 +74,7 @@ Displays detailed usage information about a command.
       return False
 
     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)]))
     self._PrintCommands(commandNames)
 
@@ -132,8 +133,8 @@ Displays detailed usage information about a command.
     out._PrintSection('Description', 'helpDescription')
 
   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
       self._PrintCommandHelp(cmd, header_prefix='[%s] ' % (name,))
 
@@ -158,7 +159,7 @@ Displays detailed usage information about a command.
       name = args[0]
 
       try:
-        cmd = self.commands[name]
+        cmd = all_commands[name]()
       except KeyError:
         print("repo: '%s' is not a repo command." % name, file=sys.stderr)
         sys.exit(1)

+ 23 - 3
subcmds/init.py

@@ -38,6 +38,7 @@ from project import SyncBuffer
 from git_config import GitConfig
 from git_command import git_require, MIN_GIT_VERSION_SOFT, MIN_GIT_VERSION_HARD
 import platform_utils
+from wrapper import Wrapper
 
 
 class Init(InteractiveCommand, MirrorSafeCommand):
@@ -166,9 +167,10 @@ to update the working directory files.
     g.add_option('--repo-url',
                  dest='repo_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',
                  dest='repo_verify', default=True, action='store_false',
                  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.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:
       # Older versions of git supported worktree, but had dangerous gc bugs.
       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):]
 
         if 'SYNC_TARGET' in os.environ:
-          target = os.environ('SYNC_TARGET')
+          target = os.environ['SYNC_TARGET']
           [success, manifest_str] = server.GetApprovedManifest(branch, target)
         elif ('TARGET_PRODUCT' in os.environ and
               '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)
         else:
           [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.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.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
 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
 
@@ -152,6 +163,9 @@ Gerrit Code Review:  https://www.gerritcodereview.com/
     p.add_option('--hashtag-branch', '--htb',
                  action='store_true',
                  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',
                  type='string', action='append', dest='reviewers',
                  help='Request reviews from these people.')
@@ -164,9 +178,6 @@ Gerrit Code Review:  https://www.gerritcodereview.com/
     p.add_option('--cbr', '--current-branch',
                  dest='current_branch', action='store_true',
                  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',
                  action='store_false', dest='notify', default=True,
                  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
       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):' % (
           name,
           len(commit_list),
@@ -410,22 +421,42 @@ Gerrit Code Review:  https://www.gerritcodereview.com/
           key = 'review.%s.uploadtopic' % branch.project.remote.review
           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:
             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
-        hashtags = set(_ExpandHashtag(branch.project.config.GetString(key)))
+        hashtags = set(_ExpandCommaList(branch.project.config.GetString(key)))
         for tag in opt.hashtags:
-          hashtags.update(_ExpandHashtag(tag))
+          hashtags.update(_ExpandCommaList(tag))
         if opt.hashtag_branch:
           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
 
         # 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,
                                auto_topic=opt.auto_topic,
                                hashtags=hashtags,
-                               draft=opt.draft,
+                               labels=labels,
                                private=opt.private,
-                               notify=None if opt.notify else 'NONE',
+                               notify=notify,
                                wip=opt.wip,
                                dest_branch=destination,
                                validate_certs=opt.validate_certs,

+ 15 - 3
subcmds/version.py

@@ -15,7 +15,10 @@
 # limitations under the License.
 
 from __future__ import print_function
+
+import platform
 import sys
+
 from command import Command, MirrorSafeCommand
 from git_command import git, RepoSourceVersion, user_agent
 from git_refs import HEAD
@@ -40,10 +43,11 @@ class Version(Command, MirrorSafeCommand):
     rp_ver = rp.bare_git.describe(HEAD)
     print('repo version %s' % rp_ver)
     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:
         print('       (currently at %s)' % src_ver)
@@ -52,3 +56,11 @@ class Version(Command, MirrorSafeCommand):
     print('git %s' % git.version_tuple().full)
     print('git User-Agent %s' % user_agent.git)
     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 unittest
+import xml.dom.minidom
 
 import error
 import manifest_xml
@@ -89,3 +90,59 @@ class ManifestValidateFilePaths(unittest.TestCase):
           error.ManifestInvalidPathError, self.check_both, path, 'a')
       self.assertRaises(
           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
 
+import contextlib
 import os
 import re
+import shutil
+import tempfile
 import unittest
 
+import platform_utils
 from pyversion import is_python3
 import wrapper
 
@@ -34,6 +38,18 @@ else:
   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):
   """Return a path relative to tests/fixtures.
   """
@@ -153,5 +169,316 @@ class SetGitTrace2ParentSid(RepoWrapperTestCase):
     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__':
   unittest.main()