Use HostIndex to display subtrees of repositories

If a repository does not exist try to list the repositories that
use that prefix, or 404 if the GitilesAccess instance returns no
matches. This allows listing a subtree of repositories without
needing to build up the entire HostIndex result set.

Change-Id: Ie3e046101919b6bedcc26198e455dface881315b
diff --git a/gitiles-dev/src/main/java/com/google/gitiles/dev/DevServer.java b/gitiles-dev/src/main/java/com/google/gitiles/dev/DevServer.java
index 82cdde0..ed9d203 100644
--- a/gitiles-dev/src/main/java/com/google/gitiles/dev/DevServer.java
+++ b/gitiles-dev/src/main/java/com/google/gitiles/dev/DevServer.java
@@ -277,7 +277,8 @@
     public GitilesAccess forRequest(HttpServletRequest req) {
       return new GitilesAccess() {
         @Override
-        public Map<String, RepositoryDescription> listRepositories(Set<String> branches) {
+        public Map<String, RepositoryDescription> listRepositories(
+            String prefix, Set<String> branches) {
           return Collections.emptyMap();
         }
 
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/DefaultAccess.java b/gitiles-servlet/src/main/java/com/google/gitiles/DefaultAccess.java
index d435289..5fda0df 100644
--- a/gitiles-servlet/src/main/java/com/google/gitiles/DefaultAccess.java
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/DefaultAccess.java
@@ -17,6 +17,7 @@
 import static com.google.common.base.Preconditions.checkNotNull;
 
 import com.google.common.base.CharMatcher;
+import com.google.common.base.Strings;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Maps;
 import com.google.common.collect.Queues;
@@ -105,10 +106,10 @@
   }
 
   @Override
-  public Map<String, RepositoryDescription> listRepositories(Set<String> branches)
-      throws IOException {
+  public Map<String, RepositoryDescription> listRepositories(String prefix,
+      Set<String> branches) throws IOException {
     Map<String, RepositoryDescription> repos = Maps.newTreeMap(US_COLLATOR);
-    for (Repository repo : scanRepositories(basePath, req)) {
+    for (Repository repo : scanRepositories(basePath, prefix, req)) {
       repos.put(getRepositoryName(repo), buildDescription(repo, branches));
       repo.close();
     }
@@ -219,15 +220,10 @@
     return "refs/heads/" + name;
   }
 
-  private Collection<Repository> scanRepositories(final File basePath, final HttpServletRequest req)
-      throws IOException {
+  private Collection<Repository> scanRepositories(File basePath, String prefix,
+      HttpServletRequest req) throws IOException {
     List<Repository> repos = Lists.newArrayList();
-    Queue<File> todo = Queues.newArrayDeque();
-    File[] baseFiles = basePath.listFiles();
-    if (baseFiles == null) {
-      throw new IOException("base path is not a directory: " + basePath.getPath());
-    }
-    Collections.addAll(todo, baseFiles);
+    Queue<File> todo = initScan(basePath, prefix);
     while (!todo.isEmpty()) {
       File file = todo.remove();
       try {
@@ -243,4 +239,28 @@
     }
     return repos;
   }
+
+  private Queue<File> initScan(File basePath, String prefix)
+      throws IOException {
+    Queue<File> todo = Queues.newArrayDeque();
+    File[] entries;
+    if (isValidPrefix(prefix)) {
+      entries = new File(basePath, CharMatcher.is('/').trimFrom(prefix)).listFiles();
+    } else {
+      entries = basePath.listFiles();
+    }
+    if (entries != null) {
+      Collections.addAll(todo, entries);
+    } else if (!basePath.isDirectory()) {
+      throw new IOException("base path is not a directory: " + basePath.getPath());
+    }
+    return todo;
+  }
+
+  private static boolean isValidPrefix(String prefix) {
+    return !Strings.isNullOrEmpty(prefix)
+        && !prefix.equals(".") && !prefix.equals("..")
+        && !prefix.contains("../")
+        && !prefix.endsWith("/..");
+  }
 }
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/GitilesAccess.java b/gitiles-servlet/src/main/java/com/google/gitiles/GitilesAccess.java
index 4572033..6434e55 100644
--- a/gitiles-servlet/src/main/java/com/google/gitiles/GitilesAccess.java
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/GitilesAccess.java
@@ -22,6 +22,7 @@
 import java.util.Map;
 import java.util.Set;
 
+import javax.annotation.Nullable;
 import javax.servlet.http.HttpServletRequest;
 
 /**
@@ -39,16 +40,22 @@
   /**
    * List repositories on the host.
    *
+   * @param prefix repository base path to list. Trailing "/" is implicitly
+   *        added if missing. Null or empty string will match all repositories.
    * @param branches branches to list along with each repository.
    * @return map of repository names to descriptions.
    * @throws ServiceNotEnabledException to trigger an HTTP 403 Forbidden
-   *     (matching behavior in {@link org.eclipse.jgit.http.server.RepositoryFilter}).
+   *         (matching behavior in
+   *         {@link org.eclipse.jgit.http.server.RepositoryFilter}).
    * @throws ServiceNotAuthorizedException to trigger an HTTP 401 Unauthorized
-   *     (matching behavior in {@link org.eclipse.jgit.http.server.RepositoryFilter}).
+   *         (matching behavior in
+   *         {@link org.eclipse.jgit.http.server.RepositoryFilter}).
    * @throws IOException if an error occurred.
    */
-  public Map<String, RepositoryDescription> listRepositories(Set<String> branches)
-      throws ServiceNotEnabledException, ServiceNotAuthorizedException, IOException;
+  public Map<String, RepositoryDescription> listRepositories(
+      @Nullable String prefix, Set<String> branches)
+          throws ServiceNotEnabledException, ServiceNotAuthorizedException,
+          IOException;
 
   /**
    * @return an opaque object that uniquely identifies the end-user making the
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 2734380..d1960b8 100644
--- a/gitiles-servlet/src/main/java/com/google/gitiles/GitilesFilter.java
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/GitilesFilter.java
@@ -18,7 +18,6 @@
 import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.gitiles.GitilesServlet.STATIC_PREFIX;
-import static com.google.gitiles.ViewFilter.getRegexGroup;
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.collect.FluentIterable;
@@ -33,17 +32,11 @@
 import com.google.gitiles.doc.DocServlet;
 
 import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
-import org.eclipse.jgit.http.server.RepositoryFilter;
 import org.eclipse.jgit.http.server.glue.MetaFilter;
 import org.eclipse.jgit.http.server.glue.ServletBinder;
 import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.transport.ServiceMayNotContinueException;
 import org.eclipse.jgit.transport.resolver.FileResolver;
 import org.eclipse.jgit.transport.resolver.RepositoryResolver;
-import org.eclipse.jgit.transport.resolver.ServiceNotAuthorizedException;
-import org.eclipse.jgit.transport.resolver.ServiceNotEnabledException;
 
 import java.io.File;
 import java.io.IOException;
@@ -195,7 +188,7 @@
     this.blameCache = blameCache;
     this.gitwebRedirect = gitwebRedirect;
     if (resolver != null) {
-      this.resolver = wrapResolver(resolver);
+      this.resolver = resolver;
     }
   }
 
@@ -285,19 +278,6 @@
     checkState(!initialized, "Gitiles already initialized");
   }
 
-  private static RepositoryResolver<HttpServletRequest> wrapResolver(
-      final RepositoryResolver<HttpServletRequest> resolver) {
-    checkNotNull(resolver, "resolver");
-    return new RepositoryResolver<HttpServletRequest>() {
-      @Override
-      public Repository open(HttpServletRequest req, String name)
-          throws RepositoryNotFoundException, ServiceNotAuthorizedException,
-          ServiceNotEnabledException, ServiceMayNotContinueException {
-        return resolver.open(req, ViewFilter.trimLeadingSlash(getRegexGroup(req, 1)));
-      }
-    };
-  }
-
   private synchronized Linkifier linkifier() {
     if (linkifier == null) {
       checkState(urls != null, "GitilesUrls not yet set");
@@ -362,7 +342,7 @@
       FileResolver<HttpServletRequest> fileResolver;
       if (resolver == null) {
         fileResolver = new FileResolver<>(new File(basePath), exportAll);
-        resolver = wrapResolver(fileResolver);
+        resolver = fileResolver;
       } else if (resolver instanceof FileResolver) {
         fileResolver = (FileResolver<HttpServletRequest>) resolver;
       } else {
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 54cf377..0e7dc59 100644
--- a/gitiles-servlet/src/main/java/com/google/gitiles/GitilesView.java
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/GitilesView.java
@@ -23,7 +23,9 @@
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Joiner;
 import com.google.common.base.MoreObjects.ToStringHelper;
+import com.google.common.base.Splitter;
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
@@ -36,6 +38,7 @@
 
 import java.io.UnsupportedEncodingException;
 import java.net.URLEncoder;
+import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.EnumSet;
 import java.util.List;
@@ -87,6 +90,7 @@
 
     private String hostName;
     private String servletPath;
+    private String repositoryPrefix;
     private String repositoryName;
     private Revision revision = Revision.NULL;
     private Revision oldRevision = Revision.NULL;
@@ -106,6 +110,9 @@
       hostName = other.hostName;
       servletPath = other.servletPath;
       switch (type) {
+        case HOST_INDEX:
+          repositoryPrefix = other.repositoryPrefix;
+          break;
         case LOG:
         case DIFF:
           oldRevision = other.oldRevision;
@@ -161,6 +168,19 @@
       return servletPath;
     }
 
+    public Builder setRepositoryPrefix(String prefix) {
+      switch (type) {
+        case HOST_INDEX:
+          this.repositoryPrefix = prefix != null
+              ? Strings.emptyToNull(maybeTrimLeadingAndTrailingSlash(prefix))
+              : null;
+          return this;
+        default:
+          throw new IllegalStateException(
+              String.format("cannot set repository prefix on %s view", type));
+      }
+    }
+
     public Builder setRepositoryName(String repositoryName) {
       switch (type) {
         case HOST_INDEX:
@@ -342,8 +362,9 @@
           checkRootedDoc();
           break;
       }
-      return new GitilesView(type, hostName, servletPath, repositoryName, revision,
-          oldRevision, path, extension, params, anchor);
+      return new GitilesView(type, hostName, servletPath, repositoryPrefix,
+          repositoryName, revision, oldRevision, path, extension, params,
+          anchor);
     }
 
     public String toUrl() {
@@ -470,6 +491,7 @@
   private final Type type;
   private final String hostName;
   private final String servletPath;
+  private final String repositoryPrefix;
   private final String repositoryName;
   private final Revision revision;
   private final Revision oldRevision;
@@ -481,6 +503,7 @@
   private GitilesView(Type type,
       String hostName,
       String servletPath,
+      String repositoryPrefix,
       String repositoryName,
       Revision revision,
       Revision oldRevision,
@@ -491,6 +514,7 @@
     this.type = type;
     this.hostName = hostName;
     this.servletPath = servletPath;
+    this.repositoryPrefix = repositoryPrefix;
     this.repositoryName = repositoryName;
     this.revision = firstNonNull(revision, Revision.NULL);
     this.oldRevision = firstNonNull(oldRevision, Revision.NULL);
@@ -516,6 +540,10 @@
     return servletPath;
   }
 
+  public String getRepositoryPrefix() {
+    return repositoryPrefix;
+  }
+
   public String getRepositoryName() {
     return repositoryName;
   }
@@ -574,6 +602,7 @@
         .omitNullValues()
         .add("host", hostName)
         .add("servlet", servletPath)
+        .add("prefix", repositoryPrefix)
         .add("repo", repositoryName)
         .add("rev", revision)
         .add("old", oldRevision)
@@ -592,8 +621,11 @@
     ListMultimap<String, String> params = this.params;
     switch (type) {
       case HOST_INDEX:
+        if (repositoryPrefix != null) {
+          url.append(repositoryPrefix).append('/');
+        }
         params = LinkedListMultimap.create();
-        if (!this.params.containsKey("format")) {
+        if (repositoryPrefix == null && !this.params.containsKey("format")) {
           params.put("format", FormatType.HTML.toString());
         }
         params.putAll(this.params);
@@ -712,9 +744,11 @@
         "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)));
-    if (repositoryName != null) {
-      breadcrumbs.add(breadcrumb(repositoryName, repositoryIndex().copyFrom(this)));
+    breadcrumbs.add(breadcrumb(hostName, hostIndex().copyFrom(this).setRepositoryPrefix(null)));
+    if (repositoryPrefix != null) {
+      breadcrumbs.addAll(hostIndexBreadcrumbs(repositoryPrefix));
+    } else if (repositoryName != null) {
+      breadcrumbs.addAll(hostIndexBreadcrumbs(repositoryName));
     }
     if (type == Type.DIFF) {
       // TODO(dborowitz): Tweak the breadcrumbs template to allow us to render
@@ -762,6 +796,18 @@
     return breadcrumbs.build();
   }
 
+  private List<Map<String, String>> hostIndexBreadcrumbs(String name) {
+    List<String> parts = Splitter.on('/').splitToList(name);
+    List<Map<String, String>> r = new ArrayList<>(parts.size());
+    for (int i = 0; i < parts.size(); i++) {
+      String prefix = Joiner.on('/').join(parts.subList(0, i + 1));
+      r.add(breadcrumb(
+          parts.get(i),
+          hostIndex().copyFrom(this).setRepositoryPrefix(prefix)));
+    }
+    return r;
+  }
+
   private static Map<String, String> breadcrumb(String text, Builder url) {
     return ImmutableMap.of("text", text, "url", url.toUrl());
   }
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/HostIndexServlet.java b/gitiles-servlet/src/main/java/com/google/gitiles/HostIndexServlet.java
index 2d52359..aa2208a 100644
--- a/gitiles-servlet/src/main/java/com/google/gitiles/HostIndexServlet.java
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/HostIndexServlet.java
@@ -24,6 +24,7 @@
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Sets;
 import com.google.gson.reflect.TypeToken;
+import com.google.template.soy.data.SoyData;
 import com.google.template.soy.data.SoyListData;
 import com.google.template.soy.data.SoyMapData;
 
@@ -37,9 +38,12 @@
 import java.io.IOException;
 import java.io.Writer;
 import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
 import java.util.Map;
 import java.util.Set;
 
+import javax.annotation.Nullable;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
 
@@ -56,16 +60,12 @@
     this.urls = checkNotNull(urls, "urls");
   }
 
-  private Map<String, RepositoryDescription> getDescriptions(HttpServletRequest req,
-      HttpServletResponse res) throws IOException {
-    return getDescriptions(req, res, parseShowBranch(req));
-  }
-
-  private Map<String, RepositoryDescription> getDescriptions(HttpServletRequest req,
-      HttpServletResponse res, Set<String> branches) throws IOException {
+  private Map<String, RepositoryDescription> list(
+      HttpServletRequest req, HttpServletResponse res, String prefix,
+      Set<String> branches) throws IOException {
     Map<String, RepositoryDescription> descs;
     try {
-      descs = getAccess(req).listRepositories(branches);
+      descs = getAccess(req).listRepositories(prefix, branches);
     } catch (RepositoryNotFoundException e) {
       res.sendError(SC_NOT_FOUND);
       return null;
@@ -85,12 +85,17 @@
       res.sendError(SC_SERVICE_UNAVAILABLE);
       return null;
     }
+    if (prefix != null && descs.isEmpty()) {
+      res.sendError(SC_NOT_FOUND);
+      return null;
+    }
     return descs;
   }
 
-  private SoyMapData toSoyMapData(RepositoryDescription desc, GitilesView view) {
+  private SoyMapData toSoyMapData(RepositoryDescription desc,
+      @Nullable String prefix, GitilesView view) {
     return new SoyMapData(
-        "name", desc.name,
+        "name", stripPrefix(prefix, desc.name),
         "description", Strings.nullToEmpty(desc.description),
         "url", GitilesView.repositoryIndex()
             .copyFrom(view)
@@ -100,25 +105,37 @@
 
   @Override
   protected void doGetHtml(HttpServletRequest req, HttpServletResponse res) throws IOException {
-    Map<String, RepositoryDescription> descs = getDescriptions(req, res);
+    GitilesView view = ViewFilter.getView(req);
+    String prefix = view.getRepositoryPrefix();
+    Map<String, RepositoryDescription> descs = list(req, res, prefix, parseShowBranch(req));
     if (descs == null) {
       return;
     }
+
     SoyListData repos = new SoyListData();
     for (RepositoryDescription desc : descs.values()) {
-      repos.add(toSoyMapData(desc, ViewFilter.getView(req)));
+      repos.add(toSoyMapData(desc, prefix, view));
     }
 
+    String hostName = urls.getHostName(req);
+    List<Map<String, String>> breadcrumbs = null;
+    if (prefix != null) {
+      hostName = hostName + '/' + prefix;
+      breadcrumbs = view.getBreadcrumbs();
+    }
     renderHtml(req, res, "gitiles.hostIndex", ImmutableMap.of(
-        "hostName", urls.getHostName(req),
+        "hostName", hostName,
+        "breadcrumbs", SoyData.createFromExistingData(breadcrumbs),
         "baseUrl", urls.getBaseGitUrl(req),
+        "prefix", prefix != null ? prefix + '/' : "",
         "repositories", repos));
   }
 
   @Override
   protected void doGetText(HttpServletRequest req, HttpServletResponse res) throws IOException {
+    String prefix = ViewFilter.getView(req).getRepositoryPrefix();
     Set<String> branches = parseShowBranch(req);
-    Map<String, RepositoryDescription> descs = getDescriptions(req, res, branches);
+    Map<String, RepositoryDescription> descs = list(req, res, prefix, branches);
     if (descs == null) {
       return;
     }
@@ -134,7 +151,7 @@
         writer.write(ref);
         writer.write(' ');
       }
-      writer.write(GitilesUrls.NAME_ESCAPER.apply(repo.name));
+      writer.write(GitilesUrls.NAME_ESCAPER.apply(stripPrefix(prefix, repo.name)));
       writer.write('\n');
     }
     writer.flush();
@@ -143,13 +160,25 @@
 
   @Override
   protected void doGetJson(HttpServletRequest req, HttpServletResponse res) throws IOException {
-    Map<String, RepositoryDescription> descs = getDescriptions(req, res);
+    String prefix = ViewFilter.getView(req).getRepositoryPrefix();
+    Map<String, RepositoryDescription> descs = list(req, res, prefix, parseShowBranch(req));
     if (descs == null) {
       return;
     }
+    if (prefix != null) {
+      Map<String, RepositoryDescription> r = new LinkedHashMap<>();
+      for (Map.Entry<String, RepositoryDescription> e : descs.entrySet()) {
+        r.put(stripPrefix(prefix, e.getKey()), e.getValue());
+      }
+      descs = r;
+    }
     renderJson(req, res, descs, new TypeToken<Map<String, RepositoryDescription>>() {}.getType());
   }
 
+  private static String stripPrefix(@Nullable String prefix, String name) {
+    return prefix != null ? name.substring(prefix.length() + 1) : name;
+  }
+
   private static Set<String> parseShowBranch(HttpServletRequest req) {
     // Roughly match Gerrit Code Review's /projects/ API by supporting
     // both show-branch and b as query parameters.
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/RepositoryFilter.java b/gitiles-servlet/src/main/java/com/google/gitiles/RepositoryFilter.java
new file mode 100644
index 0000000..6eb5033
--- /dev/null
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/RepositoryFilter.java
@@ -0,0 +1,69 @@
+// Copyright 2015 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 com.google.common.base.Preconditions.checkNotNull;
+import static com.google.gitiles.ViewFilter.getRegexGroup;
+import static javax.servlet.http.HttpServletResponse.SC_FORBIDDEN;
+import static javax.servlet.http.HttpServletResponse.SC_UNAUTHORIZED;
+import static org.eclipse.jgit.http.server.GitSmartHttpTools.sendError;
+import static org.eclipse.jgit.http.server.ServletUtils.ATTRIBUTE_REPOSITORY;
+
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.transport.ServiceMayNotContinueException;
+import org.eclipse.jgit.transport.resolver.RepositoryResolver;
+import org.eclipse.jgit.transport.resolver.ServiceNotAuthorizedException;
+import org.eclipse.jgit.transport.resolver.ServiceNotEnabledException;
+
+import java.io.IOException;
+
+import javax.servlet.FilterChain;
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+class RepositoryFilter extends AbstractHttpFilter {
+  private final RepositoryResolver<HttpServletRequest> resolver;
+
+  RepositoryFilter(RepositoryResolver<HttpServletRequest> resolver) {
+    this.resolver = checkNotNull(resolver, "resolver");
+  }
+
+  @Override
+  public void doFilter(HttpServletRequest req, HttpServletResponse res,
+      FilterChain chain) throws IOException, ServletException {
+    try {
+      String repo = ViewFilter.trimLeadingSlash(getRegexGroup(req, 1));
+      try (Repository git = resolver.open(req, repo)) {
+        req.setAttribute(ATTRIBUTE_REPOSITORY, git);
+        chain.doFilter(req, res);
+      } catch (RepositoryNotFoundException e) {
+        // Drop through the rest of the chain. ViewFilter will pass this
+        // to HostIndexServlet which will attempt to list repositories
+        // or send SC_NOT_FOUND there.
+        chain.doFilter(req, res);
+      } catch (ServiceMayNotContinueException e) {
+        sendError(req, res, SC_FORBIDDEN, e.getMessage());
+      } finally {
+        req.removeAttribute(ATTRIBUTE_REPOSITORY);
+      }
+    } catch (ServiceNotEnabledException e) {
+      sendError(req, res, SC_FORBIDDEN);
+    } catch (ServiceNotAuthorizedException e) {
+      res.sendError(SC_UNAUTHORIZED);
+    }
+  }
+}
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 390a271..9792270 100644
--- a/gitiles-servlet/src/main/java/com/google/gitiles/ViewFilter.java
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/ViewFilter.java
@@ -18,6 +18,7 @@
 import static com.google.common.base.Preconditions.checkNotNull;
 import static javax.servlet.http.HttpServletResponse.SC_NOT_FOUND;
 import static javax.servlet.http.HttpServletResponse.SC_SERVICE_UNAVAILABLE;
+import static org.eclipse.jgit.http.server.ServletUtils.ATTRIBUTE_REPOSITORY;
 
 import com.google.common.base.Strings;
 
@@ -152,7 +153,7 @@
     String path = getRegexGroup(req, 3);
 
     if (command.isEmpty()) {
-      return parseNoCommand(repoName);
+      return parseNoCommand(req, repoName);
     } else if (command.equals(CMD_ARCHIVE)) {
       return parseArchiveCommand(req, repoName, path);
     } else if (command.equals(CMD_AUTO)) {
@@ -176,7 +177,11 @@
     }
   }
 
-  private GitilesView.Builder parseNoCommand(String repoName) {
+  private GitilesView.Builder parseNoCommand(HttpServletRequest req,
+      String repoName) {
+    if (req.getAttribute(ATTRIBUTE_REPOSITORY) == null) {
+      return GitilesView.hostIndex().setRepositoryPrefix(repoName);
+    }
     return GitilesView.repositoryIndex().setRepositoryName(repoName);
   }
 
diff --git a/gitiles-servlet/src/main/resources/com/google/gitiles/templates/HostIndex.soy b/gitiles-servlet/src/main/resources/com/google/gitiles/templates/HostIndex.soy
index e0ed398..ec9553d 100644
--- a/gitiles-servlet/src/main/resources/com/google/gitiles/templates/HostIndex.soy
+++ b/gitiles-servlet/src/main/resources/com/google/gitiles/templates/HostIndex.soy
@@ -19,31 +19,36 @@
  * @param hostName host name.
  * @param? menuEntries menu entries.
  * @param? headerVariant variant name for custom header.
+ * @param? prefix prefix path for matching repositories.
+ * @param? breadcrumbs map of breadcrumbs for header.
  * @param baseUrl base URL for repositories.
  * @param repositories list of repository description maps with name, cloneUrl,
  *     and optional description values.
  */
 {template .hostIndex}
 {call .header}
-  {param title: $hostName ? $hostName + ' Git repositories' : 'Git repositories' /}
+  {param title: $prefix ? $prefix : $hostName ? $hostName + ' Git repositories' : 'Git repositories' /}
   {param menuEntries: $menuEntries /}
-  {param breadcrumbs: null /}
+  {param breadcrumbs: $breadcrumbs /}
   {param headerVariant: $headerVariant /}
 {/call}
 
 {if length($repositories)}
-
-  <h2>
-    {msg desc="Git repositories available on the host"}
-      {$hostName} Git repositories
-    {/msg}
-  </h2>
+  {if not $breadcrumbs}
+    <h2>
+      {msg desc="Git repositories available on the host"}
+        {$hostName} Git repositories
+      {/msg}
+    </h2>
+  {else}
+    <br />
+  {/if}
 
   <div class="instructions">
     {msg desc="description on how to use this repository"}
     To clone one of these repositories, install{sp}
     <a href="http://www.git-scm.com/">git</a>, and run:
-    <pre>git clone {$baseUrl}<em>name</em></pre>
+    <pre>git clone {$baseUrl}{$prefix}<em>name</em></pre>
     {/msg}
   </div>
 
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 9197c0c..646024d 100644
--- a/gitiles-servlet/src/test/java/com/google/gitiles/GitilesViewTest.java
+++ b/gitiles-servlet/src/test/java/com/google/gitiles/GitilesViewTest.java
@@ -72,6 +72,35 @@
   }
 
   @Test
+  public void hostIndexOneComponentPrefix() throws Exception {
+    GitilesView view = GitilesView.hostIndex()
+        .copyFrom(HOST)
+        .setRepositoryPrefix("foo")
+        .build();
+
+    assertEquals("/b/foo/", view.toUrl());
+    assertEquals(ImmutableList.of(
+        ImmutableMap.of("text", "host", "url", "/b/?format=HTML"),
+        ImmutableMap.of("text", "foo", "url", "/b/foo/")),
+      view.getBreadcrumbs());
+  }
+
+  @Test
+  public void hostIndexTwoComponentPrefix() throws Exception {
+    GitilesView view = GitilesView.hostIndex()
+        .copyFrom(HOST)
+        .setRepositoryPrefix("foo/bar")
+        .build();
+
+    assertEquals("/b/foo/bar/", view.toUrl());
+    assertEquals(ImmutableList.of(
+          ImmutableMap.of("text", "host", "url", "/b/?format=HTML"),
+          ImmutableMap.of("text", "foo", "url", "/b/foo/"),
+          ImmutableMap.of("text", "bar", "url", "/b/foo/bar/")),
+        view.getBreadcrumbs());
+  }
+
+  @Test
   public void queryParams() throws Exception {
     GitilesView view = GitilesView.log().copyFrom(HOST)
         .setRepositoryName("repo")
@@ -133,7 +162,8 @@
     assertEquals(
         ImmutableList.of(
             breadcrumb("host", "/b/?format=HTML"),
-            breadcrumb("foo/bar", "/b/foo/bar/")),
+            breadcrumb("foo", "/b/foo/"),
+            breadcrumb("bar", "/b/foo/bar/")),
         view.getBreadcrumbs());
   }
 
@@ -156,7 +186,8 @@
     assertEquals(
         ImmutableList.of(
             breadcrumb("host", "/b/?format=HTML"),
-            breadcrumb("foo/bar", "/b/foo/bar/")),
+            breadcrumb("foo", "/b/foo/"),
+            breadcrumb("bar", "/b/foo/bar/")),
         view.getBreadcrumbs());
   }
 
@@ -182,7 +213,8 @@
     assertEquals(
         ImmutableList.of(
             breadcrumb("host", "/b/?format=HTML"),
-            breadcrumb("foo/bar", "/b/foo/bar/"),
+            breadcrumb("foo", "/b/foo/"),
+            breadcrumb("bar", "/b/foo/bar/"),
             breadcrumb("master", "/b/foo/bar/+/master")),
         view.getBreadcrumbs());
   }
