Teach repo how to download changes to the local checkout

Now `repo download . 1402` would download the change numbered 1402
into the current project and check it out for the user, using a
detached HEAD.  `repo sync .` would back out of the change and
return to the upstream version.

Multiple projects can be fetched at once by listing them out on
the command line as different arguments.

Individual patch sets can be selected by adding a '/n' to indicate
the n-th patch set should be downloaded instead of the default of
patch set 1.

Signed-off-by: Shawn O. Pearce <sop@google.com>
diff --git a/project.py b/project.py
index 2b4a4f9..e88afcc 100644
--- a/project.py
+++ b/project.py
@@ -45,6 +45,31 @@
 def not_rev(r):
   return '^' + r
 
+class DownloadedChange(object):
+  _commit_cache = None
+
+  def __init__(self, project, base, change_id, ps_id, commit):
+    self.project = project
+    self.base = base
+    self.change_id = change_id
+    self.ps_id = ps_id
+    self.commit = commit
+
+  @property
+  def commits(self):
+    if self._commit_cache is None:
+      self._commit_cache = self.project.bare_git.rev_list(
+        '--abbrev=8',
+        '--abbrev-commit',
+        '--pretty=oneline',
+        '--reverse',
+        '--date-order',
+        not_rev(self.base),
+        self.commit,
+        '--')
+    return self._commit_cache
+
+
 class ReviewableBranch(object):
   _commit_cache = None
 
@@ -612,6 +637,23 @@
     src = os.path.join(self.worktree, src)
     self.copyfiles.append(_CopyFile(src, dest))
 
+  def DownloadPatchSet(self, change_id, patch_id):
+    """Download a single patch set of a single change to FETCH_HEAD.
+    """
+    remote = self.GetRemote(self.remote.name)
+
+    cmd = ['fetch', remote.name]
+    cmd.append('refs/changes/%2.2d/%d/%d' \
+               % (change_id % 100, change_id, patch_id))
+    cmd.extend(map(lambda x: str(x), remote.fetch))
+    if GitCommand(self, cmd, bare=True).Wait() != 0:
+      return None
+    return DownloadedChange(self,
+                            remote.ToLocal(self.revision),
+                            change_id,
+                            patch_id,
+                            self.bare_git.rev_parse('FETCH_HEAD'))
+
 
 ## Branch Management ##
 
diff --git a/subcmds/download.py b/subcmds/download.py
new file mode 100644
index 0000000..a6f3aa4
--- /dev/null
+++ b/subcmds/download.py
@@ -0,0 +1,78 @@
+#
+# Copyright (C) 2008 The Android Open Source Project
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import os
+import re
+import sys
+
+from command import Command
+
+CHANGE_RE = re.compile(r'^([1-9][0-9]*)(?:[/\.-]([1-9][0-9]*))?$')
+
+class Download(Command):
+  common = True
+  helpSummary = "Download and checkout a change"
+  helpUsage = """
+%prog {project change[/patchset]}...
+"""
+  helpDescription = """
+The '%prog' command downloads a change from the review system and
+makes it available in your project's local working directory.
+"""
+
+  def _Options(self, p):
+    pass
+
+  def _ParseChangeIds(self, args):
+    to_get = []
+    project = None
+
+    for a in args:
+      m = CHANGE_RE.match(a)
+      if m:
+        if not project:
+          self.Usage()
+        chg_id = int(m.group(1))
+        if m.group(2):
+          ps_id = int(m.group(2))
+        else:
+          ps_id = 1
+        to_get.append((project, chg_id, ps_id))
+      else:
+        project = self.GetProjects([a])[0]
+    return to_get
+
+  def Execute(self, opt, args):
+    for project, change_id, ps_id in self._ParseChangeIds(args):
+      dl = project.DownloadPatchSet(change_id, ps_id)
+      if not dl:
+        print >>sys.stderr, \
+          '[%s] change %d/%d not found' \
+          % (project.name, change_id, ps_id)
+        sys.exit(1)
+
+      if not dl.commits:
+        print >>sys.stderr, \
+          '[%s] change %d/%d has already been merged' \
+          % (project.name, change_id, ps_id)
+        continue
+
+      if len(dl.commits) > 1:
+        print >>sys.stderr, \
+          '[%s] %d/%d depends on %d unmerged changes:' \
+          % (project.name, change_id, ps_id, len(dl.commits))
+        for c in dl.commits:
+          print >>sys.stderr, '  %s' % (c)
+      project._Checkout(dl.commit)