Auto-dive into trees that contain only a single subtree

When walking into a tree in PathServlet, keep track of which path
components only contained a single subtree entry, and pass this
information along to GitilesView.getBreadcrumbs(). This creates URLs
with a special sentinel ?stay to disable auto-diving.

Bug: https://code.google.com/p/gitiles/issues/detail?id=8
Change-Id: I03bfb8a6e1974cc1c4b5d836c698311705569ff4
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 1d45101..e84bb2a 100644
--- a/gitiles-servlet/src/main/java/com/google/gitiles/GitilesView.java
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/GitilesView.java
@@ -14,9 +14,11 @@
 
 package com.google.gitiles;
 
+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 static com.google.gitiles.RevisionParser.PATH_SPLITTER;
 
 import com.google.common.base.Charsets;
 import com.google.common.base.Objects;
@@ -464,7 +466,28 @@
     return baseUrl + url.toString();
   }
 
+  /**
+   * @return a list of maps with "text" and "url" keys for all file paths
+   *     leading up to the path represented by this view. All URLs allow
+   *     auto-diving into one-entry subtrees; see also
+   *     {@link #getBreadcrumbs(List<Boolean>)}.
+   */
   public List<Map<String, String>> getBreadcrumbs() {
+    return getBreadcrumbs(null);
+  }
+
+  /**
+   * @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
+   *     only has a single entry that is another tree.
+   * @return a list of maps with "text" and "url" keys for all file paths
+   *     leading up to the path represented by this view. URLs whose
+   *     corresponding entry in {@code hasSingleTree} is true will disable
+   *     auto-diving into one-entry subtrees.
+   */
+  public List<Map<String, String>> getBreadcrumbs(List<Boolean> hasSingleTree) {
+    checkArgument(hasSingleTree == null || type == Type.PATH,
+        "hasSingleTree must be null for %s view", type);
     String path = this.path;
     ImmutableList.Builder<Map<String, String>> breadcrumbs = ImmutableList.builder();
     breadcrumbs.add(breadcrumb(hostName, hostIndex().copyFrom(this)));
@@ -492,15 +515,18 @@
         breadcrumbs.add(breadcrumb(".", copyWithPath().setTreePath("")));
       }
       StringBuilder cur = new StringBuilder();
-      boolean first = true;
-      for (String part : RevisionParser.PATH_SPLITTER.omitEmptyStrings().split(path)) {
-        if (!first) {
-          cur.append('/');
-        } else {
-          first = false;
+      List<String> parts = ImmutableList.copyOf(PATH_SPLITTER.omitEmptyStrings().split(path));
+      checkArgument(hasSingleTree == null || hasSingleTree.size() == parts.size() - 1,
+          "hasSingleTree has wrong number of entries");
+      for (int i = 0; i < parts.size(); i++) {
+        String part = parts.get(i);
+        cur.append(part).append('/');
+        String curPath = cur.toString();
+        Builder builder = copyWithPath().setTreePath(curPath);
+        if (hasSingleTree != null && i < parts.size() - 1 && hasSingleTree.get(i)) {
+          builder.replaceParam(PathServlet.AUTODIVE_PARAM, PathServlet.NO_AUTODIVE_VALUE);
         }
-        cur.append(part);
-        breadcrumbs.add(breadcrumb(part, copyWithPath().setTreePath(cur.toString())));
+        breadcrumbs.add(breadcrumb(part, builder));
       }
     }
     return breadcrumbs.build();
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/PathServlet.java b/gitiles-servlet/src/main/java/com/google/gitiles/PathServlet.java
index 384a589..2594d08 100644
--- a/gitiles-servlet/src/main/java/com/google/gitiles/PathServlet.java
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/PathServlet.java
@@ -22,12 +22,19 @@
 import static org.eclipse.jgit.lib.Constants.OBJ_TREE;
 
 import com.google.common.base.Joiner;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Lists;
 import com.google.common.collect.Maps;
+import com.google.common.primitives.Bytes;
 
 import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.errors.IncorrectObjectTypeException;
 import org.eclipse.jgit.errors.LargeObjectException;
+import org.eclipse.jgit.errors.MissingObjectException;
+import org.eclipse.jgit.errors.StopWalkException;
 import org.eclipse.jgit.http.server.ServletUtils;
+import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.FileMode;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectLoader;
@@ -37,12 +44,15 @@
 import org.eclipse.jgit.revwalk.RevTree;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.submodule.SubmoduleWalk;
+import org.eclipse.jgit.treewalk.CanonicalTreeParser;
 import org.eclipse.jgit.treewalk.TreeWalk;
+import org.eclipse.jgit.treewalk.filter.TreeFilter;
 import org.eclipse.jgit.util.RawParseUtils;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
 import java.io.IOException;
+import java.util.List;
 import java.util.Map;
 import java.util.regex.Pattern;
 
@@ -66,6 +76,9 @@
           "https?://code.google.com/p/.*",
           "https?://github.com/.*") + ")$", Pattern.CASE_INSENSITIVE);
 