@@ -227,7 +259,8 @@
     assertEquals(
         ImmutableList.of(
             breadcrumb("host", "/b/?format=HTML"),
-            breadcrumb("foo/bar", "/b/foo/bar/"),
+            breadcrumb("foo", "/b/foo/"),
+            breadcrumb("bar", "/b/foo/bar/"),
             breadcrumb("master", "/b/foo/bar/+/master"),
             breadcrumb(".", "/b/foo/bar/+/master/")),
         view.getBreadcrumbs());
@@ -256,7 +289,8 @@
     assertEquals(
         ImmutableList.of(
             breadcrumb("host", "/b/?format=HTML"),
-            breadcrumb("foo/bar", "/b/foo/bar/"),
+            breadcrumb("foo", "/b/foo/"),
+            breadcrumb("bar", "/b/foo/bar/"),
             breadcrumb("master", "/b/foo/bar/+/master"),
             breadcrumb(".", "/b/foo/bar/+/master/"),
             breadcrumb("file", "/b/foo/bar/+/master/file")),
@@ -368,7 +402,8 @@
     assertEquals(
         ImmutableList.of(
             breadcrumb("host", "/b/?format=HTML"),
-            breadcrumb("foo/bar", "/b/foo/bar/"),
+            breadcrumb("foo", "/b/foo/"),
+            breadcrumb("bar", "/b/foo/bar/"),
             breadcrumb("master", "/b/foo/bar/+/master"),
             breadcrumb(".", "/b/foo/bar/+/master/"),
             breadcrumb("path", "/b/foo/bar/+/master/path"),
@@ -404,7 +439,8 @@
     assertEquals(
         ImmutableList.of(
             breadcrumb("host", "/b/?format=HTML"),
-            breadcrumb("foo/bar", "/b/foo/bar/"),
+            breadcrumb("foo", "/b/foo/"),
+            breadcrumb("bar", "/b/foo/bar/"),
             breadcrumb("master^!", "/b/foo/bar/+/master%5E%21/"),
             breadcrumb(".", "/b/foo/bar/+/master%5E%21/"),
             breadcrumb("path", "/b/foo/bar/+/master%5E%21/path"),
@@ -438,7 +474,8 @@
     assertEquals(
         ImmutableList.of(
             breadcrumb("host", "/b/?format=HTML"),
-            breadcrumb("foo/bar", "/b/foo/bar/"),
+            breadcrumb("foo", "/b/foo/"),
+            breadcrumb("bar", "/b/foo/bar/"),
             breadcrumb("master^!", "/b/foo/bar/+/master%5E%21/"),
             breadcrumb(".", "/b/foo/bar/+/master%5E%21/"),
             breadcrumb("path", "/b/foo/bar/+/master%5E%21/path"),
@@ -474,7 +511,8 @@
     assertEquals(
         ImmutableList.of(
             breadcrumb("host", "/b/?format=HTML"),
-            breadcrumb("foo/bar", "/b/foo/bar/"),
+            breadcrumb("foo", "/b/foo/"),
+            breadcrumb("bar", "/b/foo/bar/"),
             breadcrumb("efab5678..master", "/b/foo/bar/+/efab5678..master/"),
             breadcrumb(".", "/b/foo/bar/+/efab5678..master/"),
             breadcrumb("path", "/b/foo/bar/+/efab5678..master/path"),
@@ -507,7 +545,8 @@
     assertEquals(
         ImmutableList.of(
             breadcrumb("host", "/b/?format=HTML"),
-            breadcrumb("foo/bar", "/b/foo/bar/"),
+            breadcrumb("foo", "/b/foo/"),
+            breadcrumb("bar", "/b/foo/bar/"),
             breadcrumb("master", "/b/foo/bar/+log/master")),
         view.getBreadcrumbs());
   }
@@ -535,7 +574,8 @@
     assertEquals(
         ImmutableList.of(
             breadcrumb("host", "/b/?format=HTML"),
-            breadcrumb("foo/bar", "/b/foo/bar/"),
+            breadcrumb("foo", "/b/foo/"),
+            breadcrumb("bar", "/b/foo/bar/"),
             breadcrumb("abcd1234", "/b/foo/bar/+log/abcd1234")),
         view.getBreadcrumbs());
   }
@@ -564,7 +604,8 @@
     assertEquals(
         ImmutableList.of(
             breadcrumb("host", "/b/?format=HTML"),
-            breadcrumb("foo/bar", "/b/foo/bar/"),
+            breadcrumb("foo", "/b/foo/"),
+            breadcrumb("bar", "/b/foo/bar/"),
             breadcrumb("master", "/b/foo/bar/+log/master"),
             breadcrumb("path", "/b/foo/bar/+log/master/path"),
             breadcrumb("to", "/b/foo/bar/+log/master/path/to"),
@@ -599,7 +640,8 @@
     assertEquals(
         ImmutableList.of(
             breadcrumb("host", "/b/?format=HTML"),
-            breadcrumb("foo/bar", "/b/foo/bar/"),
+            breadcrumb("foo", "/b/foo/"),
+            breadcrumb("bar", "/b/foo/bar/"),
             breadcrumb("master^..master", "/b/foo/bar/+log/master%5E..master"),
             breadcrumb("path", "/b/foo/bar/+log/master%5E..master/path"),
             breadcrumb("to", "/b/foo/bar/+log/master%5E..master/path/to"),
@@ -627,7 +669,8 @@
     assertEquals(
         ImmutableList.of(
             breadcrumb("host", "/b/?format=HTML"),
-            breadcrumb("foo/bar", "/b/foo/bar/"),
+            breadcrumb("foo", "/b/foo/"),
+            breadcrumb("bar", "/b/foo/bar/"),
             breadcrumb("HEAD", "/b/foo/bar/+log")),
         view.getBreadcrumbs());
   }
@@ -700,7 +743,8 @@
     assertEquals(
         ImmutableList.of(
             breadcrumb("host", "/b/?format=HTML"),
-            breadcrumb("foo/bar", "/b/foo/bar/"),
+            breadcrumb("foo", "/b/foo/"),
+            breadcrumb("bar", "/b/foo/bar/"),
             breadcrumb("master", "/b/foo/bar/+/master"),
             breadcrumb(".", "/b/foo/bar/+/master/"),
             breadcrumb("dir", "/b/foo/bar/+/master/dir"),
@@ -770,7 +814,8 @@
     assertEquals(
         ImmutableList.of(
             breadcrumb("host", "/b/?format=HTML"),
-            breadcrumb("foo/bar", "/b/foo/bar/"),
+            breadcrumb("foo", "/b/foo/"),
+            breadcrumb("bar", "/b/foo/bar/"),
             breadcrumb("master", "/b/foo/bar/+/master"),
             breadcrumb(".", "/b/foo/bar/+/master/"),
             breadcrumb("path", "/b/foo/bar/+/master/path"),
@@ -781,7 +826,8 @@
     assertEquals(
         ImmutableList.of(
             breadcrumb("host", "/b/?format=HTML"),
-            breadcrumb("foo/bar", "/b/foo/bar/"),
+            breadcrumb("foo", "/b/foo/"),
+            breadcrumb("bar", "/b/foo/bar/"),
             breadcrumb("master", "/b/foo/bar/+/master"),
             breadcrumb(".", "/b/foo/bar/+/master/"),
             breadcrumb("path", "/b/foo/bar/+/master/path?autodive=0"),
@@ -805,7 +851,8 @@
     assertEquals(
         ImmutableList.of(
             breadcrumb("host", "/b/?format=HTML"),
-            breadcrumb("foo/bar", "/b/foo/bar/"),
+            breadcrumb("foo", "/b/foo/"),
+            breadcrumb("bar", "/b/foo/bar/"),
             breadcrumb("master", "/b/foo/bar/+/master"),
             breadcrumb(".", "/b/foo/bar/+/master/")),
         view.getBreadcrumbs(ImmutableList.<Boolean> of()));
diff --git a/gitiles-servlet/src/test/java/com/google/gitiles/HostIndexServletTest.java b/gitiles-servlet/src/test/java/com/google/gitiles/HostIndexServletTest.java
new file mode 100644
index 0000000..f2e03e9
--- /dev/null
+++ b/gitiles-servlet/src/test/java/com/google/gitiles/HostIndexServletTest.java
@@ -0,0 +1,157 @@
+// Copyright (C) 2015 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 com.google.gitiles.TestGitilesUrls.URLS;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.*;
+
+import com.google.gson.reflect.TypeToken;
+import com.google.template.soy.data.SoyListData;
+import com.google.template.soy.data.SoyMapData;
+import com.google.template.soy.data.restricted.NullData;
+
+import org.eclipse.jgit.internal.storage.dfs.DfsRepository;
+import org.eclipse.jgit.internal.storage.dfs.DfsRepositoryDescription;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.util.Map;
+
+/** Tests for {@link HostIndexServlet}. */
+public class HostIndexServletTest extends ServletTest {
+  private static final String NAME = "foo/bar/repo";
+
+  @Override
+  @Before
+  public void setUp() throws Exception {
+    repo = new TestRepository<DfsRepository>(
+        new InMemoryRepository(new DfsRepositoryDescription(NAME)));
+    servlet = TestGitilesServlet.create(repo);
+  }
+
+  @Test
+  public void rootHtml() throws Exception {
+    Map<String, ?> data = buildData("/");
+    assertEquals(URLS.getHostName(null), data.get("hostName"));
+    assertSame(NullData.INSTANCE, data.get("breadcrumbs"));
+    assertEquals("", data.get("prefix"));
+
+    SoyListData repos = (SoyListData) data.get("repositories");
+    assertEquals(1, repos.length());
+
+    SoyMapData ent = (SoyMapData) repos.get(0);
+    assertEquals(NAME, ent.get("name").toString());
+    assertEquals("/b/" + NAME + "/", ent.get("url").toString());
+  }
+
+  @Test
+  public void fooSubdirHtml() throws Exception {
+    Map<String, ?> data = buildData("/foo/");
+    assertEquals(URLS.getHostName(null) + "/foo", data.get("hostName"));
+    assertEquals("foo/", data.get("prefix"));
+
+    SoyListData breadcrumbs = (SoyListData) data.get("breadcrumbs");
+    assertEquals(2, breadcrumbs.length());
+
+    SoyListData repos = (SoyListData) data.get("repositories");
+    assertEquals(1, repos.length());
+
+    SoyMapData ent = (SoyMapData) repos.get(0);
+    assertEquals("bar/repo", ent.get("name").toString());
+    assertEquals("/b/" + NAME + "/", ent.get("url").toString());
+  }
+
+  @Test
+  public void fooBarSubdirHtml() throws Exception {
+    Map<String, ?> data = buildData("/foo/bar/");
+    assertEquals(URLS.getHostName(null) + "/foo/bar", data.get("hostName"));
+    assertEquals("foo/bar/", data.get("prefix"));
+
+    SoyListData breadcrumbs = (SoyListData) data.get("breadcrumbs");
+    assertEquals(3, breadcrumbs.length());
+
+    SoyListData repos = (SoyListData) data.get("repositories");
+    assertEquals(1, repos.length());
+
+    SoyMapData ent = (SoyMapData) repos.get(0);
+    assertEquals("repo", ent.get("name").toString());
+    assertEquals("/b/" + NAME + "/", ent.get("url").toString());
+  }
+
+  @Test
+  public void rootText() throws Exception {
+    String name = repo.getRepository().getDescription().getRepositoryName();
+    FakeHttpServletResponse res = buildText("/");
+    assertEquals(name + "\n", new String(res.getActualBody(), UTF_8));
+  }
+
+  @Test
+  public void fooSubdirText() throws Exception {
+    FakeHttpServletResponse res = buildText("/foo/");
+    assertEquals("bar/repo\n", new String(res.getActualBody(), UTF_8));
+  }
+
+  @Test
+  public void fooBarSubdirText() throws Exception {
+    FakeHttpServletResponse res = buildText("/foo/bar/");
+    assertEquals("repo\n", new String(res.getActualBody(), UTF_8));
+  }
+
+  @Test
+  public void rootJson() throws Exception {
+    String name = repo.getRepository().getDescription().getRepositoryName();
+    Map<String, RepositoryDescription> res = buildJson(
+        "/",
+        new TypeToken<Map<String, RepositoryDescription>>() {}.getType());
+
+    assertEquals(1, res.size());
+    RepositoryDescription d = res.get(name);
+    assertNotNull(name + " exists", d);
+    assertEquals(name, d.name);
+  }
+
+  @Test
+  public void fooSubdirJson() throws Exception {
+    Map<String, RepositoryDescription> res = buildJson(
+        "/foo/",
+        new TypeToken<Map<String, RepositoryDescription>>() {}.getType());
+
+    assertEquals(1, res.size());
+    RepositoryDescription d = res.get("bar/repo");
+    assertNotNull("bar/repo exists", d);
+    assertEquals(repo.getRepository().getDescription().getRepositoryName(), d.name);
+  }
+
+  @Test
+  public void fooBarSubdirJson() throws Exception {
+    Map<String, RepositoryDescription> res = buildJson(
+        "/foo/bar/",
+        new TypeToken<Map<String, RepositoryDescription>>() {}.getType());
+
+    assertEquals(1, res.size());
+    RepositoryDescription d = res.get("repo");
+    assertNotNull("repo exists", d);
+    assertEquals(repo.getRepository().getDescription().getRepositoryName(), d.name);
+  }
+
+  @Test
+  public void emptySubdirectoryList() throws Exception {
+    assertNotFound("/no.repos/", null);
+  }
+}
diff --git a/gitiles-servlet/src/test/java/com/google/gitiles/TestGitilesAccess.java b/gitiles-servlet/src/test/java/com/google/gitiles/TestGitilesAccess.java
index 50877ba..2a6eb87 100644
--- a/gitiles-servlet/src/test/java/com/google/gitiles/TestGitilesAccess.java
+++ b/gitiles-servlet/src/test/java/com/google/gitiles/TestGitilesAccess.java
@@ -16,11 +16,13 @@
 
 import static com.google.common.base.Preconditions.checkNotNull;
 
+import com.google.common.base.CharMatcher;
 import com.google.common.collect.ImmutableMap;
 
 import org.eclipse.jgit.internal.storage.dfs.DfsRepository;
 import org.eclipse.jgit.lib.Config;
 
+import java.util.Collections;
 import java.util.Map;
 import java.util.Set;
 
@@ -38,12 +40,20 @@
   public GitilesAccess forRequest(final HttpServletRequest req) {
     return new GitilesAccess() {
       @Override
-      public Map<String, RepositoryDescription> listRepositories(Set<String> branches) {
+      public Map<String, RepositoryDescription> listRepositories(String prefix,
+          Set<String> branches) {
+        String name = repo.getDescription().getRepositoryName();
+        if (prefix != null) {
+          String pattern = CharMatcher.is('/').trimFrom(prefix) + '/';
+          if (!name.startsWith(pattern)) {
+            return Collections.emptyMap();
+          }
+        }
         if (branches != null && !branches.isEmpty()) {
           throw new UnsupportedOperationException("branches set not yet supported");
         }
         RepositoryDescription desc = new RepositoryDescription();
-        desc.name = repo.getDescription().getRepositoryName();
+        desc.name = name;
         desc.cloneUrl = TestGitilesUrls.URLS.getBaseGitUrl(req) + "/" + desc.name;
         return ImmutableMap.of(desc.name, desc);
       }