Add support for downloading archives with /+archive/foo.tar.gz

Support all the archive formats supported upstream in JGit, with the
exception of zip.

Change-Id: I8cdec13882117f5b716e54479cff903d8b25a933
diff --git a/gitiles-servlet/pom.xml b/gitiles-servlet/pom.xml
index 26642e0..4a41bb5 100644
--- a/gitiles-servlet/pom.xml
+++ b/gitiles-servlet/pom.xml
@@ -54,6 +54,11 @@
 
     <dependency>
       <groupId>org.eclipse.jgit</groupId>
+      <artifactId>org.eclipse.jgit.archive</artifactId>
+    </dependency>
+
+    <dependency>
+      <groupId>org.eclipse.jgit</groupId>
       <artifactId>org.eclipse.jgit.junit</artifactId>
       <scope>test</scope>
       <exclusions>
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/ArchiveServlet.java b/gitiles-servlet/src/main/java/com/google/gitiles/ArchiveServlet.java
new file mode 100644
index 0000000..b7d49dd
--- /dev/null
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/ArchiveServlet.java
@@ -0,0 +1,126 @@
+// Copyright 2013 Google Inc. All Rights Reserved.
+//
+// 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.
+
+package com.google.gitiles;
+
+import static javax.servlet.http.HttpServletResponse.SC_NOT_FOUND;
+import static javax.servlet.http.HttpServletResponse.SC_OK;
+
+import com.google.common.collect.ImmutableMap;
+
+import org.eclipse.jgit.api.ArchiveCommand;
+import org.eclipse.jgit.api.errors.GitAPIException;
+import org.eclipse.jgit.archive.TarFormat;
+import org.eclipse.jgit.archive.Tbz2Format;
+import org.eclipse.jgit.archive.TgzFormat;
+import org.eclipse.jgit.archive.TxzFormat;
+import org.eclipse.jgit.errors.IncorrectObjectTypeException;
+import org.eclipse.jgit.http.server.ServletUtils;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+import java.io.IOException;
+import java.util.Map;
+import java.util.Set;
+
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+public class ArchiveServlet extends BaseServlet {
+  private static final long serialVersionUID = 1L;
+
+  private enum Format {
+    TAR("application/x-tar", new TarFormat()),
+    TGZ("application/x-gzip", new TgzFormat()),
+    TBZ2("application/x-bzip2", new Tbz2Format()),
+    TXZ("application/x-xz", new TxzFormat());
+    // Zip is not supported because it may be interpreted by a Java plugin as a
+    // valid JAR file, whose code would have access to cookies on the domain.
+
+    private final ArchiveCommand.Format<?> format;
+    private final String mimeType;
+
+    private Format(String mimeType, ArchiveCommand.Format<?> format) {
+      this.format = format;
+      this.mimeType = mimeType;
+      ArchiveCommand.registerFormat(name(), format);
+    }
+  }
+
+  private static final Map<String, Format> FORMATS_BY_EXTENSION;
+
+  static {
+    ImmutableMap.Builder<String, Format> exts = ImmutableMap.builder();
+    for (Format format : Format.values()) {
+      for (String ext : format.format.suffixes()) {
+        exts.put(ext, format);
+      }
+    }
+    FORMATS_BY_EXTENSION = exts.build();
+  }
+
+  static Set<String> validExtensions() {
+    return FORMATS_BY_EXTENSION.keySet();
+  }
+
+  public ArchiveServlet() {
+    super(null);
+  }
+
+  @Override
+  protected void doGet(HttpServletRequest req, HttpServletResponse res)
+      throws IOException, ServletException {
+    GitilesView view = ViewFilter.getView(req);
+    Revision rev = view.getRevision();
+    Repository repo = ServletUtils.getRepository(req);
+
+    // Check object type before starting the archive. If we just caught the
+    // exception from cmd.call() below, we wouldn't know whether it was because
+    // the input object is not a tree or something broke later.
+    RevWalk walk = new RevWalk(repo);
+    try {
+      walk.parseTree(rev.getId());
+    } catch (IncorrectObjectTypeException e) {
+      res.sendError(SC_NOT_FOUND);
+      return;
+    } finally {
+      walk.release();
+    }
+
+    Format format = FORMATS_BY_EXTENSION.get(view.getExtension());
+    String filename = getFilename(view, rev, view.getExtension());
+    setDownloadHeaders(req, res, filename, format.mimeType);
+    res.setStatus(SC_OK);
+
+    try {
+      new ArchiveCommand(repo)
+          .setFormat(format.name())
+          .setTree(rev.getId())
+          .setOutputStream(res.getOutputStream())
+          .call();
+    } catch (GitAPIException e) {
+      throw new IOException(e);
+    }
+  }
+
+  private String getFilename(GitilesView view, Revision rev, String ext) {
+    return new StringBuilder()
+        .append(Paths.basename(view.getRepositoryName()))
+        .append('-')
+        .append(rev.getName())
+        .append(ext)
+        .toString();
+  }
+}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/BaseServlet.java b/gitiles-servlet/src/main/java/com/google/gitiles/BaseServlet.java
index e344b70..12efb2c 100644
--- a/gitiles-servlet/src/main/java/com/google/gitiles/BaseServlet.java
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/BaseServlet.java
@@ -279,4 +279,11 @@
     res.setHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, "*");
     setCacheHeaders(req, res);
   }