+  static final String AUTODIVE_PARAM = "autodive";
+  static final String NO_AUTODIVE_VALUE = "0";
+
   static enum FileType {
     TREE(FileMode.TREE),
     SYMLINK(FileMode.SYMLINK),
@@ -116,43 +129,46 @@
           return;
       }
 
-      TreeWalk tw;
+      TreeWalk tw = new TreeWalk(rw.getObjectReader());
+      tw.addTree(root);
+      tw.setRecursive(false);
       FileType type;
-      ObjectId treeId = null;
       String path = view.getTreePath();
+      List<Boolean> hasSingleTree;
+
       if (path.isEmpty()) {
-        tw = new TreeWalk(rw.getObjectReader());
-        tw.addTree(root);
-        tw.setRecursive(false);
         type = FileType.TREE;
-        treeId = root;
+        hasSingleTree = ImmutableList.<Boolean> of();
       } else {
-        tw = TreeWalk.forPath(rw.getObjectReader(), path, root);
-        if (tw == null) {
+        hasSingleTree = walkToPath(tw, path);
+        if (hasSingleTree == null) {
           res.setStatus(SC_NOT_FOUND);
           return;
         }
         type = FileType.forEntry(tw);
-        if (type == FileType.TREE) {
-          treeId = tw.getObjectId(0);
-          tw.enterSubtree();
-          tw.setRecursive(false);
-        }
       }
 
       switch (type) {
         case TREE:
-          showTree(req, res, rw, tw, treeId);
+          ObjectId treeId;
+          if (path.isEmpty()) {
+            treeId = root;
+          } else {
+            treeId = tw.getObjectId(0);
+            tw.enterSubtree();
+            tw.setRecursive(false);
+          }
+          showTree(req, res, rw, tw, treeId, hasSingleTree);
           break;
         case SYMLINK:
-          showSymlink(req, res, rw, tw);
+          showSymlink(req, res, rw, tw, hasSingleTree);
           break;
         case REGULAR_FILE:
         case EXECUTABLE_FILE:
-          showFile(req, res, rw, tw);
+          showFile(req, res, rw, tw, hasSingleTree);
           break;
         case GITLINK:
-          showGitlink(req, res, rw, tw, root);
+          showGitlink(req, res, rw, tw, root, hasSingleTree);
           break;
         default:
           log.error("Bad file type: %s", type);
@@ -166,28 +182,138 @@
     }
   }
 
