Gitiles is a simple browser for Git repositories

It is based on JGit and uses Google Closure Templates as a templating
language. Access to underlying repositories is based on a few simple
interfaces; currently, there is only a simple disk-based
implementation, but other implementations are possible.

Features include viewing repositories by branch, shortlogs, showing
individual files and diffs with syntax highlighting, with many more
planned.

The application itself is a standard Java servlet and is configured
primarily via a git config format file. Deploying the WAR in any
servlet container should be possible.

In addition, a standalone server may be run with jetty-maven-plugin
with `mvn package && mvn jetty:run`.

Change-Id: I0ab8875b6c50f7df03b9a42b4a60923a4827bde7
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/AbstractHttpFilter.java b/gitiles-servlet/src/main/java/com/google/gitiles/AbstractHttpFilter.java
new file mode 100644
index 0000000..092e12d
--- /dev/null
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/AbstractHttpFilter.java
@@ -0,0 +1,49 @@
+// Copyright 2012 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 java.io.IOException;
+
+import javax.servlet.Filter;
+import javax.servlet.FilterChain;
+import javax.servlet.FilterConfig;
+import javax.servlet.ServletException;
+import javax.servlet.ServletRequest;
+import javax.servlet.ServletResponse;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+abstract class AbstractHttpFilter implements Filter {
+  @Override
+  public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
+      throws IOException, ServletException {
+    doFilter((HttpServletRequest) req, (HttpServletResponse) res, chain);
+  }
+
+  @Override
+  @SuppressWarnings("unused") // Allow subclasses to throw ServletException.
+  public void init(FilterConfig config) throws ServletException {
+    // Default implementation does nothing.
+  }
+
+  @Override
+  public void destroy() {
+    // Default implementation does nothing.
+  }
+
+  /** @see #doFilter(ServletRequest, ServletResponse, FilterChain) */
+  public abstract void doFilter(HttpServletRequest req, HttpServletResponse res, FilterChain chain)
+      throws IOException, ServletException;
+}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/BaseServlet.java b/gitiles-servlet/src/main/java/com/google/gitiles/BaseServlet.java
new file mode 100644
index 0000000..70dc14e
--- /dev/null
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/BaseServlet.java
@@ -0,0 +1,119 @@
+// Copyright 2012 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 com.google.common.base.Charsets;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Maps;
+import com.google.common.net.HttpHeaders;
+
+import org.joda.time.Instant;
+
+import java.io.IOException;
+import java.util.Map;
+
+import javax.servlet.http.HttpServlet;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+/** Base servlet class for Gitiles servlets that serve Soy templates. */
+public abstract class BaseServlet extends HttpServlet {
+  private static final String DATA_ATTRIBUTE = BaseServlet.class.getName() + "/Data";
+
+  static void setNotCacheable(HttpServletResponse res) {
+    res.setHeader(HttpHeaders.CACHE_CONTROL, "no-cache, no-store, max-age=0, must-revalidate");
+    res.setHeader(HttpHeaders.PRAGMA, "no-cache");
+    res.setHeader(HttpHeaders.EXPIRES, "Fri, 01 Jan 1990 00:00:00 GMT");
+    res.setDateHeader(HttpHeaders.DATE, new Instant().getMillis());
+  }
+
+  public static BaseServlet notFoundServlet() {
+    return new BaseServlet(null) {
+      @Override
+      public void service(HttpServletRequest req, HttpServletResponse res) {
+        res.setStatus(SC_NOT_FOUND);
+      }
+    };
+  }
+
+  public static Map<String, String> menuEntry(String text, String url) {
+    if (url != null) {
+      return ImmutableMap.of("text", text, "url", url);
+    } else {
+      return ImmutableMap.of("text", text);
+    }
+  }
+
+  protected static Map<String, Object> getData(HttpServletRequest req) {
+    @SuppressWarnings("unchecked")
+    Map<String, Object> data = (Map<String, Object>) req.getAttribute(DATA_ATTRIBUTE);
+    if (data == null) {
+      data = Maps.newHashMap();
+      req.setAttribute(DATA_ATTRIBUTE, data);
+    }
+    return data;
+  }
+
+  protected final Renderer renderer;
+
+  protected BaseServlet(Renderer renderer) {
+    this.renderer = renderer;
+  }
+
+  /**
+   * Put a value into a request's Soy data map.
+   * <p>
+   * This method is intended to support a composition pattern whereby a
+   * {@link BaseServlet} is wrapped in a different {@link HttpServlet} that can
+   * update its data map.
+   *
+   * @param req in-progress request.
+   * @param key key.
+   * @param value Soy data value.
+   */
+  public void put(HttpServletRequest req, String key, Object value) {
+    getData(req).put(key, value);
+  }
+
+  protected void render(HttpServletRequest req, HttpServletResponse res, String templateName,
+      Map<String, ?> soyData) throws IOException {
+    try {
+      res.setContentType(FormatType.HTML.getMimeType());
+      res.setCharacterEncoding(Charsets.UTF_8.name());
+      setCacheHeaders(req, res);
+
+      Map<String, Object> allData = getData(req);
+      allData.putAll(soyData);
+      GitilesView view = ViewFilter.getView(req);
+      if (!allData.containsKey("repositoryName") && view.getRepositoryName() != null) {
+        allData.put("repositoryName", view.getRepositoryName());
+      }
+      if (!allData.containsKey("breadcrumbs")) {
+        allData.put("breadcrumbs", view.getBreadcrumbs());
+      }
+
+      res.setStatus(HttpServletResponse.SC_OK);
+      renderer.render(res, templateName, allData);
+    } finally {
+      req.removeAttribute(DATA_ATTRIBUTE);
+    }
+  }
+
+  protected void setCacheHeaders(HttpServletRequest req, HttpServletResponse res) {
+    setNotCacheable(res);
+  }
+}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/BlobSoyData.java b/gitiles-servlet/src/main/java/com/google/gitiles/BlobSoyData.java
new file mode 100644
index 0000000..6b57cbc
--- /dev/null
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/BlobSoyData.java
@@ -0,0 +1,109 @@
+// Copyright 2012 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 org.eclipse.jgit.lib.Constants.OBJ_COMMIT;
+
+import com.google.common.collect.Maps;
+
+import org.eclipse.jgit.diff.RawText;
+import org.eclipse.jgit.errors.LargeObjectException;
+import org.eclipse.jgit.errors.MissingObjectException;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectLoader;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.util.RawParseUtils;
+
+import java.io.IOException;
+import java.util.Map;
+
+/** Soy data converter for git blobs. */
+public class BlobSoyData {
+  /**
+   * Maximum number of bytes to load from a supposed text file for display.
+   * Files larger than this will be displayed as binary files, even if the
+   * contents was text. For example really big XML files may be above this limit
+   * and will get displayed as binary.
+   */
+  private static final int MAX_FILE_SIZE = 10 << 20;
+
+  private final GitilesView view;
+  private final RevWalk walk;
+
+  public BlobSoyData(RevWalk walk, GitilesView view) {
+    this.view = view;
+    this.walk = walk;
+  }
+
+  public Map<String, Object> toSoyData(ObjectId blobId)
+      throws MissingObjectException, IOException {
+    return toSoyData(null, blobId);
+  }
+
+  public Map<String, Object> toSoyData(String path, ObjectId blobId)
+      throws MissingObjectException, IOException {
+    Map<String, Object> data = Maps.newHashMapWithExpectedSize(4);
+    data.put("sha", ObjectId.toString(blobId));
+
+    ObjectLoader loader = walk.getObjectReader().open(blobId, Constants.OBJ_BLOB);
+    String content;
+    try {
+      byte[] raw = loader.getCachedBytes(MAX_FILE_SIZE);
+      content = !RawText.isBinary(raw) ? RawParseUtils.decode(raw) : null;
+    } catch (LargeObjectException.OutOfMemory e) {
+      throw e;
+    } catch (LargeObjectException e) {
+      content = null;
+    }
+
+    data.put("data", content);
+    if (content != null) {
+      data.put("lang", guessPrettifyLang(path, content));
+    } else if (content == null) {
+      data.put("size", Long.toString(loader.getSize()));
+    }
+    if (path != null && view.getRevision().getPeeledType() == OBJ_COMMIT) {
+      data.put("logUrl", GitilesView.log().copyFrom(view).toUrl());
+    }
+    return data;
+  }
+
+  private static String guessPrettifyLang(String path, String content) {
+    if (content.startsWith("#!/bin/sh") || content.startsWith("#!/bin/bash")) {
+      return "sh";
+    } else if (content.startsWith("#!/usr/bin/perl")) {
+      return "perl";
+    } else if (content.startsWith("#!/usr/bin/python")) {
+      return "py";
+    } else if (path == null) {
+      return null;
+    }
+
+    int slash = path.lastIndexOf('/');
+    int dot = path.lastIndexOf('.');
+    String lang = ((0 < dot) && (slash < dot)) ? path.substring(dot + 1) : null;
+    if ("txt".equalsIgnoreCase(lang)) {
+      return null;
+    } else if ("mk".equalsIgnoreCase(lang)) {
+      return "sh";
+    } else if ("Makefile".equalsIgnoreCase(path)
+        || ((0 < slash) && "Makefile".equalsIgnoreCase(path.substring(slash + 1)))) {
+      return "sh";
+    } else {
+      return lang;
+    }
+  }
+}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/CommitSoyData.java b/gitiles-servlet/src/main/java/com/google/gitiles/CommitSoyData.java
new file mode 100644
index 0000000..9f8a97d
--- /dev/null
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/CommitSoyData.java
@@ -0,0 +1,287 @@
+// Copyright 2012 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.common.base.Preconditions.checkState;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Lists;
+import com.google.common.collect.Maps;
+import com.google.template.soy.data.restricted.NullData;
+
+import org.eclipse.jgit.diff.DiffEntry;
+import org.eclipse.jgit.diff.DiffEntry.ChangeType;
+import org.eclipse.jgit.diff.DiffFormatter;
+import org.eclipse.jgit.lib.AnyObjectId;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.treewalk.AbstractTreeIterator;
+import org.eclipse.jgit.treewalk.CanonicalTreeParser;
+import org.eclipse.jgit.treewalk.EmptyTreeIterator;
+import org.eclipse.jgit.util.GitDateFormatter;
+import org.eclipse.jgit.util.GitDateFormatter.Format;
+import org.eclipse.jgit.util.RelativeDateFormatter;
+import org.eclipse.jgit.util.io.NullOutputStream;
+
+import java.io.IOException;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import javax.annotation.Nullable;
+import javax.servlet.http.HttpServletRequest;
+
+/** Soy data converter for git commits. */
+public class CommitSoyData {
+  /** Valid sets of keys to include in Soy data for commits. */
+  public static enum KeySet {
+    DETAIL("author", "committer", "sha", "tree", "treeUrl", "parents", "message", "logUrl"),
+    DETAIL_DIFF_TREE(DETAIL, "diffTree"),
+    SHORTLOG("abbrevSha", "url", "shortMessage", "author", "branches", "tags"),
+    DEFAULT(DETAIL);
+
+    private final Set<String> keys;
+
+    private KeySet(String... keys) {
+      this.keys = ImmutableSet.copyOf(keys);
+    }
+
+    private KeySet(KeySet other, String... keys) {
+      this.keys = ImmutableSet.<String> builder().addAll(other.keys).add(keys).build();
+    }
+  }
+
+  private final Linkifier linkifier;
+  private final HttpServletRequest req;
+  private final Repository repo;
+  private final RevWalk walk;
+  private final GitilesView view;
+  private final Map<AnyObjectId, Set<Ref>> refsById;
+  private final GitDateFormatter dateFormatter;
+
+  // TODO(dborowitz): This constructor is getting a bit ridiculous.
+  public CommitSoyData(@Nullable Linkifier linkifier, HttpServletRequest req, Repository repo,
+      RevWalk walk, GitilesView view) {
+    this(linkifier, req, repo, walk, view, null);
+  }
+
+  public CommitSoyData(@Nullable Linkifier linkifier, HttpServletRequest req, Repository repo,
+      RevWalk walk, GitilesView view, @Nullable Map<AnyObjectId, Set<Ref>> refsById) {
+    this.linkifier = linkifier;
+    this.req = req;
+    this.repo = repo;
+    this.walk = walk;
+    this.view = view;
+    this.refsById = refsById;
+    this.dateFormatter = new GitDateFormatter(Format.DEFAULT);
+  }
+
+  public Map<String, Object> toSoyData(RevCommit commit, KeySet keys) throws IOException {
+    Map<String, Object> data = Maps.newHashMapWithExpectedSize(KeySet.DEFAULT.keys.size());
+    if (keys.keys.contains("author")) {
+      data.put("author", toSoyData(commit.getAuthorIdent(), dateFormatter));
+    }
+    if (keys.keys.contains("committer")) {
+      data.put("committer", toSoyData(commit.getCommitterIdent(), dateFormatter));
+    }
+    if (keys.keys.contains("sha")) {
+      data.put("sha", ObjectId.toString(commit));
+    }
+    if (keys.keys.contains("abbrevSha")) {
+      ObjectReader reader = repo.getObjectDatabase().newReader();
+      try {
+        data.put("abbrevSha", reader.abbreviate(commit).name());
+      } finally {
+        reader.release();
+      }
+    }
+    if (keys.keys.contains("url")) {
+      data.put("url", GitilesView.revision()
+          .copyFrom(view)
+          .setRevision(commit)
+          .toUrl());
+    }
+    if (keys.keys.contains("logUrl")) {
+      Revision rev = view.getRevision();
+      GitilesView.Builder logView = GitilesView.log()
+          .copyFrom(view)
+          .setRevision(rev.getId().equals(commit) ? rev.getName() : commit.name(), commit)
+          .setOldRevision(Revision.NULL)
+          .setTreePath(null);
+      data.put("logUrl", logView.toUrl());
+    }
+    if (keys.keys.contains("tree")) {
+      data.put("tree", ObjectId.toString(commit.getTree()));
+    }
+    if (keys.keys.contains("treeUrl")) {
+      data.put("treeUrl", GitilesView.path().copyFrom(view).setTreePath("/").toUrl());
+    }
+    if (keys.keys.contains("parents")) {
+      data.put("parents", toSoyData(view, commit.getParents()));
+    }
+    if (keys.keys.contains("shortMessage")) {
+      data.put("shortMessage", commit.getShortMessage());
+    }
+    if (keys.keys.contains("branches")) {
+      data.put("branches", getRefsById(commit, Constants.R_HEADS));
+    }
+    if (keys.keys.contains("tags")) {
+      data.put("tags", getRefsById(commit, Constants.R_TAGS));
+    }
+    if (keys.keys.contains("message")) {
+      if (linkifier != null) {
+        data.put("message", linkifier.linkify(req, commit.getFullMessage()));
+      } else {
+        data.put("message", commit.getFullMessage());
+      }
+    }
+    if (keys.keys.contains("diffTree")) {
+      data.put("diffTree", computeDiffTree(commit));
+    }
+    checkState(keys.keys.size() == data.size(), "bad commit data keys: %s != %s", keys.keys,
+        data.keySet());
+    return ImmutableMap.copyOf(data);
+  }
+
+  public Map<String, Object> toSoyData(RevCommit commit) throws IOException {
+    return toSoyData(commit, KeySet.DEFAULT);
+  }
+
+  // TODO(dborowitz): Extract this.
+  static Map<String, String> toSoyData(PersonIdent ident, GitDateFormatter dateFormatter) {
+    return ImmutableMap.of(
+        "name", ident.getName(),
+        "email", ident.getEmailAddress(),
+        "time", dateFormatter.formatDate(ident),
+        // TODO(dborowitz): Switch from relative to absolute at some threshold.
+        "relativeTime", RelativeDateFormatter.format(ident.getWhen()));
+  }
+
+  private List<Map<String, String>> toSoyData(GitilesView view, RevCommit[] parents) {
+    List<Map<String, String>> result = Lists.newArrayListWithCapacity(parents.length);
+    int i = 1;
+    // TODO(dborowitz): Render something slightly different when we're actively
+    // viewing a diff against one of the parents.
+    for (RevCommit parent : parents) {
+      String name = parent.name();
+      GitilesView.Builder diff = GitilesView.diff().copyFrom(view).setTreePath("");
+      String parentName;
+      if (parents.length == 1) {
+        parentName = view.getRevision().getName() + "^";
+      } else {
+        parentName = view.getRevision().getName() + "^" + (i++);
+      }
+      result.add(ImmutableMap.of(
+          "sha", name,
+          "url", GitilesView.revision()
+              .copyFrom(view)
+              .setRevision(parentName, parent)
+              .toUrl(),
+          "diffUrl", diff.setOldRevision(parentName, parent).toUrl()));
+    }
+    return result;
+  }
+
+  private AbstractTreeIterator getTreeIterator(RevWalk walk, RevCommit commit) throws IOException {
+    CanonicalTreeParser p = new CanonicalTreeParser();
+    p.reset(walk.getObjectReader(), walk.parseTree(walk.parseCommit(commit).getTree()));
+    return p;
+  }
+
+  private Object computeDiffTree(RevCommit commit) throws IOException {
+    AbstractTreeIterator oldTree;
+    GitilesView.Builder diffUrl = GitilesView.diff().copyFrom(view)
+        .setTreePath("");
+    switch (commit.getParentCount()) {
+      case 0:
+        oldTree = new EmptyTreeIterator();
+        diffUrl.setOldRevision(Revision.NULL);
+        break;
+      case 1:
+        oldTree = getTreeIterator(walk, commit.getParent(0));
+        diffUrl.setOldRevision(view.getRevision().getName() + "^", commit.getParent(0));
+        break;
+      default:
+        // TODO(dborowitz): handle merges
+        return NullData.INSTANCE;
+    }
+    AbstractTreeIterator newTree = getTreeIterator(walk, commit);
+
+    DiffFormatter diff = new DiffFormatter(NullOutputStream.INSTANCE);
+    try {
+      diff.setRepository(repo);
+      diff.setDetectRenames(true);
+
+      List<Object> result = Lists.newArrayList();
+      for (DiffEntry e : diff.scan(oldTree, newTree)) {
+        Map<String, Object> entry = Maps.newHashMapWithExpectedSize(5);
+        entry.put("path", e.getNewPath());
+        entry.put("url", GitilesView.path()
+            .copyFrom(view)
+            .setTreePath(e.getNewPath())
+            .toUrl());
+        entry.put("diffUrl", diffUrl.setAnchor("F" + result.size()).toUrl());
+        entry.put("changeType", e.getChangeType().toString());
+        if (e.getChangeType() == ChangeType.COPY || e.getChangeType() == ChangeType.RENAME) {
+          entry.put("oldPath", e.getOldPath());
+        }
+        result.add(entry);
+      }
+      return result;
+    } finally {
+      diff.release();
+    }
+  }
+
+  private static final Comparator<Map<String, String>> NAME_COMPARATOR =
+      new Comparator<Map<String, String>>() {
+        @Override
+        public int compare(Map<String, String> o1, Map<String, String> o2) {
+          return o1.get("name").compareTo(o2.get("name"));
+        }
+      };
+
+  private List<Map<String, String>> getRefsById(ObjectId id, String prefix) {
+    checkNotNull(refsById, "must pass in ID to ref map to look up refs by ID");
+    Set<Ref> refs = refsById.get(id);
+    if (refs == null) {
+      return ImmutableList.of();
+    }
+    List<Map<String, String>> result = Lists.newArrayListWithCapacity(refs.size());
+    for (Ref ref : refs) {
+      if (ref.getName().startsWith(prefix)) {
+        result.add(ImmutableMap.of(
+          "name", ref.getName().substring(prefix.length()),
+          "url", GitilesView.revision()
+              .copyFrom(view)
+              .setRevision(Revision.unpeeled(ref.getName(), ref.getObjectId()))
+              .toUrl()));
+      }
+    }
+    Collections.sort(result, NAME_COMPARATOR);
+    return result;
+  }
+}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/ConfigUtil.java b/gitiles-servlet/src/main/java/com/google/gitiles/ConfigUtil.java
new file mode 100644
index 0000000..4f8733b
--- /dev/null
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/ConfigUtil.java
@@ -0,0 +1,141 @@
+// Copyright 2012 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 com.google.common.base.Predicates;
+import com.google.common.cache.CacheBuilder;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Iterables;
+
+import org.eclipse.jgit.lib.Config;
+import org.joda.time.Duration;
+
+import java.util.concurrent.TimeUnit;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/** Utilities for working with {@link Config} objects. */
+public class ConfigUtil {
+  /**
+   * Read a duration value from the configuration.
+   * <p>
+   * Durations can be written as expressions, for example {@code "1 s"} or
+   * {@code "5 days"}. If units are not specified, milliseconds are assumed.
+   *
+   * @param config JGit config object.
+   * @param section section to read, e.g. "google"
+   * @param subsection subsection to read, e.g. "bigtable"
+   * @param name variable to read, e.g. "deadline".
+   * @param defaultValue value to use when the value is not assigned.
+   * @return a standard duration representing the time read, or defaultValue.
+   */
+  public static Duration getDuration(Config config, String section, String subsection, String name,
+      Duration defaultValue) {
+    String valStr = config.getString(section, subsection, name);
+    if (valStr == null) {
+        return defaultValue;
+    }
+
+    valStr = valStr.trim();
+    if (valStr.length() == 0) {
+        return defaultValue;
+    }
+
+    Matcher m = matcher("^([1-9][0-9]*(?:\\.[0-9]*)?)\\s*(.*)$", valStr);
+    if (!m.matches()) {
+      String key = section + (subsection != null ? "." + subsection : "") + "." + name;
+      throw new IllegalStateException("Not time unit: " + key + " = " + valStr);
+    }
+
+    String digits = m.group(1);
+    String unitName = m.group(2).trim();
+
+    TimeUnit unit;
+    if ("".equals(unitName)) {
+      unit = TimeUnit.MILLISECONDS;
+    } else if (anyOf(unitName, "ms", "millis", "millisecond", "milliseconds")) {
+      unit = TimeUnit.MILLISECONDS;
+    } else if (anyOf(unitName, "s", "sec", "second", "seconds")) {
+      unit = TimeUnit.SECONDS;
+    } else if (anyOf(unitName, "m", "min", "minute", "minutes")) {
+      unit = TimeUnit.MINUTES;
+    } else if (anyOf(unitName, "h", "hr", "hour", "hours")) {
+      unit = TimeUnit.HOURS;
+    } else if (anyOf(unitName, "d", "day", "days")) {
+      unit = TimeUnit.DAYS;
+    } else {
+      String key = section + (subsection != null ? "." + subsection : "") + "." + name;
+      throw new IllegalStateException("Not time unit: " + key + " = " + valStr);
+    }
+
+    try {
+      if (digits.indexOf('.') == -1) {
+        long val = Long.parseLong(digits);
+        return new Duration(val * TimeUnit.MILLISECONDS.convert(1, unit));
+      } else {
+        double val = Double.parseDouble(digits);
+        return new Duration((long) (val * TimeUnit.MILLISECONDS.convert(1, unit)));
+      }
+    } catch (NumberFormatException nfe) {
+      String key = section + (subsection != null ? "." + subsection : "") + "." + name;
+      throw new IllegalStateException("Not time unit: " + key + " = " + valStr, nfe);
+    }
+  }
+
+  /**
+   * Get a {@link CacheBuilder} from a config.
+   *
+   * @param config JGit config object.
+   * @param name name of the cache subsection under the "cache" section.
+   * @return a new cache builder.
+   */
+  public static CacheBuilder<Object, Object> getCacheBuilder(Config config, String name) {
+    CacheBuilder<Object, Object> b = CacheBuilder.newBuilder();
+    try {
+      if (config.getString("cache", name, "maximumWeight") != null) {
+        b.maximumWeight(config.getLong("cache", name, "maximumWeight", 20 << 20));
+      }
+      if (config.getString("cache", name, "maximumSize") != null) {
+        b.maximumSize(config.getLong("cache", name, "maximumSize", 16384));
+      }
+      Duration expireAfterWrite = getDuration(config, "cache", name, "expireAfterWrite", null);
+      if (expireAfterWrite != null) {
+        b.expireAfterWrite(expireAfterWrite.getMillis(), TimeUnit.MILLISECONDS);
+      }
+      Duration expireAfterAccess = getDuration(config, "cache", name, "expireAfterAccess", null);
+      if (expireAfterAccess != null) {
+        b.expireAfterAccess(expireAfterAccess.getMillis(), TimeUnit.MILLISECONDS);
+      }
+      // Add other methods as needed.
+    } catch (IllegalArgumentException e) {
+      throw new IllegalArgumentException("Error getting CacheBuilder for " + name, e);
+    } catch (IllegalStateException e) {
+      throw new IllegalStateException("Error getting CacheBuilder for " + name, e);
+    }
+    return b;
+  }
+
+  private static Matcher matcher(String pattern, String valStr) {
+      return Pattern.compile(pattern).matcher(valStr);
+  }
+
+  private static boolean anyOf(String a, String... cases) {
+    return Iterables.any(ImmutableList.copyOf(cases),
+        Predicates.equalTo(a.toLowerCase()));
+  }
+
+  private ConfigUtil() {
+  }
+}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/DebugRenderer.java b/gitiles-servlet/src/main/java/com/google/gitiles/DebugRenderer.java
new file mode 100644
index 0000000..e56518a
--- /dev/null
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/DebugRenderer.java
@@ -0,0 +1,57 @@
+// Copyright 2012 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.checkState;
+
+import com.google.common.base.Function;
+import com.google.common.collect.ImmutableMap;
+import com.google.template.soy.SoyFileSet;
+import com.google.template.soy.tofu.SoyTofu;
+
+import java.io.File;
+import java.net.URISyntaxException;
+import java.net.URL;
+
+/** Renderer that reloads Soy templates from the filesystem on every request. */
+public class DebugRenderer extends Renderer {
+  public DebugRenderer(String staticPrefix, String customTemplatesFilename,
+      final String soyTemplatesRoot) {
+    super(
+        new Function<String, URL>() {
+          @Override
+          public URL apply(String name) {
+            return toFileURL(soyTemplatesRoot + File.separator + name);
+          }
+        },
+        ImmutableMap.<String, String> of(), staticPrefix,
+        toFileURL(customTemplatesFilename));
+  }
+
+  @Override
+  protected SoyTofu getTofu() {
+    SoyFileSet.Builder builder = new SoyFileSet.Builder()
+        .setCompileTimeGlobals(globals);
+    for (URL template : templates) {
+      try {
+        checkState(new File(template.toURI()).exists(), "Missing Soy template %s", template);
+      } catch (URISyntaxException e) {
+        throw new IllegalStateException(e);
+      }
+      builder.add(template);
+    }
+    return builder.build().compileToTofu();
+  }
+}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/DefaultAccess.java b/gitiles-servlet/src/main/java/com/google/gitiles/DefaultAccess.java
new file mode 100644
index 0000000..5b6d822
--- /dev/null
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/DefaultAccess.java
@@ -0,0 +1,239 @@
+// Copyright 2012 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 com.google.common.collect.Lists;
+import com.google.common.collect.Maps;
+import com.google.common.collect.Queues;
+
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.http.server.ServletUtils;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.lib.StoredConfig;
+import org.eclipse.jgit.transport.resolver.FileResolver;
+import org.eclipse.jgit.transport.resolver.ServiceNotEnabledException;
+import org.eclipse.jgit.util.IO;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Queue;
+import java.util.Set;
+
+import javax.servlet.http.HttpServletRequest;
+
+/**
+ * Default implementation of {@link GitilesAccess} with local repositories.
+ * <p>
+ * Repositories are scanned on-demand under the given path, configured by
+ * default from {@code gitiles.basePath}. There is no access control beyond what
+ * user the JVM is running under.
+ */
+public class DefaultAccess implements GitilesAccess {
+  private static final String ANONYMOUS_USER_KEY = "anonymous user";
+
+  public static class Factory implements GitilesAccess.Factory {
+    private final File basePath;
+    private final String canonicalBasePath;
+    private final String baseGitUrl;
+    private final FileResolver<HttpServletRequest> resolver;
+
+    Factory(File basePath, String baseGitUrl, FileResolver<HttpServletRequest> resolver)
+        throws IOException {
+      this.basePath = checkNotNull(basePath, "basePath");
+      this.baseGitUrl = checkNotNull(baseGitUrl, "baseGitUrl");
+      this.resolver = checkNotNull(resolver, "resolver");
+      this.canonicalBasePath = basePath.getCanonicalPath();
+    }
+
+    @Override
+    public GitilesAccess forRequest(HttpServletRequest req) {
+      String path = req.getPathInfo();
+      String repositoryPath;
+      if (path == null || path == "/") {
+        repositoryPath = null;
+      } else {
+        int slashPlus = path.indexOf("/+/");
+        if (slashPlus >= 0) {
+          repositoryPath = path.substring(0, slashPlus);
+        } else if (path.endsWith("/+")) {
+          repositoryPath = path.substring(0, path.length() - 2);
+        } else {
+          repositoryPath = path;
+        }
+      }
+      return newAccess(basePath, canonicalBasePath, baseGitUrl, resolver, req);
+    }
+
+    protected DefaultAccess newAccess(File basePath, String canonicalBasePath, String baseGitUrl,
+        FileResolver<HttpServletRequest> resolver, HttpServletRequest req) {
+      return new DefaultAccess(basePath, canonicalBasePath, baseGitUrl, resolver, req);
+    }
+  }
+
+  protected final File basePath;
+  protected final String canonicalBasePath;
+  protected final String baseGitUrl;
+  protected final FileResolver<HttpServletRequest> resolver;
+  protected final HttpServletRequest req;
+
+  protected DefaultAccess(File basePath, String canonicalBasePath, String baseGitUrl,
+      FileResolver<HttpServletRequest> resolver, HttpServletRequest req) {
+    this.basePath = checkNotNull(basePath, "basePath");
+    this.canonicalBasePath = checkNotNull(canonicalBasePath, "canonicalBasePath");
+    this.baseGitUrl = checkNotNull(baseGitUrl, "baseGitUrl");
+    this.resolver = checkNotNull(resolver, "resolver");
+    this.req = checkNotNull(req, "req");
+  }
+
+  @Override
+  public Map<String, RepositoryDescription> listRepositories(Set<String> branches)
+      throws IOException {
+    Map<String, RepositoryDescription> repos = Maps.newTreeMap();
+    for (Repository repo : scanRepositories(basePath, req)) {
+      repos.put(getRepositoryName(repo), buildDescription(repo, branches));
+      repo.close();
+    }
+    return repos;
+  }
+
+  @Override
+  public Object getUserKey() {
+    // Always return the same anonymous user key (effectively running with the
+    // same user permissions as the JVM). Subclasses may override this behavior.
+    return ANONYMOUS_USER_KEY;
+  }
+
+  @Override
+  public String getRepositoryName() {
+    return getRepositoryName(ServletUtils.getRepository(req));
+  }
+
+  @Override
+  public RepositoryDescription getRepositoryDescription() throws IOException {
+    return buildDescription(ServletUtils.getRepository(req), Collections.<String> emptySet());
+  }
+
+  private String getRepositoryName(Repository repo) {
+    String path = getRelativePath(repo);
+    if (repo.isBare() && path.endsWith(".git")) {
+      path = path.substring(0, path.length() - 4);
+    }
+    return path;
+  }
+
+  private String getRelativePath(Repository repo) {
+    String path = repo.isBare() ? repo.getDirectory().getPath() : repo.getDirectory().getParent();
+    if (repo.isBare()) {
+      path = repo.getDirectory().getPath();
+      if (path.endsWith(".git")) {
+        path = path.substring(0, path.length() - 4);
+      }
+    } else {
+      path = repo.getDirectory().getParent();
+    }
+    return getRelativePath(path);
+  }
+
+  private String getRelativePath(String path) {
+    String base = basePath.getPath();
+    if (path.startsWith(base)) {
+      return path.substring(base.length() + 1);
+    }
+    if (path.startsWith(canonicalBasePath)) {
+      return path.substring(canonicalBasePath.length() + 1);
+    }
+    throw new IllegalStateException(String.format(
+          "Repository path %s is outside base path %s", path, base));
+  }
+
+  private String loadDescriptionText(Repository repo) throws IOException {
+    String desc = null;
+    StoredConfig config = repo.getConfig();
+    IOException configError = null;
+    try {
+      config.load();
+      desc = config.getString("gitweb", null, "description");
+    } catch (ConfigInvalidException e) {
+      configError = new IOException(e);
+    }
+    if (desc == null) {
+      File descFile = new File(repo.getDirectory(), "description");
+      if (descFile.exists()) {
+        desc = new String(IO.readFully(descFile));
+      } else if (configError != null) {
+        throw configError;
+      }
+    }
+    return desc;
+  }
+
+  private RepositoryDescription buildDescription(Repository repo, Set<String> branches)
+      throws IOException {
+    RepositoryDescription desc = new RepositoryDescription();
+    desc.name = getRepositoryName(repo);
+    desc.cloneUrl = baseGitUrl + getRelativePath(repo);
+    desc.description = loadDescriptionText(repo);
+    if (!branches.isEmpty()) {
+      desc.branches = Maps.newLinkedHashMap();
+      for (String name : branches) {
+        Ref ref = repo.getRef(normalizeRefName(name));
+        if ((ref != null) && (ref.getObjectId() != null)) {
+          desc.branches.put(name, ref.getObjectId().name());
+        }
+      }
+    }
+    return desc;
+  }
+
+  private static String normalizeRefName(String name) {
+    if (name.startsWith("refs/")) {
+      return name;
+    }
+    return "refs/heads/" + name;
+  }
+
+  private Collection<Repository> scanRepositories(final File basePath, final 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());
+    }
+    todo.addAll(Arrays.asList(baseFiles));
+    while (!todo.isEmpty()) {
+      File file = todo.remove();
+      try {
+        repos.add(resolver.open(req, getRelativePath(file.getPath())));
+      } catch (RepositoryNotFoundException e) {
+        File[] children = file.listFiles();
+        if (children != null) {
+          todo.addAll(Arrays.asList(children));
+        }
+      } catch (ServiceNotEnabledException e) {
+        throw new IOException(e);
+      }
+    }
+    return repos;
+  }
+}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/DefaultRenderer.java b/gitiles-servlet/src/main/java/com/google/gitiles/DefaultRenderer.java
new file mode 100644
index 0000000..f4bd1fb
--- /dev/null
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/DefaultRenderer.java
@@ -0,0 +1,59 @@
+// Copyright 2012 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 com.google.common.base.Function;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.io.Resources;
+import com.google.template.soy.SoyFileSet;
+import com.google.template.soy.tofu.SoyTofu;
+
+import java.net.URL;
+import java.util.Map;
+
+/** Renderer that precompiles Soy and uses static precompiled CSS. */
+public class DefaultRenderer extends Renderer {
+  private final SoyTofu tofu;
+
+  DefaultRenderer() {
+    this("", null);
+  }
+
+  public DefaultRenderer(String staticPrefix, URL customTemplates) {
+    this(ImmutableMap.<String, String> of(), staticPrefix, customTemplates);
+  }
+
+  public DefaultRenderer(Map<String, String> globals, String staticPrefix, URL customTemplates) {
+    super(
+        new Function<String, URL>() {
+          @Override
+          public URL apply(String name) {
+            return Resources.getResource(Renderer.class, "templates/" + name);
+          }
+        },
+        globals, staticPrefix, customTemplates);
+    SoyFileSet.Builder builder = new SoyFileSet.Builder()
+        .setCompileTimeGlobals(this.globals);
+    for (URL template : templates) {
+      builder.add(template);
+    }
+    tofu = builder.build().compileToTofu();
+  }
+
+  @Override
+  protected SoyTofu getTofu() {
+    return tofu;
+  }
+}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/DefaultUrls.java b/gitiles-servlet/src/main/java/com/google/gitiles/DefaultUrls.java
new file mode 100644
index 0000000..6495b5d
--- /dev/null
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/DefaultUrls.java
@@ -0,0 +1,60 @@
+// Copyright 2012 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 java.net.InetAddress;
+import java.net.UnknownHostException;
+
+import javax.servlet.http.HttpServletRequest;
+
+/**
+ * Default implementation of {@link GitilesUrls}.
+ * <p>
+ * This implementation uses statically-configured defaults, and thus assumes
+ * that the servlet is running a single virtual host.
+ */
+class DefaultUrls implements GitilesUrls {
+  private final String canonicalHostName;
+  private final String baseGitUrl;
+  private final String baseGerritUrl;
+
+  DefaultUrls(String canonicalHostName, String baseGitUrl, String baseGerritUrl)
+      throws UnknownHostException {
+    if (canonicalHostName != null) {
+      this.canonicalHostName = canonicalHostName;
+    } else {
+      this.canonicalHostName = InetAddress.getLocalHost().getCanonicalHostName();
+    }
+    this.baseGitUrl = checkNotNull(baseGitUrl, "baseGitUrl");
+    this.baseGerritUrl = checkNotNull(baseGerritUrl, "baseGerritUrl");
+  }
+
+  @Override
+  public String getHostName(HttpServletRequest req) {
+    return canonicalHostName;
+  }
+
+  @Override
+  public String getBaseGitUrl(HttpServletRequest req) {
+    return baseGitUrl;
+  }
+
+  @Override
+  public String getBaseGerritUrl(HttpServletRequest req) {
+    return baseGerritUrl;
+  }
+}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/DiffServlet.java b/gitiles-servlet/src/main/java/com/google/gitiles/DiffServlet.java
new file mode 100644
index 0000000..2408cd7
--- /dev/null
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/DiffServlet.java
@@ -0,0 +1,162 @@
+// Copyright 2012 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 javax.servlet.http.HttpServletResponse.SC_NOT_FOUND;
+
+import com.google.common.base.Charsets;
+
+import org.eclipse.jgit.diff.DiffFormatter;
+import org.eclipse.jgit.errors.IncorrectObjectTypeException;
+import org.eclipse.jgit.errors.MissingObjectException;
+import org.eclipse.jgit.http.server.ServletUtils;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.treewalk.AbstractTreeIterator;
+import org.eclipse.jgit.treewalk.CanonicalTreeParser;
+import org.eclipse.jgit.treewalk.EmptyTreeIterator;
+import org.eclipse.jgit.treewalk.filter.PathFilter;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.util.Arrays;
+import java.util.Map;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+/** Serves an HTML page with all the diffs for a commit. */
+public class DiffServlet extends BaseServlet {
+  private static final String PLACEHOLDER = "id=\"DIFF_OUTPUT_BLOCK\"";
+
+  private final Linkifier linkifier;
+
+  public DiffServlet(Renderer renderer, Linkifier linkifier) {
+    super(renderer);
+    this.linkifier = checkNotNull(linkifier, "linkifier");
+  }
+
+  @Override
+  protected void doGet(HttpServletRequest req, HttpServletResponse res) throws IOException {
+    GitilesView view = ViewFilter.getView(req);
+    Repository repo = ServletUtils.getRepository(req);
+
+    RevWalk walk = new RevWalk(repo);
+    try {
+      boolean showCommit;
+      AbstractTreeIterator oldTree;
+      AbstractTreeIterator newTree;
+      try {
+        // If we are viewing the diff between a commit and one of its parents,
+        // include the commit detail in the rendered page.
+        showCommit = isParentOf(walk, view.getOldRevision(), view.getRevision());
+        oldTree = getTreeIterator(walk, view.getOldRevision().getId());
+        newTree = getTreeIterator(walk, view.getRevision().getId());
+      } catch (MissingObjectException e) {
+        res.setStatus(SC_NOT_FOUND);
+        return;
+      } catch (IncorrectObjectTypeException e) {
+        res.setStatus(SC_NOT_FOUND);
+        return;
+      }
+
+      Map<String, Object> data = getData(req);
+      data.put("title", "Diff - " + view.getRevisionRange());
+      if (showCommit) {
+        data.put("commit", new CommitSoyData(linkifier, req, repo, walk, view)
+            .toSoyData(walk.parseCommit(view.getRevision().getId())));
+      }
+      if (!data.containsKey("repositoryName") && (view.getRepositoryName() != null)) {
+        data.put("repositoryName", view.getRepositoryName());
+      }
+      if (!data.containsKey("breadcrumbs")) {
+        data.put("breadcrumbs", view.getBreadcrumbs());
+      }
+
+      String[] html = renderAndSplit(data);
+      res.setStatus(HttpServletResponse.SC_OK);
+      res.setContentType(FormatType.HTML.getMimeType());
+      res.setCharacterEncoding(Charsets.UTF_8.name());
+      setCacheHeaders(req, res);
+
+      OutputStream out = res.getOutputStream();
+      try {
+        out.write(html[0].getBytes(Charsets.UTF_8));
+        formatHtmlDiff(out, repo, walk, oldTree, newTree, view.getTreePath());
+        out.write(html[1].getBytes(Charsets.UTF_8));
+      } finally {
+        out.close();
+      }
+    } finally {
+      walk.release();
+    }
+  }
+
+  private static boolean isParentOf(RevWalk walk, Revision oldRevision, Revision newRevision)
+      throws MissingObjectException, IncorrectObjectTypeException, IOException {
+    RevCommit newCommit = walk.parseCommit(newRevision.getId());
+    if (newCommit.getParentCount() > 0) {
+      return Arrays.asList(newCommit.getParents()).contains(oldRevision.getId());
+    } else {
+      return oldRevision == Revision.NULL;
+    }
+  }
+
+  private String[] renderAndSplit(Map<String, Object> data) {
+    String html = renderer.newRenderer("gitiles.diffDetail")
+        .setData(data)
+        .render();
+    int id = html.indexOf(PLACEHOLDER);
+    if (id < 0) {
+      throw new IllegalStateException("Template must contain " + PLACEHOLDER);
+    }
+
+    int lt = html.lastIndexOf('<', id);
+    int gt = html.indexOf('>', id + PLACEHOLDER.length());
+    return new String[] {html.substring(0, lt), html.substring(gt + 1)};
+  }
+
+  private void formatHtmlDiff(OutputStream out,
+      Repository repo, RevWalk walk,
+      AbstractTreeIterator oldTree, AbstractTreeIterator newTree,
+      String path)
+      throws IOException {
+    DiffFormatter diff = new HtmlDiffFormatter(renderer, out);
+    try {
+      if (!path.equals("")) {
+        diff.setPathFilter(PathFilter.create(path));
+      }
+      diff.setRepository(repo);
+      diff.setDetectRenames(true);
+      diff.format(oldTree, newTree);
+    } finally {
+      diff.release();
+    }
+  }
+
+  private static AbstractTreeIterator getTreeIterator(RevWalk walk, ObjectId id)
+      throws IOException {
+    if (!id.equals(ObjectId.zeroId())) {
+      CanonicalTreeParser p = new CanonicalTreeParser();
+      p.reset(walk.getObjectReader(), walk.parseTree(id));
+      return p;
+    } else {
+      return new EmptyTreeIterator();
+    }
+  }
+}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/FormatType.java b/gitiles-servlet/src/main/java/com/google/gitiles/FormatType.java
new file mode 100644
index 0000000..9417a4c
--- /dev/null
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/FormatType.java
@@ -0,0 +1,76 @@
+// Copyright 2012 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 com.google.common.base.Strings;
+import com.google.common.net.HttpHeaders;
+
+import javax.servlet.http.HttpServletRequest;
+
+/** Type of formatting to use in the response to the client. */
+public enum FormatType {
+  HTML("text/html"),
+  TEXT("text/plain"),
+  JSON("application/json"),
+  DEFAULT("*/*");
+
+  private static final String FORMAT_TYPE_ATTRIBUTE = FormatType.class.getName();
+
+  public static FormatType getFormatType(HttpServletRequest req) {
+    FormatType result = (FormatType) req.getAttribute(FORMAT_TYPE_ATTRIBUTE);
+    if (result != null) {
+      return result;
+    }
+
+    String format = req.getParameter("format");
+    if (format != null) {
+      for (FormatType type : FormatType.values()) {
+        if (format.equalsIgnoreCase(type.name())) {
+          return set(req, type);
+        }
+      }
+      throw new IllegalArgumentException("Invalid format " + format);
+    }
+
+    String accept = req.getHeader(HttpHeaders.ACCEPT);
+    if (Strings.isNullOrEmpty(accept)) {
+      return set(req, DEFAULT);
+    }
+
+    for (String p : accept.split("[ ,;][ ,;]*")) {
+      for (FormatType type : FormatType.values()) {
+        if (p.equals(type.mimeType)) {
+          return set(req, type != HTML ? type : DEFAULT);
+        }
+      }
+    }
+    return set(req, DEFAULT);
+  }
+
+  private static FormatType set(HttpServletRequest req, FormatType format) {
+    req.setAttribute(FORMAT_TYPE_ATTRIBUTE, format);
+    return format;
+  }
+
+  private final String mimeType;
+
+  private FormatType(String mimeType) {
+    this.mimeType = mimeType;
+  }
+
+  public String getMimeType() {
+    return mimeType;
+  }
+}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/GitilesAccess.java b/gitiles-servlet/src/main/java/com/google/gitiles/GitilesAccess.java
new file mode 100644
index 0000000..648d709
--- /dev/null
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/GitilesAccess.java
@@ -0,0 +1,68 @@
+// Copyright 2012 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 org.eclipse.jgit.transport.resolver.ServiceNotAuthorizedException;
+import org.eclipse.jgit.transport.resolver.ServiceNotEnabledException;
+
+import java.io.IOException;
+import java.util.Map;
+import java.util.Set;
+
+import javax.servlet.http.HttpServletRequest;
+
+/**
+ * Git storage interface for Gitiles.
+ * <p>
+ * Each instance is associated with a single end-user request, which implicitly
+ * includes information about the host and repository.
+ */
+public interface GitilesAccess {
+  /** Factory for per-request access. */
+  public interface Factory {
+    public GitilesAccess forRequest(HttpServletRequest req);
+  }
+
+  /**
+   * List repositories on the host.
+   *
+   * @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}).
+   * @throws ServiceNotAuthorizedException to trigger an HTTP 401 Unauthorized
+   *     (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;
+
+  /**
+   * @return an opaque object that uniquely identifies the end-user making the
+   *     request, and supports {@link #equals(Object)} and {@link #hashCode()}.
+   *     Never null.
+   */
+  public Object getUserKey();
+
+  /** @return the repository name associated with the request. */
+  public String getRepositoryName();
+
+  /**
+   * @return the description attached to the repository of this request.
+   * @throws IOException an error occurred reading the description string from
+   *         the repository.
+   */
+  public RepositoryDescription getRepositoryDescription() throws IOException;
+}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/GitilesFilter.java b/gitiles-servlet/src/main/java/com/google/gitiles/GitilesFilter.java
new file mode 100644
index 0000000..b2cf9ec
--- /dev/null
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/GitilesFilter.java
@@ -0,0 +1,362 @@
+// Copyright 2012 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.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.base.Joiner;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.LinkedListMultimap;
+import com.google.common.collect.ListMultimap;
+import com.google.common.collect.Maps;
+
+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.lib.Config;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.storage.file.FileBasedConfig;
+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 org.eclipse.jgit.util.FS;
+
+import java.io.File;
+import java.io.IOException;
+import java.net.UnknownHostException;
+import java.util.Iterator;
+import java.util.Map;
+import java.util.regex.Pattern;
+
+import javax.servlet.Filter;
+import javax.servlet.FilterChain;
+import javax.servlet.FilterConfig;
+import javax.servlet.ServletException;
+import javax.servlet.ServletRequest;
+import javax.servlet.ServletResponse;
+import javax.servlet.http.HttpServlet;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+/**
+ * MetaFilter to serve Gitiles.
+ * <p>
+ * Do not use directly; use {@link GitilesServlet}.
+ */
+class GitilesFilter extends MetaFilter {
+  private static final String CONFIG_PATH_PARAM = "configPath";
+
+  // The following regexes have the following capture groups:
+  // 1. The whole string, which causes RegexPipeline to set REGEX_GROUPS but
+  //    not otherwise modify the original request.
+  // 2. The repository name part, before /<CMD>.
+  // 3. The command, <CMD>, with no slashes and beginning with +. Commands have
+  //    names analogous to (but not exactly the same as) git command names, such
+  //    as "+log" and "+show". The bare command "+" maps to one of the other
+  //    commands based on the revision/path, and may change over time.
+  // 4. The revision/path part, after /<CMD> (called just "path" below). This is
+  //    split into a revision and a path by RevisionParser.
+
+  private static final String CMD = "\\+[a-z0-9-]*";
+
+  @VisibleForTesting
+  static final Pattern ROOT_REGEX = Pattern.compile(""
+      + "^(      " // 1. Everything
+      + "  /*    " // Excess slashes
+      + "  (/)   " // 2. Repo name (just slash)
+      + "  ()    " // 3. Command
+      + "  ()    " // 4. Path
+      + ")$      ",
+      Pattern.COMMENTS);
+
+  @VisibleForTesting
+  static final Pattern REPO_REGEX = Pattern.compile(""
+      + "^(                     " // 1. Everything
+      + "  /*                   " // Excess slashes
+      + "  (                    " // 2. Repo name
+      + "   /                   " // Leading slash
+      + "   (?:.(?!             " // Anything, as long as it's not followed by...
+      + "        /" + CMD + "/  " // the special "/<CMD>/" separator,
+      + "        |/" + CMD + "$ " // or "/<CMD>" at the end of the string
+      + "        ))*?           "
+      + "  )                    "
+      + "  /*                   " // Trailing slashes
+      + "  ()                   " // 3. Command
+      + "  ()                   " // 4. Path
+      + ")$                     ",
+      Pattern.COMMENTS);
+
+  @VisibleForTesting
+  static final Pattern REPO_PATH_REGEX = Pattern.compile(""
+      + "^(              " // 1. Everything
+      + "  /*            " // Excess slashes
+      + "  (             " // 2. Repo name
+      + "   /            " // Leading slash
+      + "   .*?          " // Anything, non-greedy
+      + "  )             "
+      + "  /(" + CMD + ")" // 3. Command
+      + "  (             " // 4. Path
+      + "   (?:/.*)?     " // Slash path, or nothing.
+      + "  )             "
+      + ")$              ",
+      Pattern.COMMENTS);
+
+  private static class DispatchFilter extends AbstractHttpFilter {
+    private final ListMultimap<GitilesView.Type, Filter> filters;
+    private final Map<GitilesView.Type, HttpServlet> servlets;
+
+    private DispatchFilter(ListMultimap<GitilesView.Type, Filter> filters,
+        Map<GitilesView.Type, HttpServlet> servlets) {
+      this.filters = LinkedListMultimap.create(filters);
+      this.servlets = ImmutableMap.copyOf(servlets);
+      for (GitilesView.Type type : GitilesView.Type.values()) {
+        checkState(servlets.containsKey(type), "Missing handler for view %s", type);
+      }
+    }
+
+    @Override
+    public void doFilter(HttpServletRequest req, HttpServletResponse res, FilterChain chain)
+        throws IOException, ServletException {
+      GitilesView view = checkNotNull(ViewFilter.getView(req));
+      final Iterator<Filter> itr = filters.get(view.getType()).iterator();
+      final HttpServlet servlet = servlets.get(view.getType());
+      new FilterChain() {
+        @Override
+        public void doFilter(ServletRequest req, ServletResponse res)
+            throws IOException, ServletException {
+          if (itr.hasNext()) {
+            itr.next().doFilter(req, res, this);
+          } else {
+            servlet.service(req, res);
+          }
+        }
+      }.doFilter(req, res);
+    }
+  }
+
+  private final ListMultimap<GitilesView.Type, Filter> filters = LinkedListMultimap.create();
+  private final Map<GitilesView.Type, HttpServlet> servlets = Maps.newHashMap();
+
+  private Renderer renderer;
+  private GitilesUrls urls;
+  private Linkifier linkifier;
+  private GitilesAccess.Factory accessFactory;
+  private RepositoryResolver<HttpServletRequest> resolver;
+  private VisibilityCache visibilityCache;
+  private boolean initialized;
+
+  GitilesFilter() {
+  }
+
+  GitilesFilter(
+      Renderer renderer,
+      GitilesUrls urls,
+      GitilesAccess.Factory accessFactory,
+      final RepositoryResolver<HttpServletRequest> resolver,
+      VisibilityCache visibilityCache) {
+    this.renderer = checkNotNull(renderer, "renderer");
+    this.urls = checkNotNull(urls, "urls");
+    this.accessFactory = checkNotNull(accessFactory, "accessFactory");
+    this.visibilityCache = checkNotNull(visibilityCache, "visibilityCache");
+    this.linkifier = new Linkifier(urls);
+    this.resolver = wrapResolver(resolver);
+  }
+
+  @Override
+  public synchronized void init(FilterConfig config) throws ServletException {
+    super.init(config);
+    setDefaultFields(config);
+
+    for (GitilesView.Type type : GitilesView.Type.values()) {
+      if (!servlets.containsKey(type)) {
+        servlets.put(type, getDefaultHandler(type));
+      }
+    }
+
+    Filter repositoryFilter = new RepositoryFilter(resolver);
+    Filter viewFilter = new ViewFilter(accessFactory, urls, visibilityCache);
+    Filter dispatchFilter = new DispatchFilter(filters, servlets);
+    String browserCssName;
+    String prettifyCssName;
+    String prettifyJsName;
+
+    serveRegex(ROOT_REGEX)
+        .through(viewFilter)
+        .through(dispatchFilter);
+
+    serveRegex(REPO_REGEX)
+        .through(repositoryFilter)
+        .through(viewFilter)
+        .through(dispatchFilter);
+
+    serveRegex(REPO_PATH_REGEX)
+        .through(repositoryFilter)
+        .through(viewFilter)
+        .through(dispatchFilter);
+
+    initialized = true;
+  }
+
+  public synchronized BaseServlet getDefaultHandler(GitilesView.Type view) {
+    checkNotInitialized();
+    switch (view) {
+      case HOST_INDEX:
+        return new HostIndexServlet(renderer, urls, accessFactory);
+      case REPOSITORY_INDEX:
+        return new RepositoryIndexServlet(renderer, accessFactory);
+      case REVISION:
+        return new RevisionServlet(renderer, linkifier);
+      case PATH:
+        return new PathServlet(renderer);
+      case DIFF:
+        return new DiffServlet(renderer, linkifier);
+      case LOG:
+        return new LogServlet(renderer, linkifier);
+      default:
+        throw new IllegalArgumentException("Invalid view type: " + view);
+    }
+  }
+
+  synchronized void addFilter(GitilesView.Type view, Filter filter) {
+    checkNotInitialized();
+    filters.put(checkNotNull(view, "view"), checkNotNull(filter, "filter for %s", view));
+  }
+
+  synchronized void setHandler(GitilesView.Type view, HttpServlet handler) {
+    checkNotInitialized();
+    servlets.put(checkNotNull(view, "view"),
+        checkNotNull(handler, "handler for %s", view));
+  }
+
+  private synchronized void checkNotInitialized() {
+    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 void setDefaultFields(FilterConfig config) throws ServletException {
+    if (renderer != null && urls != null && accessFactory != null && resolver != null
+        && visibilityCache != null) {
+      return;
+    }
+    String configPath = config.getInitParameter(CONFIG_PATH_PARAM);
+    if (configPath == null) {
+      throw new ServletException("Missing required parameter " + configPath);
+    }
+    FileBasedConfig jgitConfig = new FileBasedConfig(new File(configPath), FS.DETECTED);
+    try {
+      jgitConfig.load();
+    } catch (IOException e) {
+      throw new ServletException(e);
+    } catch (ConfigInvalidException e) {
+      throw new ServletException(e);
+    }
+
+    if (renderer == null) {
+      String staticPrefix = config.getServletContext().getContextPath() + STATIC_PREFIX;
+      String customTemplates = jgitConfig.getString("gitiles", null, "customTemplates");
+      // TODO(dborowitz): Automatically set to true when run with mvn jetty:run.
+      if (jgitConfig.getBoolean("gitiles", null, "reloadTemplates", false)) {
+        renderer = new DebugRenderer(staticPrefix, customTemplates,
+            Joiner.on(File.separatorChar).join(System.getProperty("user.dir"),
+                "gitiles-servlet", "src", "main", "resources",
+                "com", "google", "gitiles", "templates"));
+      } else {
+        renderer = new DefaultRenderer(staticPrefix, Renderer.toFileURL(customTemplates));
+      }
+    }
+    if (urls == null) {
+      try {
+        urls = new DefaultUrls(
+            jgitConfig.getString("gitiles", null, "canonicalHostName"),
+            getBaseGitUrl(jgitConfig),
+            getGerritUrl(jgitConfig));
+      } catch (UnknownHostException e) {
+        throw new ServletException(e);
+      }
+    }
+    linkifier = new Linkifier(urls);
+    if (accessFactory == null || resolver == null) {
+      String basePath = jgitConfig.getString("gitiles", null, "basePath");
+      if (basePath == null) {
+        throw new ServletException("gitiles.basePath not set");
+      }
+      boolean exportAll = jgitConfig.getBoolean("gitiles", null, "exportAll", false);
+
+      FileResolver<HttpServletRequest> fileResolver;
+      if (resolver == null) {
+        fileResolver = new FileResolver<HttpServletRequest>(new File(basePath), exportAll);
+        resolver = wrapResolver(fileResolver);
+      } else if (resolver instanceof FileResolver) {
+        fileResolver = (FileResolver<HttpServletRequest>) resolver;
+      } else {
+        fileResolver = null;
+      }
+      if (accessFactory == null) {
+        checkState(fileResolver != null, "need a FileResolver when GitilesAccess.Factory not set");
+        try {
+        accessFactory = new DefaultAccess.Factory(
+            new File(basePath),
+            getBaseGitUrl(jgitConfig),
+            fileResolver);
+        } catch (IOException e) {
+          throw new ServletException(e);
+        }
+      }
+    }
+    if (visibilityCache == null) {
+      if (jgitConfig.getSubsections("cache").contains("visibility")) {
+        visibilityCache =
+            new VisibilityCache(false, ConfigUtil.getCacheBuilder(jgitConfig, "visibility"));
+      } else {
+        visibilityCache = new VisibilityCache(false);
+      }
+    }
+  }
+
+  private static String getBaseGitUrl(Config config) throws ServletException {
+    String baseGitUrl = config.getString("gitiles", null, "baseGitUrl");
+    if (baseGitUrl == null) {
+      throw new ServletException("gitiles.baseGitUrl not set");
+    }
+    return baseGitUrl;
+  }
+
+  private static String getGerritUrl(Config config) throws ServletException {
+    String gerritUrl = config.getString("gitiles", null, "gerritUrl");
+    if (gerritUrl == null) {
+      throw new ServletException("gitiles.gerritUrl not set");
+    }
+    return gerritUrl;
+  }
+}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/GitilesServlet.java b/gitiles-servlet/src/main/java/com/google/gitiles/GitilesServlet.java
new file mode 100644
index 0000000..2cdbc02
--- /dev/null
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/GitilesServlet.java
@@ -0,0 +1,113 @@
+// Copyright 2012 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 org.eclipse.jgit.http.server.glue.MetaServlet;
+import org.eclipse.jgit.transport.resolver.RepositoryResolver;
+
+import java.util.Enumeration;
+
+import javax.servlet.Filter;
+import javax.servlet.FilterConfig;
+import javax.servlet.ServletConfig;
+import javax.servlet.ServletContext;
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServlet;
+import javax.servlet.http.HttpServletRequest;
+
+/**
+ * Servlet to serve Gitiles.
+ * <p>
+ * This servlet can either be constructed manually with its dependencies, or
+ * configured to use default implementations for the Gitiles interfaces. To
+ * configure the defaults, you must provide a single init parameter
+ * "configPath", which is the path to a git config file containing additional
+ * configuration.
+ */
+public class GitilesServlet extends MetaServlet {
+  /** The prefix from which static resources are served. */
+  public static final String STATIC_PREFIX = "/+static/";
+
+  public GitilesServlet(Renderer renderer,
+      GitilesUrls urls,
+      GitilesAccess.Factory accessFactory,
+      RepositoryResolver<HttpServletRequest> resolver,
+      VisibilityCache visibilityCache) {
+    super(new GitilesFilter(renderer, urls, accessFactory, resolver, visibilityCache));
+  }
+
+  public GitilesServlet() {
+    super(new GitilesFilter());
+  }
+
+  @Override
+  protected GitilesFilter getDelegateFilter() {
+    return (GitilesFilter) super.getDelegateFilter();
+  }
+
+  @Override
+  public void init(final ServletConfig config) throws ServletException {
+    getDelegateFilter().init(new FilterConfig() {
+      @Override
+      public String getFilterName() {
+        return getDelegateFilter().getClass().getName();
+      }
+
+      @Override
+      public String getInitParameter(String name) {
+        return config.getInitParameter(name);
+      }
+
+      @SuppressWarnings("rawtypes")
+      @Override
+      public Enumeration getInitParameterNames() {
+        return config.getInitParameterNames();
+      }
+
+      @Override
+      public ServletContext getServletContext() {
+        return config.getServletContext();
+      }
+    });
+  }
+
+  /**
+   * Add a custom filter for a view.
+   * <p>
+   * Must be called before initializing the servlet.
+   *
+   * @param view view type.
+   * @param filter filter.
+   */
+  public void addFilter(GitilesView.Type view, Filter filter) {
+    getDelegateFilter().addFilter(view, filter);
+  }
+
+  /**
+   * Set a custom handler for a view.
+   * <p>
+   * Must be called before initializing the servlet.
+   *
+   * @param view view type.
+   * @param handler handler.
+   */
+  public void setHandler(GitilesView.Type view, HttpServlet handler) {
+    getDelegateFilter().setHandler(view, handler);
+  }
+
+  public BaseServlet getDefaultHandler(GitilesView.Type view) {
+    return getDelegateFilter().getDefaultHandler(view);
+  }
+}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/GitilesUrls.java b/gitiles-servlet/src/main/java/com/google/gitiles/GitilesUrls.java
new file mode 100644
index 0000000..fb7ff3b
--- /dev/null
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/GitilesUrls.java
@@ -0,0 +1,79 @@
+// Copyright 2012 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 com.google.common.base.Charsets;
+import com.google.common.base.Function;
+
+import java.io.UnsupportedEncodingException;
+import java.net.URLEncoder;
+
+import javax.servlet.http.HttpServletRequest;
+
+/** Interface for URLs displayed on source browsing pages. */
+public interface GitilesUrls {
+  /**
+   * Escapes repository or path names to be safely embedded into a URL.
+   * <p>
+   * This escape implementation escapes a repository or path name such as
+   * "foo/bar</child" to appear as "foo/bar%3C/child". Spaces are escaped as
+   * "%20". Its purpose is to escape a repository name to be safe for inclusion
+   * in the path component of the URL, where "/" is a valid character that
+   * should not be encoded, while almost any other non-alpha, non-numeric
+   * character will be encoded using URL style encoding.
+   */
+  public static final Function<String, String> NAME_ESCAPER = new Function<String, String>() {
+    @Override
+    public String apply(String s) {
+      try {
+        return URLEncoder.encode(s, Charsets.UTF_8.name())
+            .replace("%2F", "/")
+            .replace("%2f", "/")
+            .replace("+", "%20")
+            .replace("%2B", "+")
+            .replace("%2b", "+");
+      } catch (UnsupportedEncodingException e) {
+        throw new IllegalStateException(e);
+      }
+    }
+  };
+
+  /**
+   * Return the name of the host from the request.
+   *
+   * Used in various user-visible text, like "MyHost Git Repositories".
+   *
+   * @param req request.
+   * @return host name; may be null.
+   */
+  public String getHostName(HttpServletRequest req);
+
+  /**
+   * Return the base URL for git repositories on this host.
+   *
+   * @param req request.
+   * @return base URL for git repositories.
+   */
+  public String getBaseGitUrl(HttpServletRequest req);
+
+  /**
+   * Return the base URL for Gerrit projects on this host.
+   *
+   * @param req request.
+   * @return base URL for Gerrit Code Review, or null if Gerrit is not
+   *     configured.
+   */
+  public String getBaseGerritUrl(HttpServletRequest req);
+}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/GitilesView.java b/gitiles-servlet/src/main/java/com/google/gitiles/GitilesView.java
new file mode 100644
index 0000000..ad685c4
--- /dev/null
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/GitilesView.java
@@ -0,0 +1,563 @@
+// Copyright 2012 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.common.base.Preconditions.checkState;
+import static com.google.gitiles.GitilesUrls.NAME_ESCAPER;
+
+import com.google.common.base.Charsets;
+import com.google.common.base.Objects;
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.LinkedListMultimap;
+import com.google.common.collect.ListMultimap;
+import com.google.common.collect.Multimaps;
+
+import org.eclipse.jgit.revwalk.RevObject;
+
+import java.io.UnsupportedEncodingException;
+import java.net.URLEncoder;
+import java.util.List;
+import java.util.Map;
+
+import javax.servlet.http.HttpServletRequest;
+
+/**
+ * Information about a view in Gitiles.
+ * <p>
+ * Views are uniquely identified by a type, and dispatched to servlet types by
+ * {@link GitilesServlet}. This class contains the list of all types, as
+ * well as some methods containing basic information parsed from the URL.
+ * Construction happens in {@link ViewFilter}.
+ */
+public class GitilesView {
+  /** All the possible view types supported in the application. */
+  public static enum Type {
+    HOST_INDEX,
+    REPOSITORY_INDEX,
+    REVISION,
+    PATH,
+    DIFF,
+    LOG;
+  }
+
+  /** Builder for views. */
+  public static class Builder {
+    private final Type type;
+    private final ListMultimap<String, String> params = LinkedListMultimap.create();
+
+    private String hostName;
+    private String servletPath;
+    private String repositoryName;
+    private Revision revision = Revision.NULL;
+    private Revision oldRevision = Revision.NULL;
+    private String path;
+    private String anchor;
+
+    private Builder(Type type) {
+      this.type = type;
+    }
+
+    public Builder copyFrom(GitilesView other) {
+      hostName = other.hostName;
+      servletPath = other.servletPath;
+      switch (type) {
+        case LOG:
+        case DIFF:
+          oldRevision = other.oldRevision;
+          // Fallthrough.
+        case PATH:
+          path = other.path;
+          // Fallthrough.
+        case REVISION:
+          revision = other.revision;
+          // Fallthrough.
+        case REPOSITORY_INDEX:
+          repositoryName = other.repositoryName;
+      }
+      // Don't copy params.
+      return this;
+    }
+
+    public Builder copyFrom(HttpServletRequest req) {
+      return copyFrom(ViewFilter.getView(req));
+    }
+
+    public Builder setHostName(String hostName) {
+      this.hostName = checkNotNull(hostName);
+      return this;
+    }
+
+    public String getHostName() {
+      return hostName;
+    }
+
+    public Builder setServletPath(String servletPath) {
+      this.servletPath = checkNotNull(servletPath);
+      return this;
+    }
+
+    public String getServletPath() {
+      return servletPath;
+    }
+
+    public Builder setRepositoryName(String repositoryName) {
+      switch (type) {
+        case HOST_INDEX:
+          throw new IllegalStateException(String.format(
+              "cannot set repository name on %s view", type));
+        default:
+          this.repositoryName = checkNotNull(repositoryName);
+          return this;
+      }
+    }
+
+    public String getRepositoryName() {
+      return repositoryName;
+    }
+
+    public Builder setRevision(Revision revision) {
+      switch (type) {
+        case HOST_INDEX:
+        case REPOSITORY_INDEX:
+          throw new IllegalStateException(String.format("cannot set revision on %s view", type));
+        default:
+          this.revision = checkNotNull(revision);
+          return this;
+      }
+    }
+
+    public Builder setRevision(String name) {
+      return setRevision(Revision.named(name));
+    }
+
+    public Builder setRevision(RevObject obj) {
+      return setRevision(Revision.peeled(obj.name(), obj));
+    }
+
+    public Builder setRevision(String name, RevObject obj) {
+      return setRevision(Revision.peeled(name, obj));
+    }
+
+    public Revision getRevision() {
+      return revision;
+    }
+
+    public Builder setOldRevision(Revision revision) {
+      switch (type) {
+        case DIFF:
+        case LOG:
+          this.oldRevision = checkNotNull(revision);
+          return this;
+        default:
+          throw new IllegalStateException(
+              String.format("cannot set old revision on %s view", type));
+      }
+    }
+
+    public Builder setOldRevision(RevObject obj) {
+      return setOldRevision(Revision.peeled(obj.name(), obj));
+    }
+
+    public Builder setOldRevision(String name, RevObject obj) {
+      return setOldRevision(Revision.peeled(name, obj));
+    }
+
+    public Revision getOldRevision() {
+      return revision;
+    }
+
+    public Builder setTreePath(String path) {
+      switch (type) {
+        case PATH:
+        case DIFF:
+          this.path = maybeTrimLeadingAndTrailingSlash(checkNotNull(path));
+          return this;
+        case LOG:
+          this.path = path != null ? maybeTrimLeadingAndTrailingSlash(path) : null;
+          return this;
+        default:
+          throw new IllegalStateException(String.format("cannot set path on %s view", type));
+      }
+    }
+
+    public String getTreePath() {
+      return path;
+    }
+
+    public Builder putParam(String key, String value) {
+      params.put(key, value);
+      return this;
+    }
+
+    public Builder replaceParam(String key, String value) {
+      params.replaceValues(key, ImmutableList.of(value));
+      return this;
+    }
+
+    public Builder putAllParams(Map<String, String[]> params) {
+      for (Map.Entry<String, String[]> e : params.entrySet()) {
+        for (String v : e.getValue()) {
+          this.params.put(e.getKey(), v);
+        }
+      }
+      return this;
+    }
+
+    public ListMultimap<String, String> getParams() {
+      return params;
+    }
+
+    public Builder setAnchor(String anchor) {
+      this.anchor = anchor;
+      return this;
+    }
+
+    public String getAnchor() {
+      return anchor;
+    }
+
+    public GitilesView build() {
+      switch (type) {
+        case HOST_INDEX:
+          checkHostIndex();
+          break;
+        case REPOSITORY_INDEX:
+          checkRepositoryIndex();
+          break;
+        case REVISION:
+          checkRevision();
+          break;
+        case PATH:
+          checkPath();
+          break;
+        case DIFF:
+          checkDiff();
+          break;
+        case LOG:
+          checkLog();
+          break;
+      }
+      return new GitilesView(type, hostName, servletPath, repositoryName, revision,
+          oldRevision, path, params, anchor);
+    }
+
+    public String toUrl() {
+      return build().toUrl();
+    }
+
+    private void checkHostIndex() {
+      checkState(hostName != null, "missing hostName on %s view", type);
+      checkState(servletPath != null, "missing hostName on %s view", type);
+    }
+
+    private void checkRepositoryIndex() {
+      checkState(repositoryName != null, "missing repository name on %s view", type);
+      checkHostIndex();
+    }
+
+    private void checkRevision() {
+      checkState(revision != Revision.NULL, "missing revision on %s view", type);
+      checkRepositoryIndex();
+    }
+
+    private void checkDiff() {
+      checkPath();
+    }
+
+    private void checkLog() {
+      checkRevision();
+    }
+
+    private void checkPath() {
+      checkState(path != null, "missing path on %s view", type);
+      checkRevision();
+    }
+  }
+
+  public static Builder hostIndex() {
+    return new Builder(Type.HOST_INDEX);
+  }
+
+  public static Builder repositoryIndex() {
+    return new Builder(Type.REPOSITORY_INDEX);
+  }
+
+  public static Builder revision() {
+    return new Builder(Type.REVISION);
+  }
+
+  public static Builder path() {
+    return new Builder(Type.PATH);
+  }
+
+  public static Builder diff() {
+    return new Builder(Type.DIFF);
+  }
+
+  public static Builder log() {
+    return new Builder(Type.LOG);
+  }
+
+  private static String maybeTrimLeadingAndTrailingSlash(String str) {
+    if (str.startsWith("/")) {
+      str = str.substring(1);
+    }
+    return !str.isEmpty() && str.endsWith("/") ? str.substring(0, str.length() - 1) : str;
+  }
+
+  private final Type type;
+  private final String hostName;
+  private final String servletPath;
+  private final String repositoryName;
+  private final Revision revision;
+  private final Revision oldRevision;
+  private final String path;
+  private final ListMultimap<String, String> params;
+  private final String anchor;
+
+  private GitilesView(Type type,
+      String hostName,
+      String servletPath,
+      String repositoryName,
+      Revision revision,
+      Revision oldRevision,
+      String path,
+      ListMultimap<String, String> params,
+      String anchor) {
+    this.type = type;
+    this.hostName = hostName;
+    this.servletPath = servletPath;
+    this.repositoryName = repositoryName;
+    this.revision = Objects.firstNonNull(revision, Revision.NULL);
+    this.oldRevision = Objects.firstNonNull(oldRevision, Revision.NULL);
+    this.path = path;
+    this.params = Multimaps.unmodifiableListMultimap(params);
+    this.anchor = anchor;
+  }
+
+  public String getHostName() {
+    return hostName;
+  }
+
+  public String getServletPath() {
+    return servletPath;
+  }
+
+  public String getRepositoryName() {
+    return repositoryName;
+  }
+
+  public Revision getRevision() {
+    return revision;
+  }
+
+  public Revision getOldRevision() {
+    return oldRevision;
+  }
+
+  public String getRevisionRange() {
+    if (oldRevision == Revision.NULL) {
+      switch (type) {
+        case LOG:
+        case DIFF:
+          // For types that require two revisions, NULL indicates the empty
+          // tree/commit.
+          return revision.getName() + "^!";
+        default:
+          // For everything else NULL indicates it is not a range, just a single
+          // revision.
+          return null;
+      }
+    } else if (type == Type.DIFF && isFirstParent(revision, oldRevision)) {
+      return revision.getName() + "^!";
+    } else {
+      return oldRevision.getName() + ".." + revision.getName();
+    }
+  }
+
+  public String getTreePath() {
+    return path;
+  }
+
+  public ListMultimap<String, String> getParameters() {
+    return params;
+  }
+
+  public String getAnchor() {
+    return anchor;
+  }
+
+  public Type getType() {
+    return type;
+  }
+
+  /** @return an escaped, relative URL representing this view. */
+  public String toUrl() {
+    StringBuilder url = new StringBuilder(servletPath).append('/');
+    ListMultimap<String, String> params = this.params;
+    switch (type) {
+      case HOST_INDEX:
+        params = LinkedListMultimap.create();
+        if (!this.params.containsKey("format")) {
+          params.put("format", FormatType.HTML.toString());
+        }
+        params.putAll(this.params);
+        break;
+      case REPOSITORY_INDEX:
+        url.append(repositoryName).append('/');
+        break;
+      case REVISION:
+        url.append(repositoryName).append("/+");
+        if (!getRevision().nameIsId()) {
+          url.append("show"); // Default for /+/master is +log.
+        }
+        url.append('/').append(revision.getName());
+        break;
+      case PATH:
+        url.append(repositoryName).append("/+/").append(revision.getName()).append('/')
+            .append(path);
+        break;
+      case DIFF:
+        url.append(repositoryName).append("/+/");
+        if (isFirstParent(revision, oldRevision)) {
+          url.append(revision.getName()).append("^!");
+        } else {
+          url.append(oldRevision.getName()).append("..").append(revision.getName());
+        }
+        url.append('/').append(path);
+        break;
+      case LOG:
+        url.append(repositoryName).append("/+");
+        if (getRevision().nameIsId() || oldRevision != Revision.NULL || path != null) {
+         // Default for /+/c0ffee/(...) is +show.
+         // Default for /+/c0ffee..deadbeef(/...) is +diff.
+          url.append("log");
+        }
+        url.append('/');
+        if (oldRevision != Revision.NULL) {
+          url.append(oldRevision.getName()).append("..");
+        }
+        url.append(revision.getName());
+        if (path != null) {
+          url.append('/').append(path);
+        }
+        break;
+      default:
+        throw new IllegalStateException("Unknown view type: " + type);
+    }
+    String baseUrl = NAME_ESCAPER.apply(url.toString());
+    url = new StringBuilder();
+    if (!params.isEmpty()) {
+      url.append('?').append(paramsToString(params));
+    }
+    if (!Strings.isNullOrEmpty(anchor)) {
+      url.append('#').append(NAME_ESCAPER.apply(anchor));
+    }
+    return baseUrl + url.toString();
+  }
+
+  public List<Map<String, String>> getBreadcrumbs() {
+    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)));
+    }
+    if (type == Type.DIFF) {
+      // TODO(dborowitz): Tweak the breadcrumbs template to allow us to render
+      // separate links in "old..new".
+      breadcrumbs.add(breadcrumb(getRevisionRange(), diff().copyFrom(this).setTreePath("")));
+    } else if (type == Type.LOG) {
+      // TODO(dborowitz): Add something in the navigation area (probably not
+      // a breadcrumb) to allow switching between /+log/ and /+/.
+      if (oldRevision == Revision.NULL) {
+        breadcrumbs.add(breadcrumb(revision.getName(), log().copyFrom(this).setTreePath(null)));
+      } else {
+        breadcrumbs.add(breadcrumb(getRevisionRange(), log().copyFrom(this).setTreePath(null)));
+      }
+      path = Strings.emptyToNull(path);
+    } else if (revision != Revision.NULL) {
+      breadcrumbs.add(breadcrumb(revision.getName(), revision().copyFrom(this)));
+    }
+    if (path != null) {
+      if (type != Type.LOG) { // The "." breadcrumb would be no different for LOG.
+        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;
+        }
+        cur.append(part);
+        breadcrumbs.add(breadcrumb(part, copyWithPath().setTreePath(cur.toString())));
+      }
+    }
+    return breadcrumbs.build();
+  }
+
+  private static Map<String, String> breadcrumb(String text, Builder url) {
+    return ImmutableMap.of("text", text, "url", url.toUrl());
+  }
+
+  private Builder copyWithPath() {
+    Builder copy;
+    switch (type) {
+      case DIFF:
+        copy = diff();
+        break;
+      case LOG:
+        copy = log();
+        break;
+      default:
+        copy = path();
+        break;
+    }
+    return copy.copyFrom(this);
+  }
+
+  private static boolean isFirstParent(Revision rev1, Revision rev2) {
+    return rev2 == Revision.NULL
+        || rev2.getName().equals(rev1.getName() + "^")
+        || rev2.getName().equals(rev1.getName() + "~1");
+  }
+
+  private static String paramsToString(ListMultimap<String, String> params) {
+    try {
+    StringBuilder sb = new StringBuilder();
+    boolean first = true;
+    for (Map.Entry<String, String> e : params.entries()) {
+      if (!first) {
+        sb.append('&');
+      } else {
+        first = false;
+      }
+      sb.append(URLEncoder.encode(e.getKey(), Charsets.UTF_8.name()));
+      if (!"".equals(e.getValue())) {
+        sb.append('=')
+            .append(URLEncoder.encode(e.getValue(), Charsets.UTF_8.name()));
+      }
+    }
+    return sb.toString();
+    } catch (UnsupportedEncodingException e) {
+      throw new IllegalStateException(e);
+    }
+  }
+}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/HostIndexServlet.java b/gitiles-servlet/src/main/java/com/google/gitiles/HostIndexServlet.java
new file mode 100644
index 0000000..bf9ffd1
--- /dev/null
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/HostIndexServlet.java
@@ -0,0 +1,199 @@
+// Copyright 2012 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.FormatType.JSON;
+import static com.google.gitiles.FormatType.TEXT;
+import static javax.servlet.http.HttpServletResponse.SC_BAD_REQUEST;
+import static javax.servlet.http.HttpServletResponse.SC_FORBIDDEN;
+import static javax.servlet.http.HttpServletResponse.SC_NOT_FOUND;
+import static javax.servlet.http.HttpServletResponse.SC_SERVICE_UNAVAILABLE;
+import static javax.servlet.http.HttpServletResponse.SC_UNAUTHORIZED;
+
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Sets;
+import com.google.common.net.HttpHeaders;
+import com.google.gson.FieldNamingPolicy;
+import com.google.gson.GsonBuilder;
+import com.google.gson.reflect.TypeToken;
+import com.google.template.soy.data.SoyListData;
+import com.google.template.soy.data.SoyMapData;
+
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.transport.ServiceMayNotContinueException;
+import org.eclipse.jgit.transport.resolver.ServiceNotAuthorizedException;
+import org.eclipse.jgit.transport.resolver.ServiceNotEnabledException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.io.PrintWriter;
+import java.util.Arrays;
+import java.util.Map;
+import java.util.Set;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+/** Serves the top level index page for a Gitiles host. */
+public class HostIndexServlet extends BaseServlet {
+  private static final Logger log = LoggerFactory.getLogger(HostIndexServlet.class);
+
+  protected final GitilesUrls urls;
+  private final GitilesAccess.Factory accessFactory;
+
+  public HostIndexServlet(Renderer renderer, GitilesUrls urls,
+      GitilesAccess.Factory accessFactory) {
+    super(renderer);
+    this.urls = checkNotNull(urls, "urls");
+    this.accessFactory = checkNotNull(accessFactory, "accessFactory");
+  }
+
+  @Override
+  protected void doGet(HttpServletRequest req, HttpServletResponse res) throws IOException {
+    FormatType format;
+    try {
+      format = FormatType.getFormatType(req);
+    } catch (IllegalArgumentException err) {
+      res.sendError(SC_BAD_REQUEST);
+      return;
+    }
+
+    Set<String> branches = parseShowBranch(req);
+    Map<String, RepositoryDescription> descs;
+    try {
+      descs = accessFactory.forRequest(req).listRepositories(branches);
+    } catch (RepositoryNotFoundException e) {
+      res.sendError(SC_NOT_FOUND);
+      return;
+    } catch (ServiceNotEnabledException e) {
+      res.sendError(SC_FORBIDDEN);
+      return;
+    } catch (ServiceNotAuthorizedException e) {
+      res.sendError(SC_UNAUTHORIZED);
+      return;
+    } catch (ServiceMayNotContinueException e) {
+      // TODO(dborowitz): Show the error message to the user.
+      res.sendError(SC_FORBIDDEN);
+      return;
+    } catch (IOException err) {
+      String name = urls.getHostName(req);
+      log.warn("Cannot scan repositories" + (name != null ? "for " + name : ""), err);
+      res.sendError(SC_SERVICE_UNAVAILABLE);
+      return;
+    }
+
+    switch (format) {
+      case HTML:
+      case DEFAULT:
+      default:
+        displayHtml(req, res, descs);
+        break;
+
+      case TEXT:
+        displayText(req, res, branches, descs);
+        break;
+
+      case JSON:
+        displayJson(req, res, descs);
+        break;
+    }
+  }
+
+  private SoyMapData toSoyMapData(RepositoryDescription desc, GitilesView view) {
+    return new SoyMapData(
+        "name", desc.name,
+        "description", Strings.nullToEmpty(desc.description),
+        "url", GitilesView.repositoryIndex()
+            .copyFrom(view)
+            .setRepositoryName(desc.name)
+            .toUrl());
+  }
+
+  private void displayHtml(HttpServletRequest req, HttpServletResponse res,
+      Map<String, RepositoryDescription> descs) throws IOException {
+    SoyListData repos = new SoyListData();
+    for (RepositoryDescription desc : descs.values()) {
+      repos.add(toSoyMapData(desc, ViewFilter.getView(req)));
+    }
+
+    render(req, res, "gitiles.hostIndex", ImmutableMap.of(
+        "hostName", urls.getHostName(req),
+        "baseUrl", urls.getBaseGitUrl(req),
+        "repositories", repos));
+  }
+
+  private void displayText(HttpServletRequest req, HttpServletResponse res,
+      Set<String> branches, Map<String, RepositoryDescription> descs) throws IOException {
+    res.setContentType(TEXT.getMimeType());
+    res.setCharacterEncoding("UTF-8");
+    res.setHeader(HttpHeaders.CONTENT_DISPOSITION, "attachment");
+    setNotCacheable(res);
+
+    PrintWriter writer = res.getWriter();
+    for (RepositoryDescription repo : descs.values()) {
+      for (String name : branches) {
+        String ref = repo.branches.get(name);
+        if (ref == null) {
+          // Print stub (forty '-' symbols)
+          ref = "----------------------------------------";
+        }
+        writer.print(ref);
+        writer.print(' ');
+      }
+      writer.print(GitilesUrls.NAME_ESCAPER.apply(repo.name));
+      writer.print('\n');
+    }
+    writer.flush();
+    writer.close();
+  }
+
+  private void displayJson(HttpServletRequest req, HttpServletResponse res,
+      Map<String, RepositoryDescription> descs) throws IOException {
+    res.setContentType(JSON.getMimeType());
+    res.setCharacterEncoding("UTF-8");
+    res.setHeader(HttpHeaders.CONTENT_DISPOSITION, "attachment");
+    setNotCacheable(res);
+
+    PrintWriter writer = res.getWriter();
+    new GsonBuilder()
+        .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES)
+        .setPrettyPrinting()
+        .generateNonExecutableJson()
+        .create()
+        .toJson(descs,
+          new TypeToken<Map<String, RepositoryDescription>>() {}.getType(),
+          writer);
+    writer.print('\n');
+    writer.close();
+  }
+
+  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.
+    Set<String> branches = Sets.newLinkedHashSet();
+    String[] values = req.getParameterValues("show-branch");
+    if (values != null) {
+      branches.addAll(Arrays.asList(values));
+    }
+    values = req.getParameterValues("b");
+    if (values != null) {
+      branches.addAll(Arrays.asList(values));
+    }
+    return branches;
+  }
+}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/HtmlDiffFormatter.java b/gitiles-servlet/src/main/java/com/google/gitiles/HtmlDiffFormatter.java
new file mode 100644
index 0000000..63b1eb8
--- /dev/null
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/HtmlDiffFormatter.java
@@ -0,0 +1,128 @@
+// Copyright 2012 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 com.google.common.base.Charsets;
+import com.google.common.collect.ImmutableMap;
+
+import org.apache.commons.lang3.StringEscapeUtils;
+import org.eclipse.jgit.diff.DiffEntry;
+import org.eclipse.jgit.diff.DiffFormatter;
+import org.eclipse.jgit.diff.RawText;
+import org.eclipse.jgit.patch.FileHeader;
+import org.eclipse.jgit.patch.FileHeader.PatchType;
+import org.eclipse.jgit.util.RawParseUtils;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.util.List;
+
+/** Formats a unified format patch as UTF-8 encoded HTML. */
+final class HtmlDiffFormatter extends DiffFormatter {
+  private static final byte[] DIFF_BEGIN = "<pre class=\"diff-unified\">".getBytes(Charsets.UTF_8);
+  private static final byte[] DIFF_END = "</pre>".getBytes(Charsets.UTF_8);
+
+  private static final byte[] HUNK_BEGIN = "<span class=\"h\">".getBytes(Charsets.UTF_8);
+  private static final byte[] HUNK_END = "</span>".getBytes(Charsets.UTF_8);
+
+  private static final byte[] LINE_INSERT_BEGIN = "<span class=\"i\">".getBytes(Charsets.UTF_8);
+  private static final byte[] LINE_DELETE_BEGIN = "<span class=\"d\">".getBytes(Charsets.UTF_8);
+  private static final byte[] LINE_CHANGE_BEGIN = "<span class=\"c\">".getBytes(Charsets.UTF_8);
+  private static final byte[] LINE_END = "</span>\n".getBytes(Charsets.UTF_8);
+
+  private final Renderer renderer;
+  private int fileIndex;
+
+  HtmlDiffFormatter(Renderer renderer, OutputStream out) {
+    super(out);
+    this.renderer = checkNotNull(renderer, "renderer");
+  }
+
+  @Override
+  public void format(List<? extends DiffEntry> entries) throws IOException {
+    for (fileIndex = 0; fileIndex < entries.size(); fileIndex++) {
+      format(entries.get(fileIndex));
+    }
+  }
+
+  @Override
+  public void format(FileHeader hdr, RawText a, RawText b)
+      throws IOException {
+    int start = hdr.getStartOffset();
+    int end = hdr.getEndOffset();
+    if (!hdr.getHunks().isEmpty()) {
+      end = hdr.getHunks().get(0).getStartOffset();
+    }
+    renderHeader(RawParseUtils.decode(hdr.getBuffer(), start, end));
+
+    if (hdr.getPatchType() == PatchType.UNIFIED) {
+      getOutputStream().write(DIFF_BEGIN);
+      format(hdr.toEditList(), a, b);
+      getOutputStream().write(DIFF_END);
+    }
+  }
+
+  private void renderHeader(String header)
+      throws IOException {
+    int lf = header.indexOf('\n');
+    String first;
+    String rest;
+    if (0 <= lf) {
+      first = header.substring(0, lf);
+      rest = header.substring(lf + 1);
+    } else {
+      first = header;
+      rest = "";
+    }
+    getOutputStream().write(renderer.newRenderer("gitiles.diffHeader")
+        .setData(ImmutableMap.of("first", first, "rest", rest, "fileIndex", fileIndex))
+        .render()
+        .getBytes(Charsets.UTF_8));
+  }
+
+  @Override
+  protected void writeHunkHeader(int aStartLine, int aEndLine,
+      int bStartLine, int bEndLine) throws IOException {
+    getOutputStream().write(HUNK_BEGIN);
+    // TODO(sop): If hunk header starts including method names, escape it.
+    super.writeHunkHeader(aStartLine, aEndLine, bStartLine, bEndLine);
+    getOutputStream().write(HUNK_END);
+  }
+
+  @Override
+  protected void writeLine(char prefix, RawText text, int cur)
+      throws IOException {
+    // Manually render each line, rather than invoke a Soy template. This method
+    // can be called thousands of times in a single request. Avoid unnecessary
+    // overheads by formatting as-is.
+    OutputStream out = getOutputStream();
+    switch (prefix) {
+      case '+':
+        out.write(LINE_INSERT_BEGIN);
+        break;
+      case '-':
+        out.write(LINE_DELETE_BEGIN);
+        break;
+      case ' ':
+      default:
+        out.write(LINE_CHANGE_BEGIN);
+        break;
+    }
+    out.write(StringEscapeUtils.escapeHtml4(text.getString(cur)).getBytes(Charsets.UTF_8));
+    out.write(LINE_END);
+  }
+}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/Linkifier.java b/gitiles-servlet/src/main/java/com/google/gitiles/Linkifier.java
new file mode 100644
index 0000000..735e6f5
--- /dev/null
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/Linkifier.java
@@ -0,0 +1,96 @@
+// Copyright 2012 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 com.google.common.base.Joiner;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Lists;
+
+import java.util.List;
+import java.util.Map;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import javax.servlet.http.HttpServletRequest;
+
+/** Linkifier for blocks of text such as commit message descriptions. */
+public class Linkifier {
+  private static final Pattern LINK_PATTERN;
+
+  static {
+    // HTTP URL regex adapted from com.google.gwtexpui.safehtml.client.SafeHtml.
+    String part = "[a-zA-Z0-9$_.+!*',%;:@=?#/~<>-]";
+    String httpUrl = "https?://" +
+        part + "{2,}" +
+        "(?:[(]" + part + "*" + "[)])*" +
+        part + "*";
+    String changeId = "\\bI[0-9a-f]{8,40}\\b";
+    LINK_PATTERN = Pattern.compile(Joiner.on("|").join(
+        "(" + httpUrl + ")",
+        "(" + changeId + ")"));
+  }
+
+  private final GitilesUrls urls;
+
+  public Linkifier(GitilesUrls urls) {
+    this.urls = checkNotNull(urls, "urls");
+  }
+
+  public List<Map<String, String>> linkify(HttpServletRequest req, String message) {
+    String baseGerritUrl = urls.getBaseGerritUrl(req);
+    List<Map<String, String>> parsed = Lists.newArrayList();
+    Matcher m = LINK_PATTERN.matcher(message);
+    int last = 0;
+    while (m.find()) {
+      addText(parsed, message.substring(last, m.start()));
+      if (m.group(1) != null) {
+        // Bare URL.
+        parsed.add(link(m.group(1), m.group(1)));
+      } else if (m.group(2) != null) {
+        if (baseGerritUrl != null) {
+          // Gerrit Change-Id.
+          parsed.add(link(m.group(2), baseGerritUrl + "#/q/" + m.group(2) + ",n,z"));
+        } else {
+          addText(parsed, m.group(2));
+        }
+      }
+      last = m.end();
+    }
+    addText(parsed, message.substring(last));
+    return parsed;
+  }
+
+  private static Map<String, String> link(String text, String url) {
+    return ImmutableMap.of("text", text, "url", url);
+  }
+
+  private static void addText(List<Map<String, String>> parts, String text) {
+    if (text.isEmpty()) {
+      return;
+    }
+    if (parts.isEmpty()) {
+      parts.add(ImmutableMap.of("text", text));
+    } else {
+      Map<String, String> old = parts.get(parts.size() - 1);
+      if (!old.containsKey("url")) {
+        parts.set(parts.size() - 1, ImmutableMap.of("text", old.get("text") + text));
+      } else {
+        parts.add(ImmutableMap.of("text", text));
+      }
+    }
+  }
+}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/LogServlet.java b/gitiles-servlet/src/main/java/com/google/gitiles/LogServlet.java
new file mode 100644
index 0000000..07977f7
--- /dev/null
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/LogServlet.java
@@ -0,0 +1,193 @@
+// Copyright 2012 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.checkArgument;
+import static com.google.common.base.Preconditions.checkNotNull;
+import static javax.servlet.http.HttpServletResponse.SC_INTERNAL_SERVER_ERROR;
+import static javax.servlet.http.HttpServletResponse.SC_NOT_FOUND;
+
+import com.google.common.base.Optional;
+import com.google.common.base.Strings;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.ListMultimap;
+import com.google.common.collect.Lists;
+import com.google.common.collect.Maps;
+import com.google.gitiles.CommitSoyData.KeySet;
+
+import org.eclipse.jgit.errors.IncorrectObjectTypeException;
+import org.eclipse.jgit.errors.MissingObjectException;
+import org.eclipse.jgit.errors.RevWalkException;
+import org.eclipse.jgit.http.server.ServletUtils;
+import org.eclipse.jgit.lib.AbbreviatedObjectId;
+import org.eclipse.jgit.lib.AnyObjectId;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.FollowFilter;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevObject;
+import org.eclipse.jgit.revwalk.RevTag;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+/** Serves an HTML page with a shortlog for commits and paths. */
+public class LogServlet extends BaseServlet {
+  private static final Logger log = LoggerFactory.getLogger(LogServlet.class);
+
+  private static final String START_PARAM = "s";
+
+  private final Linkifier linkifier;
+  private final int limit;
+
+  public LogServlet(Renderer renderer, Linkifier linkifier) {
+    this(renderer, linkifier, 100);
+  }
+
+  private LogServlet(Renderer renderer, Linkifier linkifier, int limit) {
+    super(renderer);
+    this.linkifier = checkNotNull(linkifier, "linkifier");
+    checkArgument(limit >= 0, "limit must be positive: %s", limit);
+    this.limit = limit;
+  }
+
+  @Override
+  protected void doGet(HttpServletRequest req, HttpServletResponse res) throws IOException {
+    GitilesView view = ViewFilter.getView(req);
+    Repository repo = ServletUtils.getRepository(req);
+    RevWalk walk = null;
+    try {
+      try {
+        walk = newWalk(repo, view);
+      } catch (IncorrectObjectTypeException e) {
+        res.setStatus(SC_NOT_FOUND);
+        return;
+      }
+
+      Optional<ObjectId> start = getStart(view.getParameters(), walk.getObjectReader());
+      if (start == null) {
+        res.setStatus(SC_NOT_FOUND);
+        return;
+      }
+
+      Map<String, Object> data = Maps.newHashMapWithExpectedSize(5);
+
+      if (!view.getRevision().nameIsId()) {
+        List<Map<String, Object>> tags = Lists.newArrayListWithExpectedSize(1);
+        for (RevObject o : RevisionServlet.listObjects(walk, view.getRevision().getId())) {
+          if (o instanceof RevTag) {
+            tags.add(new TagSoyData(linkifier, req).toSoyData((RevTag) o));
+          }
+        }
+        if (!tags.isEmpty()) {
+          data.put("tags", tags);
+        }
+      }
+
+      Paginator paginator = new Paginator(walk, limit, start.orNull());
+      Map<AnyObjectId, Set<Ref>> refsById = repo.getAllRefsByPeeledObjectId();
+      List<Map<String, Object>> entries = Lists.newArrayListWithCapacity(limit);
+      for (RevCommit c : paginator) {
+        entries.add(new CommitSoyData(null, req, repo, walk, view, refsById)
+            .toSoyData(c, KeySet.SHORTLOG));
+      }
+
+      String title = "Log - ";
+      if (view.getOldRevision() != Revision.NULL) {
+        title += view.getRevisionRange();
+      } else {
+        title += view.getRevision().getName();
+      }
+
+      data.put("title", title);
+      data.put("entries", entries);
+      ObjectId next = paginator.getNextStart();
+      if (next != null) {
+        data.put("nextUrl", copyAndCanonicalize(view)
+            .replaceParam(START_PARAM, next.name())
+            .toUrl());
+      }
+      ObjectId prev = paginator.getPreviousStart();
+      if (prev != null) {
+        GitilesView.Builder prevView = copyAndCanonicalize(view);
+        if (!prevView.getRevision().getId().equals(prev)) {
+          prevView.replaceParam(START_PARAM, prev.name());
+        }
+        data.put("previousUrl", prevView.toUrl());
+      }
+
+      render(req, res, "gitiles.logDetail", data);
+    } catch (RevWalkException e) {
+      log.warn("Error in rev walk", e);
+      res.setStatus(SC_INTERNAL_SERVER_ERROR);
+      return;
+    } finally {
+      if (walk != null) {
+        walk.release();
+      }
+    }
+  }
+
+  private static GitilesView.Builder copyAndCanonicalize(GitilesView view) {
+    // Canonicalize the view by using full SHAs.
+    GitilesView.Builder copy = GitilesView.log().copyFrom(view)
+        .setRevision(view.getRevision());
+    if (view.getOldRevision() != Revision.NULL) {
+      copy.setOldRevision(view.getOldRevision());
+    }
+    return copy;
+  }
+
+  private static Optional<ObjectId> getStart(ListMultimap<String, String> params,
+      ObjectReader reader) throws IOException {
+    List<String> values = params.get(START_PARAM);
+    switch (values.size()) {
+      case 0:
+        return Optional.absent();
+      case 1:
+        Collection<ObjectId> ids = reader.resolve(AbbreviatedObjectId.fromString(values.get(0)));
+        if (ids.size() != 1) {
+          return null;
+        }
+        return Optional.of(Iterables.getOnlyElement(ids));
+      default:
+        return null;
+    }
+  }
+
+  private static RevWalk newWalk(Repository repo, GitilesView view)
+      throws MissingObjectException, IncorrectObjectTypeException, IOException {
+    RevWalk walk = new RevWalk(repo);
+    walk.markStart(walk.parseCommit(view.getRevision().getId()));
+    if (view.getOldRevision() != Revision.NULL) {
+      walk.markUninteresting(walk.parseCommit(view.getOldRevision().getId()));
+    }
+    if (!Strings.isNullOrEmpty(view.getTreePath())) {
+      walk.setTreeFilter(FollowFilter.create(view.getTreePath()));
+    }
+    return walk;
+  }
+}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/Paginator.java b/gitiles-servlet/src/main/java/com/google/gitiles/Paginator.java
new file mode 100644
index 0000000..0454556
--- /dev/null
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/Paginator.java
@@ -0,0 +1,176 @@
+// Copyright 2012 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.checkArgument;
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.common.base.Preconditions.checkState;
+
+import org.eclipse.jgit.errors.IncorrectObjectTypeException;
+import org.eclipse.jgit.errors.MissingObjectException;
+import org.eclipse.jgit.errors.RevWalkException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+import java.io.IOException;
+import java.util.ArrayDeque;
+import java.util.Deque;
+import java.util.Iterator;
+
+import javax.annotation.Nullable;
+
+/**
+ * Wrapper around {@link RevWalk} that paginates for Gitiles.
+ *
+ * A single page of a shortlog is defined by a revision range, such as "master"
+ * or "master..next", a page size, and a start commit, such as "c0ffee". The
+ * distance between the first commit in the walk ("next") and the first commit
+ * in the page may be arbitrarily long, but in order to present the commit list
+ * in a stable way, we must always start from the first commit in the walk. This
+ * is because there may be arbitrary merge commits between "c0ffee" and "next"
+ * that effectively insert arbitrary commits into the history starting from
+ * "c0ffee".
+ */
+class Paginator implements Iterable<RevCommit> {
+  private final RevWalk walk;
+  private final ObjectId start;
+  private final int limit;
+  private final Deque<ObjectId> prevBuffer;
+
+  private boolean done;
+  private int i;
+  private int n;
+  private int foundIndex;
+  private ObjectId nextStart;
+
+  /**
+   * @param walk revision walk.
+   * @param limit page size.
+   * @param start commit at which to start the walk, or null to start at the
+   *     beginning.
+   */
+  Paginator(RevWalk walk, int limit, @Nullable ObjectId start) {
+    this.walk = checkNotNull(walk, "walk");
+    this.start = start;
+    checkArgument(limit > 0, "limit must be positive: %s", limit);
+    this.limit = limit;
+    prevBuffer = new ArrayDeque<ObjectId>(start != null ? limit : 0);
+    i = -1;
+    foundIndex = -1;
+  }
+
+  /**
+   * Get the next element in this page of the walk.
+   *
+   * @return the next element, or null if the walk is finished.
+   *
+   * @throws MissingObjectException See {@link RevWalk#next()}.
+   * @throws IncorrectObjectTypeException See {@link RevWalk#next()}.
+   * @throws IOException See {@link RevWalk#next()}.
+   */
+  public RevCommit next() throws MissingObjectException, IncorrectObjectTypeException,
+      IOException {
+    RevCommit commit;
+    if (foundIndex < 0) {
+      while (true) {
+        commit = walk.next();
+        if (commit == null) {
+          done = true;
+          return null;
+        }
+        i++;
+        if (start == null || start.equals(commit)) {
+          foundIndex = i;
+          break;
+        }
+        if (prevBuffer.size() == limit) {
+          prevBuffer.remove();
+        }
+        prevBuffer.add(commit);
+      }
+    } else {
+      commit = walk.next();
+    }
+
+    if (++n == limit) {
+      done = true;
+    } else if (n == limit + 1 || commit == null) {
+      nextStart = commit;
+      done = true;
+      return null;
+    }
+    return commit;
+  }
+
+  /**
+   * @return the ID at the start of the page of results preceding this one, or
+   *     null if this is the first page.
+   */
+  public ObjectId getPreviousStart() {
+    checkState(done, "getPreviousStart() invalid before walk done");
+    return prevBuffer.pollFirst();
+  }
+
+  /**
+   * @return the ID at the start of the page of results after this one, or null
+   *     if this is the last page.
+   */
+  public ObjectId getNextStart() {
+    checkState(done, "getNextStart() invalid before walk done");
+    return nextStart;
+  }
+
+  /**
+   * @return an iterator over the commits in this walk.
+   * @throws RevWalkException if an error occurred, wrapping the checked
+   *     exception from {@link #next()}.
+   */
+  @Override
+  public Iterator<RevCommit> iterator() {
+    return new Iterator<RevCommit>() {
+      RevCommit next = nextUnchecked();
+
+      @Override
+      public boolean hasNext() {
+        return next != null;
+      }
+
+      @Override
+      public RevCommit next() {
+        RevCommit r = next;
+        next = nextUnchecked();
+        return r;
+      }
+
+      @Override
+      public void remove() {
+        throw new UnsupportedOperationException();
+      }
+    };
+  }
+
+  private RevCommit nextUnchecked() {
+    try {
+      return next();
+    } catch (MissingObjectException e) {
+      throw new RevWalkException(e);
+    } catch (IncorrectObjectTypeException e) {
+      throw new RevWalkException(e);
+    } catch (IOException e) {
+      throw new RevWalkException(e);
+    }
+  }
+}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/PathServlet.java b/gitiles-servlet/src/main/java/com/google/gitiles/PathServlet.java
new file mode 100644
index 0000000..d228202
--- /dev/null
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/PathServlet.java
@@ -0,0 +1,276 @@
+// Copyright 2012 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.TreeSoyData.resolveTargetUrl;
+import static javax.servlet.http.HttpServletResponse.SC_INTERNAL_SERVER_ERROR;
+import static javax.servlet.http.HttpServletResponse.SC_NOT_FOUND;
+import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
+import static org.eclipse.jgit.lib.Constants.OBJ_COMMIT;
+import static org.eclipse.jgit.lib.Constants.OBJ_TREE;
+
+import com.google.common.base.Joiner;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Maps;
+
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.errors.LargeObjectException;
+import org.eclipse.jgit.http.server.ServletUtils;
+import org.eclipse.jgit.lib.FileMode;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectLoader;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevObject;
+import org.eclipse.jgit.revwalk.RevTree;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.submodule.SubmoduleWalk;
+import org.eclipse.jgit.treewalk.TreeWalk;
+import org.eclipse.jgit.util.RawParseUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.util.Map;
+import java.util.regex.Pattern;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+/** Serves an HTML page with detailed information about a path within a tree. */
+// TODO(dborowitz): Handle non-UTF-8 names.
+public class PathServlet extends BaseServlet {
+  private static final Logger log = LoggerFactory.getLogger(PathServlet.class);
+
+  /**
+   * Submodule URLs where we know there is a web page if the user visits the
+   * repository URL verbatim in a web browser.
+   */
+  private static final Pattern VERBATIM_SUBMODULE_URL_PATTERN =
+      Pattern.compile("^(" + Joiner.on('|').join(
+          "https?://[^.]+.googlesource.com/.*",
+          "https?://[^.]+.googlecode.com/.*",
+          "https?://code.google.com/p/.*",
+          "https?://github.com/.*") + ")$", Pattern.CASE_INSENSITIVE);
+
+  static enum FileType {
+    TREE(FileMode.TREE),
+    SYMLINK(FileMode.SYMLINK),
+    REGULAR_FILE(FileMode.REGULAR_FILE),
+    EXECUTABLE_FILE(FileMode.EXECUTABLE_FILE),
+    GITLINK(FileMode.GITLINK);
+
+    private final FileMode mode;
+
+    private FileType(FileMode mode) {
+      this.mode = mode;
+    }
+
+    static FileType forEntry(TreeWalk tw) {
+      int mode = tw.getRawMode(0);
+      for (FileType type : values()) {
+        if (type.mode.equals(mode)) {
+          return type;
+        }
+      }
+      return null;
+    }
+  }
+
+  public PathServlet(Renderer renderer) {
+    super(renderer);
+  }
+
+  @Override
+  protected void doGet(HttpServletRequest req, HttpServletResponse res) throws IOException {
+    GitilesView view = ViewFilter.getView(req);
+    Repository repo = ServletUtils.getRepository(req);
+
+    RevWalk rw = new RevWalk(repo);
+    try {
+      RevObject obj = rw.peel(rw.parseAny(view.getRevision().getId()));
+      RevTree root;
+
+      switch (obj.getType()) {
+        case OBJ_COMMIT:
+          root = ((RevCommit) obj).getTree();
+          break;
+        case OBJ_TREE:
+          root = (RevTree) obj;
+          break;
+        default:
+          res.setStatus(SC_NOT_FOUND);
+          return;
+      }
+
+      TreeWalk tw;
+      FileType type;
+      String path = view.getTreePath();
+      if (path.isEmpty()) {
+        tw = new TreeWalk(rw.getObjectReader());
+        tw.addTree(root);
+        tw.setRecursive(false);
+        type = FileType.TREE;
+      } else {
+        tw = TreeWalk.forPath(rw.getObjectReader(), path, root);
+        if (tw == null) {
+          res.setStatus(SC_NOT_FOUND);
+          return;
+        }
+        type = FileType.forEntry(tw);
+        if (type == FileType.TREE) {
+          tw.enterSubtree();
+          tw.setRecursive(false);
+        }
+      }
+
+      switch (type) {
+        case TREE:
+          showTree(req, res, rw, tw, obj);
+          break;
+        case SYMLINK:
+          showSymlink(req, res, rw, tw);
+          break;
+        case REGULAR_FILE:
+        case EXECUTABLE_FILE:
+          showFile(req, res, rw, tw);
+          break;
+        case GITLINK:
+          showGitlink(req, res, rw, tw, root);
+          break;
+        default:
+          log.error("Bad file type: %s", type);
+          res.setStatus(SC_NOT_FOUND);
+          break;
+      }
+    } catch (LargeObjectException e) {
+      res.setStatus(SC_INTERNAL_SERVER_ERROR);
+    } finally {
+      rw.release();
+    }
+  }
+
+  private void showTree(HttpServletRequest req, HttpServletResponse res, RevWalk rw, TreeWalk tw,
+      ObjectId id) throws IOException {
+    GitilesView view = ViewFilter.getView(req);
+    // 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() : "/",
+        "type", FileType.TREE.toString(),
+        "data", new TreeSoyData(rw, view).toSoyData(id, tw)));
+  }
+
+  private void showFile(HttpServletRequest req, HttpServletResponse res, RevWalk rw, TreeWalk tw)
+      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(),
+        "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 {
+    GitilesView view = ViewFilter.getView(req);
+    ObjectId id = tw.getObjectId(0);
+    Map<String, Object> data = Maps.newHashMap();
+
+    ObjectLoader loader = rw.getObjectReader().open(id, OBJ_BLOB);
+    String target;
+    try {
+      target = RawParseUtils.decode(loader.getCachedBytes(TreeSoyData.MAX_SYMLINK_SIZE));
+    } catch (LargeObjectException.OutOfMemory e) {
+      throw e;
+    } catch (LargeObjectException e) {
+      data.put("sha", ObjectId.toString(id));
+      data.put("data", null);
+      data.put("size", Long.toString(loader.getSize()));
+      render(req, res, "gitiles.pathDetail", ImmutableMap.of(
+          "title", ViewFilter.getView(req).getTreePath(),
+          "type", FileType.REGULAR_FILE.toString(),
+          "data", data));
+      return;
+    }
+
+    String url = resolveTargetUrl(
+        GitilesView.path()
+            .copyFrom(view)
+            .setTreePath(dirname(view.getTreePath()))
+            .build(),
+        target);
+    data.put("title", view.getTreePath());
+    data.put("target", target);
+    if (url != null) {
+      data.put("targetUrl", url);
+    }
+
+    // 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(),
+        "type", FileType.SYMLINK.toString(),
+        "data", data));
+  }
+
+  private static String dirname(String path) {
+    while (path.charAt(path.length() - 1) == '/') {
+      path = path.substring(0, path.length() - 1);
+    }
+    int lastSlash = path.lastIndexOf('/');
+    if (lastSlash > 0) {
+      return path.substring(0, lastSlash - 1);
+    } else if (lastSlash == 0) {
+      return "/";
+    } else {
+      return ".";
+    }
+  }
+
+  private void showGitlink(HttpServletRequest req, HttpServletResponse res, RevWalk rw,
+      TreeWalk tw, RevTree root) throws IOException {
+    GitilesView view = ViewFilter.getView(req);
+    SubmoduleWalk sw = SubmoduleWalk.forPath(ServletUtils.getRepository(req), root,
+        view.getTreePath());
+
+    String remoteUrl;
+    try {
+      remoteUrl = sw.getRemoteUrl();
+    } catch (ConfigInvalidException e) {
+      throw new IOException(e);
+    } finally {
+      sw.release();
+    }
+
+    Map<String, Object> data = Maps.newHashMap();
+    data.put("sha", ObjectId.toString(tw.getObjectId(0)));
+    data.put("remoteUrl", remoteUrl);
+
+      // TODO(dborowitz): Guess when we can put commit SHAs in the URL.
+      String httpUrl = resolveHttpUrl(remoteUrl);
+      if (httpUrl != null) {
+        data.put("httpUrl", httpUrl);
+      }
+
+      // TODO(sop): Allow caching links by SHA-1 when no S cookie is sent.
+      render(req, res, "gitiles.pathDetail", ImmutableMap.of(
+          "title", view.getTreePath(),
+          "type", FileType.GITLINK.toString(),
+          "data", data));
+  }
+
+  private static String resolveHttpUrl(String remoteUrl) {
+    return VERBATIM_SUBMODULE_URL_PATTERN.matcher(remoteUrl).matches() ? remoteUrl : null;
+  }
+}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/Renderer.java b/gitiles-servlet/src/main/java/com/google/gitiles/Renderer.java
new file mode 100644
index 0000000..42ce635
--- /dev/null
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/Renderer.java
@@ -0,0 +1,107 @@
+// Copyright 2012 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 com.google.common.base.Charsets;
+import com.google.common.base.Function;
+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.template.soy.tofu.SoyTofu;
+
+import java.io.File;
+import java.io.IOException;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.util.List;
+import java.util.Map;
+
+import javax.servlet.http.HttpServletResponse;
+
+/** Renderer for Soy templates used by Gitiles. */
+public abstract class Renderer {
+  private static final List<String> SOY_FILENAMES = ImmutableList.of(
+      "Common.soy",
+      "DiffDetail.soy",
+      "HostIndex.soy",
+      "LogDetail.soy",
+      "ObjectDetail.soy",
+      "PathDetail.soy",
+      "RevisionDetail.soy",
+      "RepositoryIndex.soy");
+
+  public static final Map<String, String> STATIC_URL_GLOBALS = ImmutableMap.of(
+      "gitiles.CSS_URL", "gitiles.css",
+      "gitiles.PRETTIFY_CSS_URL", "prettify/prettify.css",
+      "gitiles.PRETTIFY_JS_URL", "prettify/prettify.js");
+
+  protected static final URL toFileURL(String filename) {
+    if (filename == null) {
+      return null;
+    }
+    try {
+      return new File(filename).toURI().toURL();
+    } catch (MalformedURLException e) {
+      throw new IllegalArgumentException(e);
+    }
+  }
+
+  protected ImmutableList<URL> templates;
+  protected ImmutableMap<String, String> globals;
+
+  protected Renderer(Function<String, URL> resourceMapper, Map<String, String> globals,
+      String staticPrefix, URL customTemplates) {
+    checkNotNull(staticPrefix, "staticPrefix");
+    List<URL> allTemplates = Lists.newArrayListWithCapacity(SOY_FILENAMES.size() + 1);
+    for (String filename : SOY_FILENAMES) {
+      allTemplates.add(resourceMapper.apply(filename));
+    }
+    if (customTemplates != null) {
+      allTemplates.add(customTemplates);
+    } else {
+      allTemplates.add(resourceMapper.apply("DefaultCustomTemplates.soy"));
+    }
+    templates = ImmutableList.copyOf(allTemplates);
+
+    Map<String, String> allGlobals = Maps.newHashMap();
+    for (Map.Entry<String, String> e : STATIC_URL_GLOBALS.entrySet()) {
+      allGlobals.put(e.getKey(), staticPrefix + e.getValue());
+    }
+    allGlobals.putAll(globals);
+    this.globals = ImmutableMap.copyOf(allGlobals);
+  }
+
+  public void render(HttpServletResponse res, String templateName) throws IOException {
+    render(res, templateName, ImmutableMap.<String, Object> of());
+  }
+
+  public void render(HttpServletResponse res, String templateName, Map<String, ?> soyData)
+      throws IOException {
+    res.setContentType("text/html");
+    res.setCharacterEncoding("UTF-8");
+    byte[] data = newRenderer(templateName).setData(soyData).render().getBytes(Charsets.UTF_8);
+    res.setContentLength(data.length);
+    res.getOutputStream().write(data);
+  }
+
+  SoyTofu.Renderer newRenderer(String templateName) {
+    return getTofu().newRenderer(templateName);
+  }
+
+  protected abstract SoyTofu getTofu();
+}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/RepositoryDescription.java b/gitiles-servlet/src/main/java/com/google/gitiles/RepositoryDescription.java
new file mode 100644
index 0000000..06ee4da
--- /dev/null
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/RepositoryDescription.java
@@ -0,0 +1,25 @@
+// Copyright 2012 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 java.util.Map;
+
+/** Describes a repository in the {@link HostIndexServlet} JSON output. */
+public class RepositoryDescription {
+  public String name;
+  public String cloneUrl;
+  public String description;
+  public Map<String, String> branches;
+}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/RepositoryIndexServlet.java b/gitiles-servlet/src/main/java/com/google/gitiles/RepositoryIndexServlet.java
new file mode 100644
index 0000000..325068a
--- /dev/null
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/RepositoryIndexServlet.java
@@ -0,0 +1,80 @@
+// Copyright 2012 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 com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Lists;
+
+import org.eclipse.jgit.http.server.ServletUtils;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.RefComparator;
+import org.eclipse.jgit.lib.RefDatabase;
+
+import java.io.IOException;
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+/** Serves the index page for a repository, if accessed directly by a browser. */
+public class RepositoryIndexServlet extends BaseServlet {
+  private final GitilesAccess.Factory accessFactory;
+
+  public RepositoryIndexServlet(Renderer renderer, GitilesAccess.Factory accessFactory) {
+    super(renderer);
+    this.accessFactory = checkNotNull(accessFactory, "accessFactory");
+  }
+
+  @Override
+  protected void doGet(HttpServletRequest req, HttpServletResponse res) throws IOException {
+    render(req, res, "gitiles.repositoryIndex", buildData(req));
+  }
+
+  @VisibleForTesting
+  Map<String, ?> buildData(HttpServletRequest req) throws IOException {
+    RepositoryDescription desc = accessFactory.forRequest(req).getRepositoryDescription();
+    return ImmutableMap.of(
+        "cloneUrl", desc.cloneUrl,
+        "description", Strings.nullToEmpty(desc.description),
+        "branches", getRefs(req, Constants.R_HEADS),
+        "tags", getRefs(req, Constants.R_TAGS));
+  }
+
+  private List<Map<String, String>> getRefs(HttpServletRequest req, String prefix)
+      throws IOException {
+    RefDatabase refdb = ServletUtils.getRepository(req).getRefDatabase();
+    String repoName = ViewFilter.getView(req).getRepositoryName();
+    Collection<Ref> refs = RefComparator.sort(refdb.getRefs(prefix).values());
+    List<Map<String, String>> result = Lists.newArrayListWithCapacity(refs.size());
+
+    for (Ref ref : refs) {
+      String name = ref.getName().substring(prefix.length());
+      boolean needPrefix = !ref.getName().equals(refdb.getRef(name).getName());
+      result.add(ImmutableMap.of(
+          "url", GitilesView.log().copyFrom(req).setRevision(
+              Revision.unpeeled(needPrefix ? ref.getName() : name, ref.getObjectId())).toUrl(),
+          "name", name));
+    }
+
+    return result;
+  }
+}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/Revision.java b/gitiles-servlet/src/main/java/com/google/gitiles/Revision.java
new file mode 100644
index 0000000..05778e0
--- /dev/null
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/Revision.java
@@ -0,0 +1,138 @@
+// Copyright 2012 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.checkArgument;
+import static org.eclipse.jgit.lib.Constants.OBJ_BAD;
+import static org.eclipse.jgit.lib.Constants.OBJ_TAG;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Objects;
+
+import org.eclipse.jgit.errors.MissingObjectException;
+import org.eclipse.jgit.lib.AbbreviatedObjectId;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.revwalk.RevObject;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+import java.io.IOException;
+
+/**
+ * Object encapsulating a single revision as seen by Gitiles.
+ * <p>
+ * A single revision consists of a name, an ID, and a type. Name parsing is done
+ * once per request by {@link RevisionParser}.
+ */
+public class Revision {
+  /** Sentinel indicating a missing or empty revision. */
+  public static final Revision NULL = peeled("", ObjectId.zeroId(), OBJ_BAD);
+
+  /** Common default branch given to clients. */
+  public static final Revision HEAD = named("HEAD");
+
+  private final String name;
+  private final ObjectId id;
+  private final int type;
+  private final ObjectId peeledId;
+  private final int peeledType;
+
+  public static Revision peeled(String name, RevObject obj) {
+    return peeled(name, obj, obj.getType());
+  }
+
+  public static Revision unpeeled(String name, ObjectId id) {
+    return peeled(name, id, OBJ_BAD);
+  }
+
+  public static Revision named(String name) {
+    return peeled(name, null, OBJ_BAD);
+  }
+
+  public static Revision peel(String name, ObjectId id, RevWalk walk)
+      throws MissingObjectException, IOException {
+    RevObject obj = walk.parseAny(id);
+    RevObject peeled = walk.peel(obj);
+    return new Revision(name, obj, obj.getType(), peeled, peeled.getType());
+  }
+
+  private static Revision peeled(String name, ObjectId id, int type) {
+    checkArgument(type != OBJ_TAG, "expected non-tag for %s/%s", name, id);
+    return new Revision(name, id, type, id, type);
+  }
+
+  @VisibleForTesting
+  Revision(String name, ObjectId id, int type, ObjectId peeledId, int peeledType) {
+    this.name = name;
+    this.id = id;
+    this.type = type;
+    this.peeledId = peeledId;
+    this.peeledType = peeledType;
+  }
+
+  public String getName() {
+    return name;
+  }
+
+  public int getType() {
+    return type;
+  }
+
+  public ObjectId getId() {
+    return id;
+  }
+
+  public ObjectId getPeeledId() {
+    return peeledId;
+  }
+
+  public int getPeeledType() {
+    return peeledType;
+  }
+
+  public boolean nameIsId() {
+    return AbbreviatedObjectId.isId(name)
+        && (AbbreviatedObjectId.fromString(name).prefixCompare(id) == 0);
+  }
+
+  @Override
+  public boolean equals(Object o) {
+    if (o instanceof Revision) {
+      Revision r = (Revision) o;
+      return Objects.equal(name, r.name)
+          && Objects.equal(id, r.id)
+          && Objects.equal(type, r.type)
+          && Objects.equal(peeledId, r.peeledId)
+          && Objects.equal(peeledType, r.peeledType);
+    }
+    return false;
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hashCode(name, id, type, peeledId, peeledType);
+  }
+
+  @Override
+  public String toString() {
+    return Objects.toStringHelper(this)
+        .omitNullValues()
+        .add("name", name)
+        .add("id", id)
+        .add("type", type)
+        .add("peeledId", peeledId)
+        .add("peeledType", peeledType)
+        .toString();
+  }
+}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/RevisionParser.java b/gitiles-servlet/src/main/java/com/google/gitiles/RevisionParser.java
new file mode 100644
index 0000000..3e0acfa
--- /dev/null
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/RevisionParser.java
@@ -0,0 +1,214 @@
+// Copyright 2012 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 com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.CharMatcher;
+import com.google.common.base.Objects;
+import com.google.common.base.Splitter;
+
+import org.eclipse.jgit.errors.IncorrectObjectTypeException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+import java.io.IOException;
+
+/** Object to parse revisions out of Gitiles paths. */
+class RevisionParser {
+  static final Splitter PATH_SPLITTER = Splitter.on('/');
+  private static final Splitter OPERATOR_SPLITTER = Splitter.on(CharMatcher.anyOf("^~"));
+
+  static class Result {
+    private final Revision revision;
+    private final Revision oldRevision;
+    private final int pathStart;
+
+    @VisibleForTesting
+    Result(Revision revision) {
+      this(revision, null, revision.getName().length());
+    }
+
+    @VisibleForTesting
+    Result(Revision revision, Revision oldRevision, int pathStart) {
+      this.revision = revision;
+      this.oldRevision = oldRevision;
+      this.pathStart = pathStart;
+    }
+
+    public Revision getRevision() {
+      return revision;
+    }
+
+    public Revision getOldRevision() {
+      return oldRevision;
+    }
+
+    @Override
+    public boolean equals(Object o) {
+      if (o instanceof Result) {
+        Result r = (Result) o;
+        return Objects.equal(revision, r.revision)
+            && Objects.equal(oldRevision, r.oldRevision)
+            && Objects.equal(pathStart, r.pathStart);
+      }
+      return false;
+    }
+
+    @Override
+    public int hashCode() {
+      return Objects.hashCode(revision, oldRevision, pathStart);
+    }
+
+    @Override
+    public String toString() {
+      return Objects.toStringHelper(this)
+          .omitNullValues()
+          .add("revision", revision)
+          .add("oldRevision", oldRevision)
+          .add("pathStart", pathStart)
+          .toString();
+    }
+
+    int getPathStart() {
+      return pathStart;
+    }
+  }
+
+  private final Repository repo;
+  private final GitilesAccess access;
+  private final VisibilityCache cache;
+
+  RevisionParser(Repository repo, GitilesAccess access, VisibilityCache cache) {
+    this.repo = checkNotNull(repo, "repo");
+    this.access = checkNotNull(access, "access");
+    this.cache = checkNotNull(cache, "cache");
+  }
+
+  Result parse(String path) throws IOException {
+    RevWalk walk = new RevWalk(repo);
+    try {
+      Revision oldRevision = null;
+
+      StringBuilder b = new StringBuilder();
+      boolean first = true;
+      for (String part : PATH_SPLITTER.split(path)) {
+        if (part.isEmpty()) {
+          return null; // No valid revision contains empty segments.
+        }
+        if (!first) {
+          b.append('/');
+        }
+
+        if (oldRevision == null) {
+          int dots = part.indexOf("..");
+          int firstParent = part.indexOf("^!");
+          if (dots == 0 || firstParent == 0) {
+            return null;
+          } else if (dots > 0) {
+            b.append(part.substring(0, dots));
+            String oldName = b.toString();
+            if (!isValidRevision(oldName)) {
+              return null;
+            } else {
+              ObjectId old = repo.resolve(oldName);
+              if (old == null) {
+                return null;
+              }
+              oldRevision = Revision.peel(oldName, old, walk);
+            }
+            part = part.substring(dots + 2);
+            b = new StringBuilder();
+          } else if (firstParent > 0) {
+            if (firstParent != part.length() - 2) {
+              return null;
+            }
+            b.append(part.substring(0, part.length() - 2));
+            String name = b.toString();
+            if (!isValidRevision(name)) {
+              return null;
+            }
+            ObjectId id = repo.resolve(name);
+            if (id == null) {
+              return null;
+            }
+            RevCommit c;
+            try {
+              c = walk.parseCommit(id);
+            } catch (IncorrectObjectTypeException e) {
+              return null; // Not a commit, ^! is invalid.
+            }
+            if (c.getParentCount() > 0) {
+              oldRevision = Revision.peeled(name + "^", c.getParent(0));
+            } else {
+              oldRevision = Revision.NULL;
+            }
+            Result result = new Result(Revision.peeled(name, c), oldRevision, name.length() + 2);
+            return isVisible(walk, result) ? result : null;
+          }
+        }
+        b.append(part);
+
+        String name = b.toString();
+        if (!isValidRevision(name)) {
+          return null;
+        }
+        ObjectId id = repo.resolve(name);
+        if (id != null) {
+          int pathStart;
+          if (oldRevision == null) {
+            pathStart = name.length(); // foo
+          } else {
+            // foo..bar (foo may be empty)
+            pathStart = oldRevision.getName().length() + 2 + name.length();
+          }
+          Result result = new Result(Revision.peel(name, id, walk), oldRevision, pathStart);
+          return isVisible(walk, result) ? result : null;
+        }
+        first = false;
+      }
+      return null;
+    } finally {
+      walk.release();
+    }
+  }
+
+  private static boolean isValidRevision(String revision) {
+    // Disallow some uncommon but valid revision expressions that either we
+    // don't support or we represent differently in our URLs.
+    return revision.indexOf(':') < 0
+        && revision.indexOf("^{") < 0
+        && revision.indexOf('@') < 0;
+  }
+
+  private boolean isVisible(RevWalk walk, Result result) throws IOException {
+    String maybeRef = OPERATOR_SPLITTER.split(result.getRevision().getName()).iterator().next();
+    if (repo.getRef(maybeRef) != null) {
+      // Name contains a visible ref; skip expensive reachability check.
+      return true;
+    }
+    if (!cache.isVisible(repo, walk, access, result.getRevision().getId())) {
+      return false;
+    }
+    if (result.getOldRevision() != null && result.getOldRevision() != Revision.NULL) {
+      return cache.isVisible(repo, walk, access, result.getOldRevision().getId());
+    } else {
+      return true;
+    }
+  }
+}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/RevisionServlet.java b/gitiles-servlet/src/main/java/com/google/gitiles/RevisionServlet.java
new file mode 100644
index 0000000..66a7086
--- /dev/null
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/RevisionServlet.java
@@ -0,0 +1,136 @@
+// Copyright 2012 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 javax.servlet.http.HttpServletResponse.SC_NOT_FOUND;
+import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
+import static org.eclipse.jgit.lib.Constants.OBJ_COMMIT;
+import static org.eclipse.jgit.lib.Constants.OBJ_TAG;
+import static org.eclipse.jgit.lib.Constants.OBJ_TREE;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Lists;
+import com.google.gitiles.CommitSoyData.KeySet;
+
+import org.eclipse.jgit.errors.IncorrectObjectTypeException;
+import org.eclipse.jgit.errors.MissingObjectException;
+import org.eclipse.jgit.http.server.ServletUtils;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevObject;
+import org.eclipse.jgit.revwalk.RevTag;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.Map;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+/** Serves an HTML page with detailed information about a ref. */
+public class RevisionServlet extends BaseServlet {
+  private static final Logger log = LoggerFactory.getLogger(RevisionServlet.class);
+
+  private final Linkifier linkifier;
+
+  public RevisionServlet(Renderer renderer, Linkifier linkifier) {
+    super(renderer);
+    this.linkifier = checkNotNull(linkifier, "linkifier");
+  }
+
+  @Override
+  protected void doGet(HttpServletRequest req, HttpServletResponse res) throws IOException {
+    GitilesView view = ViewFilter.getView(req);
+    Repository repo = ServletUtils.getRepository(req);
+
+    RevWalk walk = new RevWalk(repo);
+    try {
+      List<RevObject> objects = listObjects(walk, view.getRevision().getId());
+      List<Map<String, ?>> soyObjects = Lists.newArrayListWithCapacity(objects.size());
+      boolean hasBlob = false;
+
+      // TODO(sop): Allow caching commits by SHA-1 when no S cookie is sent.
+      for (RevObject obj : objects) {
+        try {
+          switch (obj.getType()) {
+            case OBJ_COMMIT:
+              soyObjects.add(ImmutableMap.of(
+                  "type", Constants.TYPE_COMMIT,
+                  "data", new CommitSoyData(linkifier, req, repo, walk, view)
+                      .toSoyData((RevCommit) obj, KeySet.DETAIL_DIFF_TREE)));
+              break;
+            case OBJ_TREE:
+              soyObjects.add(ImmutableMap.of(
+                  "type", Constants.TYPE_TREE,
+                  "data", new TreeSoyData(walk, view).toSoyData(obj)));
+              break;
+            case OBJ_BLOB:
+              soyObjects.add(ImmutableMap.of(
+                  "type", Constants.TYPE_BLOB,
+                  "data", new BlobSoyData(walk, view).toSoyData(obj)));
+              hasBlob = true;
+              break;
+            case OBJ_TAG:
+              soyObjects.add(ImmutableMap.of(
+                  "type", Constants.TYPE_TAG,
+                  "data", new TagSoyData(linkifier, req).toSoyData((RevTag) obj)));
+              break;
+            default:
+              log.warn("Bad object type for %s: %s", ObjectId.toString(obj.getId()), obj.getType());
+              res.setStatus(SC_NOT_FOUND);
+              return;
+          }
+        } catch (MissingObjectException e) {
+          log.warn("Missing object " + ObjectId.toString(obj.getId()), e);
+          res.setStatus(SC_NOT_FOUND);
+          return;
+        } catch (IncorrectObjectTypeException e) {
+          log.warn("Incorrect object type for " + ObjectId.toString(obj.getId()), e);
+          res.setStatus(SC_NOT_FOUND);
+          return;
+        }
+      }
+
+      render(req, res, "gitiles.revisionDetail", ImmutableMap.of(
+          "title", view.getRevision().getName(),
+          "objects", soyObjects,
+          "hasBlob", hasBlob));
+    } finally {
+      walk.release();
+    }
+  }
+
+  // TODO(dborowitz): Extract this.
+  static List<RevObject> listObjects(RevWalk walk, ObjectId id)
+      throws MissingObjectException, IOException {
+    List<RevObject> objects = Lists.newArrayListWithExpectedSize(1);
+    while (true) {
+      RevObject cur = walk.parseAny(id);
+      objects.add(cur);
+      if (cur.getType() == Constants.OBJ_TAG) {
+        id = ((RevTag) cur).getObject();
+      } else {
+        break;
+      }
+    }
+    return objects;
+  }
+}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/TagSoyData.java b/gitiles-servlet/src/main/java/com/google/gitiles/TagSoyData.java
new file mode 100644
index 0000000..9ddf1e5
--- /dev/null
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/TagSoyData.java
@@ -0,0 +1,50 @@
+// Copyright 2012 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 com.google.common.collect.Maps;
+
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.revwalk.RevTag;
+import org.eclipse.jgit.util.GitDateFormatter;
+import org.eclipse.jgit.util.GitDateFormatter.Format;
+
+import java.util.Map;
+
+import javax.servlet.http.HttpServletRequest;
+
+/** Soy data converter for git tags. */
+public class TagSoyData {
+  private final Linkifier linkifier;
+  private final HttpServletRequest req;
+  private final GitDateFormatter dateFormatter;
+
+  public TagSoyData(Linkifier linkifier, HttpServletRequest req) {
+    this.linkifier = linkifier;
+    this.req = req;
+    this.dateFormatter = new GitDateFormatter(Format.DEFAULT);
+  }
+
+  public Map<String, Object> toSoyData(RevTag tag) {
+    Map<String, Object> data = Maps.newHashMapWithExpectedSize(4);
+    data.put("sha", ObjectId.toString(tag));
+    if (tag.getTaggerIdent() != null) {
+      data.put("tagger", CommitSoyData.toSoyData(tag.getTaggerIdent(), dateFormatter));
+    }
+    data.put("object", ObjectId.toString(tag.getObject()));
+    data.put("message", linkifier.linkify(req, tag.getFullMessage()));
+    return data;
+  }
+}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/TreeSoyData.java b/gitiles-servlet/src/main/java/com/google/gitiles/TreeSoyData.java
new file mode 100644
index 0000000..6cbf541
--- /dev/null
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/TreeSoyData.java
@@ -0,0 +1,160 @@
+// Copyright 2012 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.RevisionParser.PATH_SPLITTER;
+import static org.eclipse.jgit.lib.Constants.OBJ_COMMIT;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Charsets;
+import com.google.common.collect.Lists;
+import com.google.common.collect.Maps;
+import com.google.common.io.Files;
+import com.google.gitiles.PathServlet.FileType;
+
+import org.eclipse.jgit.errors.MissingObjectException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.treewalk.TreeWalk;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.Map;
+import java.util.StringTokenizer;
+
+/** Soy data converter for git trees. */
+public class TreeSoyData {
+  /**
+   * Number of characters to display for a symlink target. Targets longer than
+   * this are abbreviated for display in a tree listing.
+   */
+  private static final int MAX_SYMLINK_TARGET_LENGTH = 72;
+
+  /**
+   * Maximum number of bytes to load from a blob that claims to be a symlink. If
+   * the blob is larger than this byte limit it will be displayed as a binary
+   * file instead of as a symlink.
+   */
+  static final int MAX_SYMLINK_SIZE = 16 << 10;
+
+  static String resolveTargetUrl(GitilesView view, String target) {
+    if (target.startsWith("/")) {
+      return null;
+    }
+
+    // simplifyPath() normalizes "a/../../" to "a", so manually check whether
+    // the path leads above the git root.
+    int depth = new StringTokenizer(view.getTreePath(), "/").countTokens();
+    for (String part : PATH_SPLITTER.split(target)) {
+      if (part.equals("..")) {
+        depth--;
+        if (depth < 0) {
+          return null;
+        }
+      } else if (!part.isEmpty() && !part.equals(".")) {
+        depth++;
+      }
+    }
+
+    String path = Files.simplifyPath(view.getTreePath() + "/" + target);
+    return GitilesView.path()
+        .copyFrom(view)
+        .setTreePath(!path.equals(".") ? path : "")
+        .toUrl();
+  }
+
+  @VisibleForTesting
+  static String getTargetDisplayName(String target) {
+    if (target.length() <= MAX_SYMLINK_TARGET_LENGTH) {
+      return target;
+    } else {
+      int lastSlash = target.lastIndexOf('/');
+      // TODO(dborowitz): Doesn't abbreviate a long last path component.
+      return lastSlash >= 0 ? "..." + target.substring(lastSlash) : target;
+    }
+  }
+
+  private final RevWalk rw;
+  private final GitilesView view;
+
+  public TreeSoyData(RevWalk rw, GitilesView view) {
+    this.rw = rw;
+    this.view = view;
+  }
+
+  public Map<String, Object> toSoyData(ObjectId treeId, TreeWalk tw) throws MissingObjectException,
+         IOException {
+    List<Object> entries = Lists.newArrayList();
+    GitilesView.Builder urlBuilder = GitilesView.path().copyFrom(view);
+    while (tw.next()) {
+      FileType type = FileType.forEntry(tw);
+      String name = tw.getNameString();
+
+      switch (view.getType()) {
+        case PATH:
+          urlBuilder.setTreePath(view.getTreePath() + "/" + name);
+          break;
+        case REVISION:
+          // Got here from a tag pointing at a tree.
+          urlBuilder.setTreePath(name);
+          break;
+        default:
+          throw new IllegalStateException(String.format(
+              "Cannot render TreeSoyData from %s view", view.getType()));
+      }
+
+      String url = urlBuilder.toUrl();
+      if (type == FileType.TREE) {
+        name += "/";
+        url += "/";
+      }
+      Map<String, String> entry = Maps.newHashMapWithExpectedSize(4);
+      entry.put("type", type.toString());
+      entry.put("name", name);
+      entry.put("url", url);
+      if (type == FileType.SYMLINK) {
+        String target = new String(
+            rw.getObjectReader().open(tw.getObjectId(0)).getCachedBytes(),
+            Charsets.UTF_8);
+        // TODO(dborowitz): Merge Shawn's changes before copying these methods
+        // in.
+        entry.put("targetName", getTargetDisplayName(target));
+        String targetUrl = resolveTargetUrl(view, target);
+        if (targetUrl != null) {
+          entry.put("targetUrl", targetUrl);
+        }
+      }
+      entries.add(entry);
+    }
+
+    Map<String, Object> data = Maps.newHashMapWithExpectedSize(3);
+    data.put("sha", treeId.name());
+    data.put("entries", entries);
+
+    if (view.getType() == GitilesView.Type.PATH
+        && view.getRevision().getPeeledType() == OBJ_COMMIT) {
+      data.put("logUrl", GitilesView.log().copyFrom(view).toUrl());
+    }
+
+    return data;
+  }
+
+  public Map<String, Object> toSoyData(ObjectId treeId) throws MissingObjectException, IOException {
+    TreeWalk tw = new TreeWalk(rw.getObjectReader());
+    tw.addTree(treeId);
+    tw.setRecursive(false);
+    return toSoyData(treeId, tw);
+  }
+}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/ViewFilter.java b/gitiles-servlet/src/main/java/com/google/gitiles/ViewFilter.java
new file mode 100644
index 0000000..8874b1d
--- /dev/null
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/ViewFilter.java
@@ -0,0 +1,160 @@
+// Copyright 2012 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.checkArgument;
+import static com.google.common.base.Preconditions.checkNotNull;
+import static javax.servlet.http.HttpServletResponse.SC_NOT_FOUND;
+
+import org.eclipse.jgit.http.server.ServletUtils;
+import org.eclipse.jgit.http.server.glue.WrappedRequest;
+import org.eclipse.jgit.lib.Constants;
+
+import java.io.IOException;
+import java.util.Map;
+
+import javax.servlet.FilterChain;
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+/** Filter to parse URLs and convert them to {@link GitilesView}s. */
+public class ViewFilter extends AbstractHttpFilter {
+  // TODO(dborowitz): Make this public in JGit (or implement getRegexGroup
+  // upstream).
+  private static final String REGEX_GROUPS_ATTRIBUTE =
+      "org.eclipse.jgit.http.server.glue.MetaServlet.serveRegex";
+
+  private static final String VIEW_ATTIRBUTE = ViewFilter.class.getName() + "/View";
+
+  private static final String CMD_AUTO = "+";
+  private static final String CMD_DIFF = "+diff";
+  private static final String CMD_LOG = "+log";
+  private static final String CMD_SHOW = "+show";
+
+  public static GitilesView getView(HttpServletRequest req) {
+    return (GitilesView) req.getAttribute(VIEW_ATTIRBUTE);
+  }
+
+  static String getRegexGroup(HttpServletRequest req, int groupId) {
+    WrappedRequest[] groups = (WrappedRequest[]) req.getAttribute(REGEX_GROUPS_ATTRIBUTE);
+    return checkNotNull(groups)[groupId].getPathInfo();
+  }
+
+  static void setView(HttpServletRequest req, GitilesView view) {
+    req.setAttribute(VIEW_ATTIRBUTE, view);
+  }
+
+  static String trimLeadingSlash(String str) {
+    checkArgument(str.startsWith("/"), "expected string starting with a slash: %s", str);
+    return str.substring(1);
+  }
+
+  private final GitilesUrls urls;
+  private final GitilesAccess.Factory accessFactory;
+  private final VisibilityCache visibilityCache;
+
+  public ViewFilter(GitilesAccess.Factory accessFactory, GitilesUrls urls,
+      VisibilityCache visibilityCache) {
+    this.urls = checkNotNull(urls, "urls");
+    this.accessFactory = checkNotNull(accessFactory, "accessFactory");
+    this.visibilityCache = checkNotNull(visibilityCache, "visibilityCache");
+  }
+
+  @Override
+  public void doFilter(HttpServletRequest req, HttpServletResponse res, FilterChain chain)
+      throws IOException, ServletException {
+    GitilesView.Builder view = parse(req);
+    if (view == null) {
+      res.setStatus(SC_NOT_FOUND);
+      return;
+    }
+    @SuppressWarnings("unchecked")
+    Map<String, String[]> params = req.getParameterMap();
+    view.setHostName(urls.getHostName(req))
+        .setServletPath(req.getContextPath() + req.getServletPath())
+        .putAllParams(params);
+    setView(req, view.build());
+    try {
+      chain.doFilter(req, res);
+    } finally {
+      req.removeAttribute(VIEW_ATTIRBUTE);
+    }
+  }
+
+  private GitilesView.Builder parse(HttpServletRequest req) throws IOException {
+    String repoName = trimLeadingSlash(getRegexGroup(req, 1));
+    String command = getRegexGroup(req, 2);
+    String path = getRegexGroup(req, 3);
+
+    // Non-path cases.
+    if (repoName.isEmpty()) {
+      return GitilesView.hostIndex();
+    } else if (command.isEmpty()) {
+      return GitilesView.repositoryIndex().setRepositoryName(repoName);
+    } else if (path.isEmpty()) {
+      return null; // Command but no path.
+    }
+
+    path = trimLeadingSlash(path);
+    RevisionParser revParser = new RevisionParser(
+        ServletUtils.getRepository(req), accessFactory.forRequest(req), visibilityCache);
+    RevisionParser.Result result = revParser.parse(path);
+    if (result == null) {
+      return null;
+    }
+    path = path.substring(result.getPathStart());
+
+    command = getCommand(command, result, path);
+    GitilesView.Builder view;
+    if (CMD_LOG.equals(command)) {
+      view = GitilesView.log().setTreePath(path);
+    } else if (CMD_SHOW.equals(command)) {
+      if (path.isEmpty()) {
+        view = GitilesView.revision();
+      } else {
+        view = GitilesView.path().setTreePath(path);
+      }
+    } else if (CMD_DIFF.equals(command)) {
+      view = GitilesView.diff().setTreePath(path);
+    } else {
+      return null; // Bad command.
+    }
+    if (result.getOldRevision() != null) { // May be NULL.
+      view.setOldRevision(result.getOldRevision());
+    }
+    view.setRepositoryName(repoName)
+        .setRevision(result.getRevision());
+    return view;
+  }
+
+  private String getCommand(String command, RevisionParser.Result result, String path) {
+    // Note: if you change the mapping for +, make sure to change
+    // GitilesView.toUrl() correspondingly.
+    if (!CMD_AUTO.equals(command)) {
+      return command;
+    } else if (result.getOldRevision() != null) {
+      return CMD_DIFF;
+    }
+    Revision rev = result.getRevision();
+    if (rev.getPeeledType() != Constants.OBJ_COMMIT
+        || !path.isEmpty()
+        || result.getRevision().nameIsId()) {
+      return CMD_SHOW;
+    } else {
+      return CMD_LOG;
+    }
+  }
+}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/VisibilityCache.java b/gitiles-servlet/src/main/java/com/google/gitiles/VisibilityCache.java
new file mode 100644
index 0000000..22969d1
--- /dev/null
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/VisibilityCache.java
@@ -0,0 +1,189 @@
+// Copyright 2012 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.common.base.Predicates.not;
+import static com.google.common.collect.Collections2.filter;
+
+import com.google.common.base.Objects;
+import com.google.common.base.Predicate;
+import com.google.common.base.Throwables;
+import com.google.common.cache.Cache;
+import com.google.common.cache.CacheBuilder;
+
+import org.eclipse.jgit.errors.IncorrectObjectTypeException;
+import org.eclipse.jgit.errors.MissingObjectException;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.RefDatabase;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevSort;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+import java.io.IOException;
+import java.util.Collection;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+
+/** Cache of per-user object visibility. */
+public class VisibilityCache {
+  private static class Key {
+    private final Object user;
+    private final String repositoryName;
+    private final ObjectId objectId;
+
+    private Key(Object user, String repositoryName, ObjectId objectId) {
+      this.user = checkNotNull(user, "user");
+      this.repositoryName = checkNotNull(repositoryName, "repositoryName");
+      this.objectId = checkNotNull(objectId, "objectId");
+    }
+
+    @Override
+    public boolean equals(Object o) {
+      if (o instanceof Key) {
+        Key k = (Key) o;
+        return Objects.equal(user, k.user)
+            && Objects.equal(repositoryName, k.repositoryName)
+            && Objects.equal(objectId, k.objectId);
+      }
+      return false;
+    }
+
+    @Override
+    public int hashCode() {
+      return Objects.hashCode(user, repositoryName, objectId);
+    }
+
+    @Override
+    public String toString() {
+      return Objects.toStringHelper(this)
+        .add("user", user)
+        .add("repositoryName", repositoryName)
+        .add("objectId", objectId)
+        .toString();
+    }
+  }
+
+  private final Cache<Key, Boolean> cache;
+  private final boolean topoSort;
+
+  public static CacheBuilder<Object, Object> newBuilder() {
+    return CacheBuilder.newBuilder()
+        .maximumSize(1 << 10)
+        .expireAfterWrite(30, TimeUnit.MINUTES);
+  }
+
+  public VisibilityCache(boolean topoSort) {
+    this(topoSort, newBuilder());
+  }
+
+  public VisibilityCache(boolean topoSort, CacheBuilder<Object, Object> builder) {
+    this.cache = builder.build();
+    this.topoSort = topoSort;
+  }
+
+  public Cache<?, Boolean> getCache() {
+    return cache;
+  }
+
+  boolean isVisible(final Repository repo, final RevWalk walk, GitilesAccess access,
+      final ObjectId id) throws IOException {
+    try {
+      return cache.get(
+          new Key(access.getUserKey(), access.getRepositoryName(), id),
+          new Callable<Boolean>() {
+            @Override
+            public Boolean call() throws IOException {
+              return isVisible(repo, walk, id);
+            }
+          });
+    } catch (ExecutionException e) {
+      Throwables.propagateIfInstanceOf(e.getCause(), IOException.class);
+      throw new IOException(e);
+    }
+  }
+
+  private boolean isVisible(Repository repo, RevWalk walk, ObjectId id) throws IOException {
+    RevCommit commit;
+    try {
+      commit = walk.parseCommit(id);
+    } catch (IncorrectObjectTypeException e) {
+      return false;
+    }
+
+    // If any reference directly points at the requested object, permit display.
+    // Common for displays of pending patch sets in Gerrit Code Review, or
+    // bookmarks to the commit a tag points at.
+    Collection<Ref> allRefs = repo.getRefDatabase().getRefs(RefDatabase.ALL).values();
+    for (Ref ref : allRefs) {
+      ref = repo.getRefDatabase().peel(ref);
+      if (id.equals(ref.getObjectId()) || id.equals(ref.getPeeledObjectId())) {
+        return true;
+      }
+    }
+
+    // Check heads first under the assumption that most requests are for refs
+    // close to a head. Tags tend to be much further back in history and just
+    // clutter up the priority queue in the common case.
+    return isReachableFrom(walk, commit, filter(allRefs, refStartsWith(Constants.R_HEADS)))
+        || isReachableFrom(walk, commit, filter(allRefs, refStartsWith(Constants.R_TAGS)))
+        || isReachableFrom(walk, commit, filter(allRefs, not(refStartsWith("refs/changes/"))));
+  }
+
+  private static Predicate<Ref> refStartsWith(final String prefix) {
+    return new Predicate<Ref>() {
+      @Override
+      public boolean apply(Ref ref) {
+        return ref.getName().startsWith(prefix);
+      }
+    };
+  }
+
+  private boolean isReachableFrom(RevWalk walk, RevCommit commit, Collection<Ref> refs)
+      throws IOException {
+    walk.reset();
+    if (topoSort) {
+      walk.sort(RevSort.TOPO);
+    }
+    walk.markStart(commit);
+    for (Ref ref : refs) {
+      if (ref.getPeeledObjectId() != null) {
+        markUninteresting(walk, ref.getPeeledObjectId());
+      } else {
+        markUninteresting(walk, ref.getObjectId());
+      }
+    }
+    // If the commit is reachable from any branch head, it will appear to be
+    // uninteresting to the RevWalk and no output will be produced.
+    return walk.next() == null;
+  }
+
+  private static void markUninteresting(RevWalk walk, ObjectId id) throws IOException {
+    if (id == null) {
+      return;
+    }
+    try {
+      walk.markUninteresting(walk.parseCommit(id));
+    } catch (IncorrectObjectTypeException e) {
+      // Do nothing, doesn't affect reachability.
+    } catch (MissingObjectException e) {
+      // Do nothing, doesn't affect reachability.
+    }
+  }
+}