+
+  protected void setDownloadHeaders(HttpServletRequest req, HttpServletResponse res,
+      String filename, String contentType) {
+    res.setContentType(contentType);
+    res.setHeader(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=" + filename);
+    setCacheHeaders(req, res);
+  }
 }
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/GitilesFilter.java b/gitiles-servlet/src/main/java/com/google/gitiles/GitilesFilter.java
index 1955fff..cb2e459 100644
--- a/gitiles-servlet/src/main/java/com/google/gitiles/GitilesFilter.java
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/GitilesFilter.java
@@ -242,6 +242,8 @@
         return new LogServlet(renderer, linkifier());
       case DESCRIBE:
         return new DescribeServlet();
+      case ARCHIVE:
+        return new ArchiveServlet();
       default:
         throw new IllegalArgumentException("Invalid view type: " + view);
     }
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/GitilesView.java b/gitiles-servlet/src/main/java/com/google/gitiles/GitilesView.java
index 1dc3b64..9121578 100644
--- a/gitiles-servlet/src/main/java/com/google/gitiles/GitilesView.java
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/GitilesView.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.common.base.Preconditions.checkState;
 import static com.google.gitiles.GitilesUrls.NAME_ESCAPER;
 
 import com.google.common.annotations.VisibleForTesting;
@@ -34,6 +35,7 @@
 
 import java.io.UnsupportedEncodingException;
 import java.net.URLEncoder;
+import java.util.EnumSet;
 import java.util.List;
 import java.util.Map;
 
@@ -48,6 +50,8 @@
  * Construction happens in {@link ViewFilter}.
  */
 public class GitilesView {
+  private static final String DEFAULT_ARCHIVE_EXTENSION = ".tar.gz";
+
   /** All the possible view types supported in the application. */
   public static enum Type {
     HOST_INDEX,
@@ -57,7 +61,8 @@
     PATH,
     DIFF,
     LOG,
-    DESCRIBE;
+    DESCRIBE,
+    ARCHIVE;
   }
 
   /** Exception thrown when building a view that is invalid. */
@@ -80,6 +85,7 @@
     private Revision revision = Revision.NULL;
     private Revision oldRevision = Revision.NULL;
     private String path;
+    private String extension;
     private String anchor;
 
     private Builder(Type type) {
@@ -98,6 +104,7 @@
           path = other.path;
           // Fallthrough.
         case REVISION:
+        case ARCHIVE:
           revision = other.revision;
           // Fallthrough.
         case DESCRIBE:
@@ -108,6 +115,9 @@
         default:
           break;
       }
+      if (type == Type.ARCHIVE) {
+        extension = other.extension;
+      }
       // Don't copy params.
       return this;
     }
@@ -222,6 +232,22 @@
       return path;
     }
 
+    public Builder setExtension(String extension) {
+      switch (type) {
+        default:
+          checkState(extension == null, "cannot set path on %s view", type);
+          // Fallthrough;
+        case ARCHIVE:
+          this.extension = extension;
+          break;
+      }
+      return this;
+    }
+
+    public String getExtension() {
+      return extension;
+    }
+
     public Builder putParam(String key, String value) {
       params.put(key, value);
       return this;
@@ -280,9 +306,12 @@
         case LOG:
           checkLog();
           break;
+        case ARCHIVE:
+          checkArchive();
+          break;
       }
       return new GitilesView(type, hostName, servletPath, repositoryName, revision,
-          oldRevision, path, params, anchor);
+          oldRevision, path, extension, params, anchor);
     }
 
     public String toUrl() {
@@ -330,6 +359,10 @@
       checkView(path != null, "missing path on %s view", type);
       checkRevision();
     }
+
+    private void checkArchive() {
+      checkRevision();
+    }
   }
 
   public static Builder hostIndex() {
@@ -364,6 +397,10 @@
     return new Builder(Type.LOG);
   }
 