+  private static class AutoDiveFilter extends TreeFilter {
+    /** @see GitilesView#getBreadcrumbs(List<Boolean>) */
+    List<Boolean> hasSingleTree;
+
+    private final byte[] pathRaw;
+    private int count;
+    private boolean done;
+
+    AutoDiveFilter(String pathStr) {
+      hasSingleTree = Lists.newArrayList();
+      pathRaw = Constants.encode(pathStr);
+    }
+
+    @Override
+    public boolean include(TreeWalk tw) throws MissingObjectException,
+        IncorrectObjectTypeException, IOException {
+      count++;
+      int cmp = tw.isPathPrefix(pathRaw, pathRaw.length);
+      if (cmp > 0) {
+        throw StopWalkException.INSTANCE;
+      }
+      boolean include;
+      if (cmp == 0) {
+        if (!isDone(tw)) {
+          hasSingleTree.add(hasSingleTreeEntry(tw));
+        }
+        include = true;
+      } else {
+        include = false;
+      }
+      if (tw.isSubtree()) {
+        count = 0;
+      }
+      return include;
+    }
+
+    private boolean hasSingleTreeEntry(TreeWalk tw) throws IOException {
+      if (count != 1 || !FileMode.TREE.equals(tw.getRawMode(0))) {
+        return false;
+      }
+      CanonicalTreeParser p = new CanonicalTreeParser();
+      p.reset(tw.getObjectReader(), tw.getObjectId(0));
+      p.next();
+      return p.eof();
+    }
+
+    @Override
+    public boolean shouldBeRecursive() {
+      return Bytes.indexOf(pathRaw, (byte)'/') >= 0;
+    }
+
+    @Override
+    public TreeFilter clone() {
+      return this;
+    }
+
+    private boolean isDone(TreeWalk tw) {
+      if (!done) {
+        done = pathRaw.length == tw.getPathLength();
+      }
+      return done;
+    }
+  }
+
+  private List<Boolean> walkToPath(TreeWalk tw, String pathString) throws IOException {
+    AutoDiveFilter f = new AutoDiveFilter(pathString);
+    tw.setFilter(f);
+    while (tw.next()) {
+      if (f.isDone(tw)) {
+        return f.hasSingleTree;
+      } else if (tw.isSubtree()) {
+        tw.enterSubtree();
+      }
+    }
+    return null;
+  }
+
   private void showTree(HttpServletRequest req, HttpServletResponse res, RevWalk rw, TreeWalk tw,
-      ObjectId id) throws IOException {
+      ObjectId id, List<Boolean> hasSingleTree) throws IOException {
     GitilesView view = ViewFilter.getView(req);
+    List<String> autodive = view.getParameters().get(AUTODIVE_PARAM);
+    if (autodive.size() != 1 || !NO_AUTODIVE_VALUE.equals(autodive.get(0))) {
+      byte[] path = Constants.encode(view.getTreePath());
+      CanonicalTreeParser child = getOnlyChildSubtree(rw, id, path);
+      if (child != null) {
+        while (true) {
+          path = new byte[child.getEntryPathLength()];
+          System.arraycopy(child.getEntryPathBuffer(), 0, path, 0, child.getEntryPathLength());
+          CanonicalTreeParser next = getOnlyChildSubtree(rw, child.getEntryObjectId(), path);
+          if (next == null) {
+            break;
+          }
+          child = next;
+        }
+        res.sendRedirect(GitilesView.path().copyFrom(view)
+            .setTreePath(
+                RawParseUtils.decode(child.getEntryPathBuffer(), 0, child.getEntryPathLength()))
+            .toUrl());
+        return;
+      }
+    }
     // TODO(sop): Allow caching trees by SHA-1 when no S cookie is sent.
     render(req, res, "gitiles.pathDetail", ImmutableMap.of(
         "title", !view.getTreePath().isEmpty() ? view.getTreePath() : "/",
+        "breadcrumbs", view.getBreadcrumbs(hasSingleTree),
         "type", FileType.TREE.toString(),
         "data", new TreeSoyData(rw, view).toSoyData(id, tw)));
   }
 
-  private void showFile(HttpServletRequest req, HttpServletResponse res, RevWalk rw, TreeWalk tw)
+  private CanonicalTreeParser getOnlyChildSubtree(RevWalk rw, ObjectId id, byte[] prefix)
       throws IOException {
+    CanonicalTreeParser p = new CanonicalTreeParser(prefix, rw.getObjectReader(), id);
+    if (p.eof() || p.getEntryFileMode() != FileMode.TREE) {
+      return null;
+    }
+    p.next(1);
+    return p.eof() ? p : null;
+  }
+
+  private void showFile(HttpServletRequest req, HttpServletResponse res, RevWalk rw, TreeWalk tw,
+      List<Boolean> hasSingleTree) throws IOException {
     GitilesView view = ViewFilter.getView(req);
     // TODO(sop): Allow caching files by SHA-1 when no S cookie is sent.
     render(req, res, "gitiles.pathDetail", ImmutableMap.of(
         "title", ViewFilter.getView(req).getTreePath(),
+        "breadcrumbs", view.getBreadcrumbs(hasSingleTree),
         "type", FileType.forEntry(tw).toString(),
         "data", new BlobSoyData(rw, view).toSoyData(tw.getPathString(), tw.getObjectId(0))));
   }
 
   private void showSymlink(HttpServletRequest req, HttpServletResponse res, RevWalk rw,
-      TreeWalk tw) throws IOException {
+      TreeWalk tw, List<Boolean> hasSingleTree) throws IOException {
     GitilesView view = ViewFilter.getView(req);
     ObjectId id = tw.getObjectId(0);
     Map<String, Object> data = Maps.newHashMap();
@@ -204,6 +330,7 @@
       data.put("size", Long.toString(loader.getSize()));
       render(req, res, "gitiles.pathDetail", ImmutableMap.of(
           "title", ViewFilter.getView(req).getTreePath(),
+          "breadcrumbs", view.getBreadcrumbs(hasSingleTree),
           "type", FileType.REGULAR_FILE.toString(),
           "data", data));
       return;
@@ -224,6 +351,7 @@
     // TODO(sop): Allow caching files by SHA-1 when no S cookie is sent.
     render(req, res, "gitiles.pathDetail", ImmutableMap.of(
         "title", ViewFilter.getView(req).getTreePath(),
+        "breadcrumbs", view.getBreadcrumbs(hasSingleTree),
         "type", FileType.SYMLINK.toString(),
         "data", data));
   }