+  public static Builder archive() {
+    return new Builder(Type.ARCHIVE);
+  }
+
   static String maybeTrimLeadingAndTrailingSlash(String str) {
     if (str.startsWith("/")) {
       str = str.substring(1);
@@ -378,6 +415,7 @@
   private final Revision revision;
   private final Revision oldRevision;
   private final String path;
+  private final String extension;
   private final ListMultimap<String, String> params;
   private final String anchor;
 
@@ -388,6 +426,7 @@
       Revision revision,
       Revision oldRevision,
       String path,
+      String extension,
       ListMultimap<String, String> params,
       String anchor) {
     this.type = type;
@@ -397,6 +436,7 @@
     this.revision = Objects.firstNonNull(revision, Revision.NULL);
     this.oldRevision = Objects.firstNonNull(oldRevision, Revision.NULL);
     this.path = path;
+    this.extension = extension;
     this.params = Multimaps.unmodifiableListMultimap(params);
     this.anchor = anchor;
   }
@@ -445,6 +485,10 @@
     return path;
   }
 
+  public String getExtension() {
+    return extension;
+  }
+
   public ListMultimap<String, String> getParameters() {
     return params;
   }
@@ -466,7 +510,8 @@
         .add("repo", repositoryName)
         .add("rev", revision)
         .add("old", oldRevision)
-        .add("path", path);
+        .add("path", path)
+        .add("extension", extension);
     if (!params.isEmpty()) {
       b.add("params", params);
     }
@@ -498,6 +543,10 @@
       case REVISION:
         url.append(repositoryName).append("/+/").append(revision.getName());
         break;
+      case ARCHIVE:
+        url.append(repositoryName).append("/+archive/").append(revision.getName())
+            .append(Objects.firstNonNull(extension, DEFAULT_ARCHIVE_EXTENSION));
+        break;
       case PATH:
         url.append(repositoryName).append("/+/").append(revision.getName()).append('/')
             .append(path);
@@ -548,6 +597,8 @@
     return getBreadcrumbs(null);
   }
 
+  private static final EnumSet<Type> NON_HTML_TYPES = EnumSet.of(Type.DESCRIBE, Type.ARCHIVE);
+
   /**
    * @param hasSingleTree list of booleans, one per path entry in this view's
    *     path excluding the leaf. True entries indicate the tree at that path
@@ -558,8 +609,8 @@
    *     auto-diving into one-entry subtrees.
    */
   public List<Map<String, String>> getBreadcrumbs(List<Boolean> hasSingleTree) {
-    checkArgument(type != Type.DESCRIBE,
-        "breadcrumbs for DESCRIBE view not supported");
+    checkArgument(!NON_HTML_TYPES.contains(type),
+        "breadcrumbs for %s view not supported", type);
     checkArgument(type != Type.REFS || Strings.isNullOrEmpty(path),
         "breadcrumbs for REFS view with path not supported");
     checkArgument(hasSingleTree == null || type == Type.PATH,
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/Paths.java b/gitiles-servlet/src/main/java/com/google/gitiles/Paths.java
index e4e66af..f69995b 100644
--- a/gitiles-servlet/src/main/java/com/google/gitiles/Paths.java
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/Paths.java
@@ -14,6 +14,7 @@
 
 package com.google.gitiles;
 
+import com.google.common.base.CharMatcher;
 import com.google.common.base.Splitter;
 import com.google.common.base.Strings;
 import com.google.common.io.Files;
@@ -22,7 +23,8 @@
 
 /** Static utilities for dealing with pathnames. */
 class Paths {
-  static final Splitter SPLITTER = Splitter.on('/');
+  private static final CharMatcher MATCHER = CharMatcher.is('/');
+  static final Splitter SPLITTER = Splitter.on(MATCHER);
 
   static String simplifyPathUpToRoot(String path, String root) {
     if (path.startsWith("/")) {
@@ -48,6 +50,15 @@
     return !result.equals(".") ? result : "";
   }
 
+  static String basename(String path) {
+    path = MATCHER.trimTrailingFrom(path);
+    int slash = path.lastIndexOf('/');
+    if (slash < 0) {
+      return path;
+    }
+    return path.substring(slash + 1);
+  }
+
   private Paths() {
   }
 }
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/ViewFilter.java b/gitiles-servlet/src/main/java/com/google/gitiles/ViewFilter.java
index 767b99c..5b373de 100644
--- a/gitiles-servlet/src/main/java/com/google/gitiles/ViewFilter.java
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/ViewFilter.java
@@ -39,6 +39,7 @@
 
   private static final String VIEW_ATTIRBUTE = ViewFilter.class.getName() + "/View";
 
+  private static final String CMD_ARCHIVE = "+archive";
   private static final String CMD_AUTO = "+";
   private static final String CMD_DESCRIBE = "+describe";
   private static final String CMD_DIFF = "+diff";
@@ -114,6 +115,8 @@
 
     if (command.isEmpty()) {
       return parseNoCommand(req, repoName, path);
+    } else if (command.equals(CMD_ARCHIVE)) {
+      return parseArchiveCommand(req, repoName, path);
     } else if (command.equals(CMD_AUTO)) {
       return parseAutoCommand(req, repoName, path);
     } else if (command.equals(CMD_DESCRIBE)) {
@@ -136,6 +139,29 @@
     return GitilesView.repositoryIndex().setRepositoryName(repoName);
   }
 
+  private GitilesView.Builder parseArchiveCommand(
+      HttpServletRequest req, String repoName, String path) throws IOException {
+    String ext = null;
+    for (String e : ArchiveServlet.validExtensions()) {
+      if (path.endsWith(e)) {
+        path = path.substring(0, path.length() - e.length());
+        ext = e;
+        break;
+      }
+    }
+    if (ext == null) {
+      return null;
+    }
+    RevisionParser.Result result = parseRevision(req, path);
+    if (result == null || result.getOldRevision() != null) {
+      return null;
+    }
+    return GitilesView.archive()
+        .setRepositoryName(repoName)
+        .setRevision(result.getRevision())
+        .setExtension(ext);
+  }
+
   private GitilesView.Builder parseAutoCommand(
       HttpServletRequest req, String repoName, String path) throws IOException {
     // Note: if you change the mapping for +, make sure to change
diff --git a/gitiles-servlet/src/test/java/com/google/gitiles/ViewFilterTest.java b/gitiles-servlet/src/test/java/com/google/gitiles/ViewFilterTest.java
index 80d204a..0f00174 100644
--- a/gitiles-servlet/src/test/java/com/google/gitiles/ViewFilterTest.java
+++ b/gitiles-servlet/src/test/java/com/google/gitiles/ViewFilterTest.java
@@ -380,6 +380,36 @@
     assertEquals("", view.getPathPart());
   }
 
+  public void testArchive() throws Exception {
+    RevCommit master = repo.branch("refs/heads/master").commit().create();
+    repo.branch("refs/heads/branch").commit().create();
+    GitilesView view;
+
+    assertNull(getView("/repo/+archive"));
+    assertNull(getView("/repo/+archive/"));
+    assertNull(getView("/repo/+archive/master..branch"));
+    assertNull(getView("/repo/+archive/master.foo"));
+    assertNull(getView("/repo/+archive/master.zip"));
+
+    view = getView("/repo/+archive/master.tar.gz");
+    assertEquals(Type.ARCHIVE, view.getType());
+    assertEquals("repo", view.getRepositoryName());
+    assertEquals("master", view.getRevision().getName());
+    assertEquals(master, view.getRevision().getId());
+    assertEquals(Revision.NULL, view.getOldRevision());
+    assertEquals(".tar.gz", view.getExtension());
+    assertNull(view.getPathPart());
+
+    view = getView("/repo/+archive/master.tar.bz2");
+    assertEquals(Type.ARCHIVE, view.getType());
+    assertEquals("repo", view.getRepositoryName());
+    assertEquals("master", view.getRevision().getName());
+    assertEquals(master, view.getRevision().getId());
+    assertEquals(Revision.NULL, view.getOldRevision());
+    assertEquals(".tar.bz2", view.getExtension());
+    assertNull(view.getPathPart());
+  }
+
   private GitilesView getView(String pathAndQuery) throws ServletException, IOException {
     final AtomicReference<GitilesView> view = Atomics.newReference();
     HttpServlet testServlet = new HttpServlet() {
diff --git a/pom.xml b/pom.xml
index 17ebc66..c1b4555 100644
--- a/pom.xml
+++ b/pom.xml
@@ -70,6 +70,12 @@
 
       <dependency>
         <groupId>org.eclipse.jgit</groupId>
+        <artifactId>org.eclipse.jgit.archive</artifactId>
+        <version>${jgitVersion}</version>
+      </dependency>
+
+      <dependency>
+        <groupId>org.eclipse.jgit</groupId>
         <artifactId>org.eclipse.jgit.junit</artifactId>
         <version>${jgitVersion}</version>
         <exclusions>