@@ -243,7 +371,7 @@
   }
 
   private void showGitlink(HttpServletRequest req, HttpServletResponse res, RevWalk rw,
-      TreeWalk tw, RevTree root) throws IOException {
+      TreeWalk tw, RevTree root, List<Boolean> hasSingleTree) throws IOException {
     GitilesView view = ViewFilter.getView(req);
     SubmoduleWalk sw = SubmoduleWalk.forPath(ServletUtils.getRepository(req), root,
         view.getTreePath());
diff --git a/gitiles-servlet/src/test/java/com/google/gitiles/GitilesViewTest.java b/gitiles-servlet/src/test/java/com/google/gitiles/GitilesViewTest.java
index cf246b9..1862467 100644
--- a/gitiles-servlet/src/test/java/com/google/gitiles/GitilesViewTest.java
+++ b/gitiles-servlet/src/test/java/com/google/gitiles/GitilesViewTest.java
@@ -498,6 +498,40 @@
         view.getBreadcrumbs());
   }
 
+  public void testBreadcrumbsHasSingleTree() throws Exception {
+    ObjectId id = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
+    GitilesView view = GitilesView.path()
+        .copyFrom(HOST)
+        .setRepositoryName("foo/bar")
+        .setRevision(Revision.unpeeled("master", id))
+        .setTreePath("/path/to/a/file")
+        .build();
+
+    assertEquals("/b/foo/bar/+/master/path/to/a/file", view.toUrl());
+    assertEquals(
+        ImmutableList.of(
+            breadcrumb("host", "/b/?format=HTML"),
+            breadcrumb("foo/bar", "/b/foo/bar/"),
+            breadcrumb("master", "/b/foo/bar/+/master"),
+            breadcrumb(".", "/b/foo/bar/+/master/"),
+            breadcrumb("path", "/b/foo/bar/+/master/path"),
+            breadcrumb("to", "/b/foo/bar/+/master/path/to?autodive=0"),
+            breadcrumb("a", "/b/foo/bar/+/master/path/to/a?autodive=0"),
+            breadcrumb("file", "/b/foo/bar/+/master/path/to/a/file")),
+        view.getBreadcrumbs(ImmutableList.of(false, true, true)));
+    assertEquals(
+        ImmutableList.of(
+            breadcrumb("host", "/b/?format=HTML"),
+            breadcrumb("foo/bar", "/b/foo/bar/"),
+            breadcrumb("master", "/b/foo/bar/+/master"),
+            breadcrumb(".", "/b/foo/bar/+/master/"),
+            breadcrumb("path", "/b/foo/bar/+/master/path?autodive=0"),
+            breadcrumb("to", "/b/foo/bar/+/master/path/to"),
+            breadcrumb("a", "/b/foo/bar/+/master/path/to/a"),
+            breadcrumb("file", "/b/foo/bar/+/master/path/to/a/file")),
+        view.getBreadcrumbs(ImmutableList.of(true, false, false)));
+  }
+
   private static ImmutableMap<String, String> breadcrumb(String text, String url) {
     return ImmutableMap.of("text", text, "url", url);
   }