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/.gitignore b/.gitignore
new file mode 100644
index 0000000..ebbbd5f
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,3 @@
+/.classpath
+/.project
+/.settings
diff --git a/.settings/org.eclipse.core.resources.prefs b/.settings/org.eclipse.core.resources.prefs
new file mode 100644
index 0000000..5b781ec
--- /dev/null
+++ b/.settings/org.eclipse.core.resources.prefs
@@ -0,0 +1,3 @@
+eclipse.preferences.version=1
+encoding//src/main/java=UTF-8
+encoding//src/test/java=UTF-8
diff --git a/.settings/org.eclipse.jdt.core.prefs b/.settings/org.eclipse.jdt.core.prefs
new file mode 100644
index 0000000..60105c1
--- /dev/null
+++ b/.settings/org.eclipse.jdt.core.prefs
@@ -0,0 +1,5 @@
+eclipse.preferences.version=1
+org.eclipse.jdt.core.compiler.codegen.targetPlatform=1.6
+org.eclipse.jdt.core.compiler.compliance=1.6
+org.eclipse.jdt.core.compiler.problem.forbiddenReference=warning
+org.eclipse.jdt.core.compiler.source=1.6
diff --git a/.settings/org.eclipse.jdt.ui.prefs b/.settings/org.eclipse.jdt.ui.prefs
new file mode 100644
index 0000000..fa9df30
--- /dev/null
+++ b/.settings/org.eclipse.jdt.ui.prefs
@@ -0,0 +1,5 @@
+eclipse.preferences.version=1
+org.eclipse.jdt.ui.ignorelowercasenames=true
+org.eclipse.jdt.ui.importorder=com.google;com;junit;net;org;java;javax;
+org.eclipse.jdt.ui.ondemandthreshold=999
+org.eclipse.jdt.ui.staticondemandthreshold=999
diff --git a/gitiles-servlet/pom.xml b/gitiles-servlet/pom.xml
new file mode 100644
index 0000000..5537693
--- /dev/null
+++ b/gitiles-servlet/pom.xml
@@ -0,0 +1,115 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+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.
+-->
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+  xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
+  <modelVersion>4.0.0</modelVersion>
+
+  <parent>
+    <groupId>com.google.gitiles</groupId>
+    <artifactId>gitiles-parent</artifactId>
+    <version>1.0-SNAPSHOT</version>
+  </parent>
+
+  <artifactId>gitiles-servlet</artifactId>
+  <name>Gitiles - Servlet</name>
+
+  <description>
+    Gitiles servlet implementation
+  </description>
+
+  <dependencies>
+    <dependency>
+      <groupId>com.google.guava</groupId>
+      <artifactId>guava</artifactId>
+    </dependency>
+
+    <dependency>
+      <groupId>com.google.template</groupId>
+      <artifactId>soy</artifactId>
+    </dependency>
+
+    <dependency>
+      <groupId>org.eclipse.jgit</groupId>
+      <artifactId>org.eclipse.jgit</artifactId>
+    </dependency>
+
+    <dependency>
+      <groupId>org.eclipse.jgit</groupId>
+      <artifactId>org.eclipse.jgit.http.server</artifactId>
+    </dependency>
+
+    <dependency>
+      <groupId>org.eclipse.jgit</groupId>
+      <artifactId>org.eclipse.jgit.junit</artifactId>
+      <scope>test</scope>
+      <exclusions>
+        <exclusion>
+          <groupId>org.eclipse.jgit</groupId>
+          <artifactId>org.eclipse.jgit</artifactId>
+        </exclusion>
+      </exclusions>
+    </dependency>
+
+    <dependency>
+      <groupId>org.apache.tomcat</groupId>
+      <artifactId>servlet-api</artifactId>
+      <scope>provided</scope>
+    </dependency>
+
+    <dependency>
+      <groupId>junit</groupId>
+      <artifactId>junit</artifactId>
+      <scope>test</scope>
+    </dependency>
+
+    <dependency>
+      <groupId>org.slf4j</groupId>
+      <artifactId>slf4j-api</artifactId>
+    </dependency>
+
+    <dependency>
+      <groupId>joda-time</groupId>
+      <artifactId>joda-time</artifactId>
+    </dependency>
+
+    <dependency>
+      <groupId>com.google.code.gson</groupId>
+      <artifactId>gson</artifactId>
+    </dependency>
+
+    <dependency>
+      <groupId>org.apache.commons</groupId>
+      <artifactId>commons-lang3</artifactId>
+    </dependency>
+  </dependencies>
+
+  <build>
+    <plugins>
+      <plugin>
+        <groupId>org.apache.maven.plugins</groupId>
+        <artifactId>maven-source-plugin</artifactId>
+        <executions>
+          <execution>
+            <goals>
+              <goal>jar</goal>
+            </goals>
+          </execution>
+        </executions>
+      </plugin>
+    </plugins>
+  </build>
+</project>
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.
+    }
+  }
+}
diff --git a/gitiles-servlet/src/main/resources/com/google/gitiles/static/gitiles.css b/gitiles-servlet/src/main/resources/com/google/gitiles/static/gitiles.css
new file mode 100644
index 0000000..0c57519
--- /dev/null
+++ b/gitiles-servlet/src/main/resources/com/google/gitiles/static/gitiles.css
@@ -0,0 +1,319 @@
+/**
+ * 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.
+ */
+
+/* Common styles and definitions. */
+
+h1 {
+  position: absolute;
+  top: 0;
+  white-space: nowrap;
+  margin-top: 5px;
+}
+.menu {
+  position: absolute;
+  top: 0;
+  right: 0;
+  font-size: 10pt;
+  white-space: nowrap;
+  text-align: right;
+  margin-top: 5px;
+  margin-right: 5px;
+}
+.menu .entry {
+  padding-right: 5px;
+  border-right: 1px solid black;
+  margin-right: 0;
+}
+h2 {
+  margin-top: 3em;
+}
+.breadcrumbs {
+  margin-top: 3em;
+  font-size: 150%;
+  border-bottom: #ddd solid 1px; /* BORDER */
+}
+table.list {
+  margin-top: 1em;
+  width: 90%;
+}
+table.list tr.no-hover:hover {
+  background: #fff;
+}
+table.list tr:hover, ol.list li:hover, pre.prettyprint li:hover {
+  background: #eee; /* HOVER_BACKGROUND */
+}
+table.list td {
+  white-space: nowrap;
+  padding-top: 0.25em;
+  padding-bottom: 0.25em;
+}
+.log-link {
+  margin-left: 0.5em
+}
+
+
+/* Styles for the host index page. */
+
+.instructions {
+  width: 45em;
+  margin-left: 1em;
+  margin-right: 1em;
+  border-top: 1px solid #555;
+  border-bottom: 1px solid #555;
+  color: #555;
+}
+.instructions pre {
+  display: block;
+  margin-left: 1em;
+  border-left: 2px solid #060;
+  padding-left: 1em;
+  white-space: nowrap;
+}
+.footer {
+  text-align: right;
+  background: #eee;
+  padding-right: 1em;
+  width: 90%;
+}
+.footer a {
+  color: black;
+  font-weight: bold;
+  font-size: 70%;
+}
+
+
+/* Styles for the repository index page. */
+
+.repository-description {
+  border-bottom: #ddd solid 1px; /* BORDER */
+  padding-bottom: 5px; /* VPADDING */
+}
+.repository-refs {
+  width: 450px;
+}
+.repository-branches {
+  float: left;
+  width: 200px;
+}
+.repository-tags {
+  float: right;
+  width: 250px;
+}
+.clone-line {
+  background-color: #e5ecf9; /* BOX_BACKGROUND */
+  border: none;
+  margin: 5px /* VPADDING */ 0 0 0;
+  padding: 5px 2em; /* PADDING */
+  font-size: 9pt;
+}
+
+
+/* Styles for the object detail templates. */
+
+.sha1 {
+  color: #666;
+  font-size: 9pt;
+}
+div.sha1 {
+  padding-top: 5px; /* VPADDING */
+}
+
+.git-commit, .git-tag {
+  font-size: 9pt;
+  border-bottom: #ddd solid 1px; /* BORDER */
+  padding: 5px 2em; /* PADDING */
+}
+.git-commit table, .git-tag table {
+  margin: 0;
+}
+.git-commit table th, .git-tag table th {
+  text-align: right;
+}
+pre.commit-message, pre.tag-message {
+  border-bottom: #ddd solid 1px; /* BORDER */
+  padding: 5px 2em; /* PADDING */
+  color: #000;
+  font-size: 9pt;
+  margin: 0;
+}
+
+ul.diff-tree {
+  font-size: 9pt;
+  list-style-type: none;
+  margin: 0;
+  padding: 5px 2em; /* PADDING */
+}
+ul.diff-tree .add {
+  color: #060;
+}
+ul.diff-tree .delete {
+  color: #600;
+}
+ul.diff-tree .rename, ul.diff-tree .copy {
+  color: #006;
+}
+span.diff-link, ul.diff-tree .add, ul.diff-tree .modify, ul.diff-tree .delete,
+    ul.diff-tree .rename, ul.diff-tree .copy {
+  margin-left: 0.5em;
+}
+.diff-summary {
+  font-size: 9pt;
+  font-style: italic;
+  padding: 5px 2em; /* PADDING */
+  border-bottom: #ddd solid 1px; /* BORDER */
+}
+
+ol.files {
+  list-style-type: none;
+  margin-left: 1em;
+  font-size: 10pt;
+  line-height: normal;
+}
+
+/* Tree icons are taken from the public domain Apache standard icons:
+ * http://www.apache.org/icons/ */
+ol.files li.git-tree{
+  /* small/folder.png */
+  list-style-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABmJLR0QAAAAAAAD5Q7t/AAAACXBIWXMAAABIAAAASABGyWs+AAAACXZwQWcAAAAQAAAAEABcxq3DAAAA+klEQVQ4y63TMWvCQBjG8f8r+RouRSe7ONchuCTQsZS6FNLBSQrdi1PmFhcnhwpOUjoK3iIvRWcdnP0q16FcSOSijfSZjht+PNy9r6gqAGEYWgBVFSpEANtq1nl7TQCIk5Q8dA4OAPZmxHX0AsByOiw0Wk6HRJ020vBDAlh7+MouHOQauUSdNma9JU7SAhIcV9qbkRcy6633DQJKUgb9GTiG5Ooue598aucAAPO9A+Djc3UZcCr/A/Se37OalQFVlfliQ5ykF0E1+J2uKtCg/2jdufCN+fmfLzY83N7wdN8tRcaTmXjnwAeVRdw6n4pbLIBWsw7AeDITgB/OTHEfF3G6oAAAAABJRU5ErkJggg==);
+}
+ol.files li.symlink{
+  /* small/forward.png */
+  list-style-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAQAAAC1+jfqAAAAAmJLR0QAAKqNIzIAAAAJcEhZcwAAAEgAAABIAEbJaz4AAAAJdnBBZwAAABAAAAAQAFzGrcMAAACtSURBVCjPfZGxEYMwDEWffNTswApcGs+RY4KUtDSZgIbWJRNwWSHnjibHCtkhCyiFDYYE+Krk/0/S9xdPQq4f4QfZuukpFC6yI3jpIz4UexPuyiFMrsMJDebJOcwbx5WSMl4QTsyXqVkBNFig5AagwU+1dTECI446unBM2ga7nXJQVj0ePPWpxEAlHfbQhfglg0EnxhVlaSUK5qhg/pUm0htBkkFPJasV/0jBfwHsdko9kUCoMwAAAABJRU5ErkJggg==);
+}
+ol.files li.regular-file{
+  /* small/text.png */
+  list-style-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAQAAAC1+jfqAAAAAmJLR0QAAKqNIzIAAAAJcEhZcwAAAEgAAABIAEbJaz4AAAAJdnBBZwAAABAAAAAQAFzGrcMAAACDSURBVCjPYzzAgB8woQuc+Y/KZzz33xCJe55BkmEpgwkjHhMkGKJRTMFQcJ7hOYMJI0IJCwMDA4MVXPoYhP2fgcEEWcE6JBOOMRxkZGBYjWpCEJI0A4P9/xdIGtBMeMHAwCDBIIGuIIgBFRzDbQImoJcJx/CZ8Imx9/99DOFzDKFQFgBj6yGwhtEzOwAAAABJRU5ErkJggg==)
+}
+ol.files li.executable-file{
+  /* small/patch.png */
+  list-style-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAQAAAC1+jfqAAAAAmJLR0QA/4ePzL8AAAAJcEhZcwAAAEgAAABIAEbJaz4AAAAJdnBBZwAAABAAAAAQAFzGrcMAAACDSURBVCjPlY+xDYMwEEW/I4agZYRQsgojkC4ZAzp2ojMjuPQYjwJEcvgSKV+yzrKenv9J/2bk8hD5TCRjkdvVUKs3lgJYlfUKbyRE7gbojrkEtwOAJA18/UKC7HWYjrMXLYBJSQ+lEymAp+x0DHY6hkazGsfgrAnwc02bqg0jqfSetw2j7lch7TZvfQAAACR0RVh0Y29tbWVudABieSBNYXJ0aW4uS3JhZW1lckBNY2guU05JLkRlaCPF6QAAAABJRU5ErkJggg==);
+}
+ol.files li.gitlink{
+  /* small/continued.png */
+  list-style-image:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAQAAAC1+jfqAAAAAmJLR0QAAKqNIzIAAAAJcEhZcwAAAEgAAABIAEbJaz4AAAAJdnBBZwAAABAAAAAQAFzGrcMAAACPSURBVCjPzdCxbcNAFIPh7wQvkTIjBEjj1iscXGUSTZDGLjVCKuFWCK5Lc/AKGoUppNq1Wf18IB4Ilu65Ji8SmDOy2znzQeOgiTmLZoQ1VbWGkWYxh2mk+vBdGh6+XDzQLN5UIyc2d9vx+Ad/4BdNpTtHzrmlu0UctF+70rHm3WeBNVzLXnJzLfbAKyz5TP/rekGK5PrZfgAAAABJRU5ErkJggg==);
+}
+
+
+/* Styles for the path detail page. */
+
+.symlink-detail, .gitlink-detail {
+  margin-left: 1em;
+  color: #666;
+  font-style: italic;
+  font-size: 10pt;
+}
+
+
+/* Styles for the log detail page. */
+
+ol.shortlog {
+  list-style-type: none;
+  margin: 0;
+  padding: 5px 2em; /* PADDING */
+}
+ol.shortlog li {
+  border-bottom: #ddd solid 1px; /* BORDER */
+  padding-top: 2px;
+  padding-bottom: 2px;
+}
+ol.shortlog li.first {
+  border-top: #ddd solid 1px; /* BORDER */
+}
+ol.shortlog li:hover {
+  background: #eee; /* HOVER_BACKGROUND */
+}
+ol.shortlog .sha1 {
+  font-family: monospace;
+}
+.log-nav {
+  margin-top: 5px;
+  text-align: center;
+}
+.author {
+  padding-left: 3px;
+}
+.time {
+  font-size: 9pt; /* SHORTLOG_SMALL_FONT_SIZE */
+  font-style: italic;
+}
+.branch-label, .tag-label {
+  font-size: 9pt; /* SHORTLOG_SMALL_FONT_SIZE */
+  margin-left: 3px;
+}
+a.branch-label {
+  color: #dd4b39;
+}
+a.tag-label {
+  color: #009933;
+}
+
+
+/* Styles for the diff detail template. */
+
+.diff-header {
+}
+.diff-git {
+  color: #444;
+  font-weight: bold;
+}
+a.diff-git:hover {
+  text-decoration: none;
+}
+.diff-header, .diff-unified {
+  color: #000;
+  font-size: 9pt;
+  margin: 0;
+  padding-left: 2em;
+}
+.diff-unified {
+  border-bottom: #ddd solid 1px; /* BORDER */
+}
+.diff-unified .h {
+  color: darkblue;
+}
+.diff-unified .d {
+  color: darkred;
+}
+.diff-unified .i {
+  color: darkgreen;
+}
+
+
+/* Override some styles from the default prettify.css. */
+
+/* Line numbers on all lines. */
+li.L0, li.L1, li.L2, li.L3, li.L4, li.L5, li.L6, li.L7, li.L8, li.L9 {
+  list-style-type: decimal;
+}
+
+/* Disable alternating line background color. */
+li.L0, li.L1, li.L2, li.L3, li.L4, li.L5, li.L6, li.L7, li.L8, li.L9 {
+  background: #fff;
+}
+
+pre.git-blob {
+  border-top: #ddd solid 1px; /* BORDER */
+  border-bottom: #ddd solid 1px; /* BORDER */
+  border-left: none;
+  border-right: none;
+  padding-left: 1em;
+  padding-bottom: 5px;
+  font-family: monospace;
+  font-size: 8pt;
+}
+pre.prettyprint ol {
+  color: grey;
+}
diff --git a/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/COPYING b/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/COPYING
new file mode 100644
index 0000000..37a41c0
--- /dev/null
+++ b/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/COPYING
@@ -0,0 +1,214 @@
+google-code-prettify downloaded from:
+http://google-code-prettify.googlecode.com/files/prettify-small-1-Jun-2011.tar.bz2
+
+All files under the Apache License, with the following copyrights:
+prettify.js: Copyright (C) 2006 Google Inc.
+lang-apollo.js: Copyright (C) 2009 Onno Hommes.
+lang-clj.js: * @license Copyright (C) 2011 Google Inc.
+lang-css.js: Copyright (C) 2009 Google Inc.
+lang-go.js: Copyright (C) 2010 Google Inc.
+lang-hs.js: Copyright (C) 2009 Google Inc.
+lang-lisp.js: Copyright (C) 2008 Google Inc.
+lang-lua.js: Copyright (C) 2008 Google Inc.
+lang-ml.js: Copyright (C) 2008 Google Inc.
+lang-n.js: Copyright (C) 2011 Zimin A.V.
+lang-proto.js: Copyright (C) 2006 Google Inc.
+lang-scala.js: Copyright (C) 2010 Google Inc.
+lang-sql.js: Copyright (C) 2008 Google Inc.
+lang-tex.js: Copyright (C) 2011 Martin S.
+lang-vb.js: Copyright (C) 2009 Google Inc.
+lang-wiki.js: Copyright (C) 2009 Google Inc.
+lang-xq.js: Copyright (C) 2011 Patrick Wied
+
+===============================================================================
+
+                                 Apache License
+                           Version 2.0, January 2004
+                        http://www.apache.org/licenses/
+
+   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+   1. Definitions.
+
+      "License" shall mean the terms and conditions for use, reproduction,
+      and distribution as defined by Sections 1 through 9 of this document.
+
+      "Licensor" shall mean the copyright owner or entity authorized by
+      the copyright owner that is granting the License.
+
+      "Legal Entity" shall mean the union of the acting entity and all
+      other entities that control, are controlled by, or are under common
+      control with that entity. For the purposes of this definition,
+      "control" means (i) the power, direct or indirect, to cause the
+      direction or management of such entity, whether by contract or
+      otherwise, or (ii) ownership of fifty percent (50%) or more of the
+      outstanding shares, or (iii) beneficial ownership of such entity.
+
+      "You" (or "Your") shall mean an individual or Legal Entity
+      exercising permissions granted by this License.
+
+      "Source" form shall mean the preferred form for making modifications,
+      including but not limited to software source code, documentation
+      source, and configuration files.
+
+      "Object" form shall mean any form resulting from mechanical
+      transformation or translation of a Source form, including but
+      not limited to compiled object code, generated documentation,
+      and conversions to other media types.
+
+      "Work" shall mean the work of authorship, whether in Source or
+      Object form, made available under the License, as indicated by a
+      copyright notice that is included in or attached to the work
+      (an example is provided in the Appendix below).
+
+      "Derivative Works" shall mean any work, whether in Source or Object
+      form, that is based on (or derived from) the Work and for which the
+      editorial revisions, annotations, elaborations, or other modifications
+      represent, as a whole, an original work of authorship. For the purposes
+      of this License, Derivative Works shall not include works that remain
+      separable from, or merely link (or bind by name) to the interfaces of,
+      the Work and Derivative Works thereof.
+
+      "Contribution" shall mean any work of authorship, including
+      the original version of the Work and any modifications or additions
+      to that Work or Derivative Works thereof, that is intentionally
+      submitted to Licensor for inclusion in the Work by the copyright owner
+      or by an individual or Legal Entity authorized to submit on behalf of
+      the copyright owner. For the purposes of this definition, "submitted"
+      means any form of electronic, verbal, or written communication sent
+      to the Licensor or its representatives, including but not limited to
+      communication on electronic mailing lists, source code control systems,
+      and issue tracking systems that are managed by, or on behalf of, the
+      Licensor for the purpose of discussing and improving the Work, but
+      excluding communication that is conspicuously marked or otherwise
+      designated in writing by the copyright owner as "Not a Contribution."
+
+      "Contributor" shall mean Licensor and any individual or Legal Entity
+      on behalf of whom a Contribution has been received by Licensor and
+      subsequently incorporated within the Work.
+
+   2. Grant of Copyright License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      copyright license to reproduce, prepare Derivative Works of,
+      publicly display, publicly perform, sublicense, and distribute the
+      Work and such Derivative Works in Source or Object form.
+
+   3. Grant of Patent License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      (except as stated in this section) patent license to make, have made,
+      use, offer to sell, sell, import, and otherwise transfer the Work,
+      where such license applies only to those patent claims licensable
+      by such Contributor that are necessarily infringed by their
+      Contribution(s) alone or by combination of their Contribution(s)
+      with the Work to which such Contribution(s) was submitted. If You
+      institute patent litigation against any entity (including a
+      cross-claim or counterclaim in a lawsuit) alleging that the Work
+      or a Contribution incorporated within the Work constitutes direct
+      or contributory patent infringement, then any patent licenses
+      granted to You under this License for that Work shall terminate
+      as of the date such litigation is filed.
+
+   4. Redistribution. You may reproduce and distribute copies of the
+      Work or Derivative Works thereof in any medium, with or without
+      modifications, and in Source or Object form, provided that You
+      meet the following conditions:
+
+      (a) You must give any other recipients of the Work or
+          Derivative Works a copy of this License; and
+
+      (b) You must cause any modified files to carry prominent notices
+          stating that You changed the files; and
+
+      (c) You must retain, in the Source form of any Derivative Works
+          that You distribute, all copyright, patent, trademark, and
+          attribution notices from the Source form of the Work,
+          excluding those notices that do not pertain to any part of
+          the Derivative Works; and
+
+      (d) If the Work includes a "NOTICE" text file as part of its
+          distribution, then any Derivative Works that You distribute must
+          include a readable copy of the attribution notices contained
+          within such NOTICE file, excluding those notices that do not
+          pertain to any part of the Derivative Works, in at least one
+          of the following places: within a NOTICE text file distributed
+          as part of the Derivative Works; within the Source form or
+          documentation, if provided along with the Derivative Works; or,
+          within a display generated by the Derivative Works, if and
+          wherever such third-party notices normally appear. The contents
+          of the NOTICE file are for informational purposes only and
+          do not modify the License. You may add Your own attribution
+          notices within Derivative Works that You distribute, alongside
+          or as an addendum to the NOTICE text from the Work, provided
+          that such additional attribution notices cannot be construed
+          as modifying the License.
+
+      You may add Your own copyright statement to Your modifications and
+      may provide additional or different license terms and conditions
+      for use, reproduction, or distribution of Your modifications, or
+      for any such Derivative Works as a whole, provided Your use,
+      reproduction, and distribution of the Work otherwise complies with
+      the conditions stated in this License.
+
+   5. Submission of Contributions. Unless You explicitly state otherwise,
+      any Contribution intentionally submitted for inclusion in the Work
+      by You to the Licensor shall be under the terms and conditions of
+      this License, without any additional terms or conditions.
+      Notwithstanding the above, nothing herein shall supersede or modify
+      the terms of any separate license agreement you may have executed
+      with Licensor regarding such Contributions.
+
+   6. Trademarks. This License does not grant permission to use the trade
+      names, trademarks, service marks, or product names of the Licensor,
+      except as required for reasonable and customary use in describing the
+      origin of the Work and reproducing the content of the NOTICE file.
+
+   7. Disclaimer of Warranty. Unless required by applicable law or
+      agreed to in writing, Licensor provides the Work (and each
+      Contributor provides its Contributions) on an "AS IS" BASIS,
+      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+      implied, including, without limitation, any warranties or conditions
+      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+      PARTICULAR PURPOSE. You are solely responsible for determining the
+      appropriateness of using or redistributing the Work and assume any
+      risks associated with Your exercise of permissions under this License.
+
+   8. Limitation of Liability. In no event and under no legal theory,
+      whether in tort (including negligence), contract, or otherwise,
+      unless required by applicable law (such as deliberate and grossly
+      negligent acts) or agreed to in writing, shall any Contributor be
+      liable to You for damages, including any direct, indirect, special,
+      incidental, or consequential damages of any character arising as a
+      result of this License or out of the use or inability to use the
+      Work (including but not limited to damages for loss of goodwill,
+      work stoppage, computer failure or malfunction, or any and all
+      other commercial damages or losses), even if such Contributor
+      has been advised of the possibility of such damages.
+
+   9. Accepting Warranty or Additional Liability. While redistributing
+      the Work or Derivative Works thereof, You may choose to offer,
+      and charge a fee for, acceptance of support, warranty, indemnity,
+      or other liability obligations and/or rights consistent with this
+      License. However, in accepting such obligations, You may act only
+      on Your own behalf and on Your sole responsibility, not on behalf
+      of any other Contributor, and only if You agree to indemnify,
+      defend, and hold each Contributor harmless for any liability
+      incurred by, or claims asserted against, such Contributor by reason
+      of your accepting any such warranty or additional liability.
+
+   END OF TERMS AND CONDITIONS
+
+   Copyright 2011 Mike Samuel et al
+
+   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.
diff --git a/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/lang-apollo.js b/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/lang-apollo.js
new file mode 100644
index 0000000..7098baf
--- /dev/null
+++ b/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/lang-apollo.js
@@ -0,0 +1,2 @@
+PR.registerLangHandler(PR.createSimpleLexer([["com",/^#[^\n\r]*/,null,"#"],["pln",/^[\t\n\r \xa0]+/,null,"\t\n\r Â\xa0"],["str",/^"(?:[^"\\]|\\[\S\s])*(?:"|$)/,null,'"']],[["kwd",/^(?:ADS|AD|AUG|BZF|BZMF|CAE|CAF|CA|CCS|COM|CS|DAS|DCA|DCOM|DCS|DDOUBL|DIM|DOUBLE|DTCB|DTCF|DV|DXCH|EDRUPT|EXTEND|INCR|INDEX|NDX|INHINT|LXCH|MASK|MSK|MP|MSU|NOOP|OVSK|QXCH|RAND|READ|RELINT|RESUME|RETURN|ROR|RXOR|SQUARE|SU|TCR|TCAA|OVSK|TCF|TC|TS|WAND|WOR|WRITE|XCH|XLQ|XXALQ|ZL|ZQ|ADD|ADZ|SUB|SUZ|MPY|MPR|MPZ|DVP|COM|ABS|CLA|CLZ|LDQ|STO|STQ|ALS|LLS|LRS|TRA|TSQ|TMI|TOV|AXT|TIX|DLY|INP|OUT)\s/,
+null],["typ",/^(?:-?GENADR|=MINUS|2BCADR|VN|BOF|MM|-?2CADR|-?[1-6]DNADR|ADRES|BBCON|[ES]?BANK=?|BLOCK|BNKSUM|E?CADR|COUNT\*?|2?DEC\*?|-?DNCHAN|-?DNPTR|EQUALS|ERASE|MEMORY|2?OCT|REMADR|SETLOC|SUBRO|ORG|BSS|BES|SYN|EQU|DEFINE|END)\s/,null],["lit",/^'(?:-*(?:\w|\\[!-~])(?:[\w-]*|\\[!-~])[!=?]?)?/],["pln",/^-*(?:[!-z]|\\[!-~])(?:[\w-]*|\\[!-~])[!=?]?/],["pun",/^[^\w\t\n\r "'-);\\\xa0]+/]]),["apollo","agc","aea"]);
diff --git a/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/lang-clj.js b/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/lang-clj.js
new file mode 100644
index 0000000..542a220
--- /dev/null
+++ b/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/lang-clj.js
@@ -0,0 +1,18 @@
+/*
+ Copyright (C) 2011 Google Inc.
+
+ 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.
+*/
+var a=null;
+PR.registerLangHandler(PR.createSimpleLexer([["opn",/^[([{]+/,a,"([{"],["clo",/^[)\]}]+/,a,")]}"],["com",/^;[^\n\r]*/,a,";"],["pln",/^[\t\n\r \xa0]+/,a,"\t\n\r \xa0"],["str",/^"(?:[^"\\]|\\[\S\s])*(?:"|$)/,a,'"']],[["kwd",/^(?:def|if|do|let|quote|var|fn|loop|recur|throw|try|monitor-enter|monitor-exit|defmacro|defn|defn-|macroexpand|macroexpand-1|for|doseq|dosync|dotimes|and|or|when|not|assert|doto|proxy|defstruct|first|rest|cons|defprotocol|deftype|defrecord|reify|defmulti|defmethod|meta|with-meta|ns|in-ns|create-ns|import|intern|refer|alias|namespace|resolve|ref|deref|refset|new|set!|memfn|to-array|into-array|aset|gen-class|reduce|map|filter|find|nil?|empty?|hash-map|hash-set|vec|vector|seq|flatten|reverse|assoc|dissoc|list|list?|disj|get|union|difference|intersection|extend|extend-type|extend-protocol|prn)\b/,a],
+["typ",/^:[\dA-Za-z-]+/]]),["clj"]);
diff --git a/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/lang-css.js b/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/lang-css.js
new file mode 100644
index 0000000..041e1f5
--- /dev/null
+++ b/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/lang-css.js
@@ -0,0 +1,2 @@
+PR.registerLangHandler(PR.createSimpleLexer([["pln",/^[\t\n\f\r ]+/,null," \t\r\n"]],[["str",/^"(?:[^\n\f\r"\\]|\\(?:\r\n?|\n|\f)|\\[\S\s])*"/,null],["str",/^'(?:[^\n\f\r'\\]|\\(?:\r\n?|\n|\f)|\\[\S\s])*'/,null],["lang-css-str",/^url\(([^"')]*)\)/i],["kwd",/^(?:url|rgb|!important|@import|@page|@media|@charset|inherit)(?=[^\w-]|$)/i,null],["lang-css-kw",/^(-?(?:[_a-z]|\\[\da-f]+ ?)(?:[\w-]|\\\\[\da-f]+ ?)*)\s*:/i],["com",/^\/\*[^*]*\*+(?:[^*/][^*]*\*+)*\//],["com",
+/^(?:<\!--|--\>)/],["lit",/^(?:\d+|\d*\.\d+)(?:%|[a-z]+)?/i],["lit",/^#[\da-f]{3,6}/i],["pln",/^-?(?:[_a-z]|\\[\da-f]+ ?)(?:[\w-]|\\\\[\da-f]+ ?)*/i],["pun",/^[^\s\w"']+/]]),["css"]);PR.registerLangHandler(PR.createSimpleLexer([],[["kwd",/^-?(?:[_a-z]|\\[\da-f]+ ?)(?:[\w-]|\\\\[\da-f]+ ?)*/i]]),["css-kw"]);PR.registerLangHandler(PR.createSimpleLexer([],[["str",/^[^"')]+/]]),["css-str"]);
diff --git a/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/lang-go.js b/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/lang-go.js
new file mode 100644
index 0000000..fc18dc0
--- /dev/null
+++ b/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/lang-go.js
@@ -0,0 +1 @@
+PR.registerLangHandler(PR.createSimpleLexer([["pln",/^[\t\n\r \xa0]+/,null,"\t\n\r Â\xa0"],["pln",/^(?:"(?:[^"\\]|\\[\S\s])*(?:"|$)|'(?:[^'\\]|\\[\S\s])+(?:'|$)|`[^`]*(?:`|$))/,null,"\"'"]],[["com",/^(?:\/\/[^\n\r]*|\/\*[\S\s]*?\*\/)/],["pln",/^(?:[^"'/`]|\/(?![*/]))+/]]),["go"]);
diff --git a/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/lang-hs.js b/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/lang-hs.js
new file mode 100644
index 0000000..9d77b08
--- /dev/null
+++ b/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/lang-hs.js
@@ -0,0 +1,2 @@
+PR.registerLangHandler(PR.createSimpleLexer([["pln",/^[\t-\r ]+/,null,"\t\n\r "],["str",/^"(?:[^\n\f\r"\\]|\\[\S\s])*(?:"|$)/,null,'"'],["str",/^'(?:[^\n\f\r'\\]|\\[^&])'?/,null,"'"],["lit",/^(?:0o[0-7]+|0x[\da-f]+|\d+(?:\.\d+)?(?:e[+-]?\d+)?)/i,null,"0123456789"]],[["com",/^(?:--+[^\n\f\r]*|{-(?:[^-]|-+[^}-])*-})/],["kwd",/^(?:case|class|data|default|deriving|do|else|if|import|in|infix|infixl|infixr|instance|let|module|newtype|of|then|type|where|_)(?=[^\d'A-Za-z]|$)/,
+null],["pln",/^(?:[A-Z][\w']*\.)*[A-Za-z][\w']*/],["pun",/^[^\d\t-\r "'A-Za-z]+/]]),["hs"]);
diff --git a/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/lang-lisp.js b/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/lang-lisp.js
new file mode 100644
index 0000000..02a30e8
--- /dev/null
+++ b/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/lang-lisp.js
@@ -0,0 +1,3 @@
+var a=null;
+PR.registerLangHandler(PR.createSimpleLexer([["opn",/^\(+/,a,"("],["clo",/^\)+/,a,")"],["com",/^;[^\n\r]*/,a,";"],["pln",/^[\t\n\r \xa0]+/,a,"\t\n\r \xa0"],["str",/^"(?:[^"\\]|\\[\S\s])*(?:"|$)/,a,'"']],[["kwd",/^(?:block|c[ad]+r|catch|con[ds]|def(?:ine|un)|do|eq|eql|equal|equalp|eval-when|flet|format|go|if|labels|lambda|let|load-time-value|locally|macrolet|multiple-value-call|nil|progn|progv|quote|require|return-from|setq|symbol-macrolet|t|tagbody|the|throw|unwind)\b/,a],
+["lit",/^[+-]?(?:[#0]x[\da-f]+|\d+\/\d+|(?:\.\d+|\d+(?:\.\d*)?)(?:[de][+-]?\d+)?)/i],["lit",/^'(?:-*(?:\w|\\[!-~])(?:[\w-]*|\\[!-~])[!=?]?)?/],["pln",/^-*(?:[_a-z]|\\[!-~])(?:[\w-]*|\\[!-~])[!=?]?/i],["pun",/^[^\w\t\n\r "'-);\\\xa0]+/]]),["cl","el","lisp","scm"]);
diff --git a/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/lang-lua.js b/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/lang-lua.js
new file mode 100644
index 0000000..e83a3c4
--- /dev/null
+++ b/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/lang-lua.js
@@ -0,0 +1,2 @@
+PR.registerLangHandler(PR.createSimpleLexer([["pln",/^[\t\n\r \xa0]+/,null,"\t\n\r Â\xa0"],["str",/^(?:"(?:[^"\\]|\\[\S\s])*(?:"|$)|'(?:[^'\\]|\\[\S\s])*(?:'|$))/,null,"\"'"]],[["com",/^--(?:\[(=*)\[[\S\s]*?(?:]\1]|$)|[^\n\r]*)/],["str",/^\[(=*)\[[\S\s]*?(?:]\1]|$)/],["kwd",/^(?:and|break|do|else|elseif|end|false|for|function|if|in|local|nil|not|or|repeat|return|then|true|until|while)\b/,null],["lit",/^[+-]?(?:0x[\da-f]+|(?:\.\d+|\d+(?:\.\d*)?)(?:e[+-]?\d+)?)/i],
+["pln",/^[_a-z]\w*/i],["pun",/^[^\w\t\n\r \xa0][^\w\t\n\r "'+=\xa0-]*/]]),["lua"]);
diff --git a/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/lang-ml.js b/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/lang-ml.js
new file mode 100644
index 0000000..6df02d7
--- /dev/null
+++ b/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/lang-ml.js
@@ -0,0 +1,2 @@
+PR.registerLangHandler(PR.createSimpleLexer([["pln",/^[\t\n\r \xa0]+/,null,"\t\n\r Â\xa0"],["com",/^#(?:if[\t\n\r \xa0]+(?:[$_a-z][\w']*|``[^\t\n\r`]*(?:``|$))|else|endif|light)/i,null,"#"],["str",/^(?:"(?:[^"\\]|\\[\S\s])*(?:"|$)|'(?:[^'\\]|\\[\S\s])(?:'|$))/,null,"\"'"]],[["com",/^(?:\/\/[^\n\r]*|\(\*[\S\s]*?\*\))/],["kwd",/^(?:abstract|and|as|assert|begin|class|default|delegate|do|done|downcast|downto|elif|else|end|exception|extern|false|finally|for|fun|function|if|in|inherit|inline|interface|internal|lazy|let|match|member|module|mutable|namespace|new|null|of|open|or|override|private|public|rec|return|static|struct|then|to|true|try|type|upcast|use|val|void|when|while|with|yield|asr|land|lor|lsl|lsr|lxor|mod|sig|atomic|break|checked|component|const|constraint|constructor|continue|eager|event|external|fixed|functor|global|include|method|mixin|object|parallel|process|protected|pure|sealed|trait|virtual|volatile)\b/],
+["lit",/^[+-]?(?:0x[\da-f]+|(?:\.\d+|\d+(?:\.\d*)?)(?:e[+-]?\d+)?)/i],["pln",/^(?:[_a-z][\w']*[!#?]?|``[^\t\n\r`]*(?:``|$))/i],["pun",/^[^\w\t\n\r "'\xa0]+/]]),["fs","ml"]);
diff --git a/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/lang-n.js b/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/lang-n.js
new file mode 100644
index 0000000..6c2e85b
--- /dev/null
+++ b/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/lang-n.js
@@ -0,0 +1,4 @@
+var a=null;
+PR.registerLangHandler(PR.createSimpleLexer([["str",/^(?:'(?:[^\n\r'\\]|\\.)*'|"(?:[^\n\r"\\]|\\.)*(?:"|$))/,a,'"'],["com",/^#(?:(?:define|elif|else|endif|error|ifdef|include|ifndef|line|pragma|undef|warning)\b|[^\n\r]*)/,a,"#"],["pln",/^\s+/,a," \r\n\t\xa0"]],[["str",/^@"(?:[^"]|"")*(?:"|$)/,a],["str",/^<#[^#>]*(?:#>|$)/,a],["str",/^<(?:(?:(?:\.\.\/)*|\/?)(?:[\w-]+(?:\/[\w-]+)+)?[\w-]+\.h|[a-z]\w*)>/,a],["com",/^\/\/[^\n\r]*/,a],["com",/^\/\*[\S\s]*?(?:\*\/|$)/,
+a],["kwd",/^(?:abstract|and|as|base|catch|class|def|delegate|enum|event|extern|false|finally|fun|implements|interface|internal|is|macro|match|matches|module|mutable|namespace|new|null|out|override|params|partial|private|protected|public|ref|sealed|static|struct|syntax|this|throw|true|try|type|typeof|using|variant|virtual|volatile|when|where|with|assert|assert2|async|break|checked|continue|do|else|ensures|for|foreach|if|late|lock|new|nolate|otherwise|regexp|repeat|requires|return|surroundwith|unchecked|unless|using|while|yield)\b/,
+a],["typ",/^(?:array|bool|byte|char|decimal|double|float|int|list|long|object|sbyte|short|string|ulong|uint|ufloat|ulong|ushort|void)\b/,a],["lit",/^@[$_a-z][\w$@]*/i,a],["typ",/^@[A-Z]+[a-z][\w$@]*/,a],["pln",/^'?[$_a-z][\w$@]*/i,a],["lit",/^(?:0x[\da-f]+|(?:\d(?:_\d+)*\d*(?:\.\d*)?|\.\d\+)(?:e[+-]?\d+)?)[a-z]*/i,a,"0123456789"],["pun",/^.[^\s\w"-$'./@`]*/,a]]),["n","nemerle"]);
diff --git a/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/lang-proto.js b/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/lang-proto.js
new file mode 100644
index 0000000..f006ad8
--- /dev/null
+++ b/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/lang-proto.js
@@ -0,0 +1 @@
+PR.registerLangHandler(PR.sourceDecorator({keywords:"bytes,default,double,enum,extend,extensions,false,group,import,max,message,option,optional,package,repeated,required,returns,rpc,service,syntax,to,true",types:/^(bool|(double|s?fixed|[su]?int)(32|64)|float|string)\b/,cStyleComments:!0}),["proto"]);
diff --git a/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/lang-scala.js b/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/lang-scala.js
new file mode 100644
index 0000000..60d034d
--- /dev/null
+++ b/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/lang-scala.js
@@ -0,0 +1,2 @@
+PR.registerLangHandler(PR.createSimpleLexer([["pln",/^[\t\n\r \xa0]+/,null,"\t\n\r Â\xa0"],["str",/^"(?:""(?:""?(?!")|[^"\\]|\\.)*"{0,3}|(?:[^\n\r"\\]|\\.)*"?)/,null,'"'],["lit",/^`(?:[^\n\r\\`]|\\.)*`?/,null,"`"],["pun",/^[!#%&(--:-@[-^{-~]+/,null,"!#%&()*+,-:;<=>?@[\\]^{|}~"]],[["str",/^'(?:[^\n\r'\\]|\\(?:'|[^\n\r']+))'/],["lit",/^'[$A-Z_a-z][\w$]*(?![\w$'])/],["kwd",/^(?:abstract|case|catch|class|def|do|else|extends|final|finally|for|forSome|if|implicit|import|lazy|match|new|object|override|package|private|protected|requires|return|sealed|super|throw|trait|try|type|val|var|while|with|yield)\b/],
+["lit",/^(?:true|false|null|this)\b/],["lit",/^(?:0(?:[0-7]+|x[\da-f]+)l?|(?:0|[1-9]\d*)(?:(?:\.\d+)?(?:e[+-]?\d+)?f?|l?)|\\.\d+(?:e[+-]?\d+)?f?)/i],["typ",/^[$_]*[A-Z][\d$A-Z_]*[a-z][\w$]*/],["pln",/^[$A-Z_a-z][\w$]*/],["com",/^\/(?:\/.*|\*(?:\/|\**[^*/])*(?:\*+\/?)?)/],["pun",/^(?:\.+|\/)/]]),["scala"]);
diff --git a/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/lang-sql.js b/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/lang-sql.js
new file mode 100644
index 0000000..da705b0
--- /dev/null
+++ b/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/lang-sql.js
@@ -0,0 +1,2 @@
+PR.registerLangHandler(PR.createSimpleLexer([["pln",/^[\t\n\r \xa0]+/,null,"\t\n\r Â\xa0"],["str",/^(?:"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')/,null,"\"'"]],[["com",/^(?:--[^\n\r]*|\/\*[\S\s]*?(?:\*\/|$))/],["kwd",/^(?:add|all|alter|and|any|as|asc|authorization|backup|begin|between|break|browse|bulk|by|cascade|case|check|checkpoint|close|clustered|coalesce|collate|column|commit|compute|constraint|contains|containstable|continue|convert|create|cross|current|current_date|current_time|current_timestamp|current_user|cursor|database|dbcc|deallocate|declare|default|delete|deny|desc|disk|distinct|distributed|double|drop|dummy|dump|else|end|errlvl|escape|except|exec|execute|exists|exit|fetch|file|fillfactor|for|foreign|freetext|freetexttable|from|full|function|goto|grant|group|having|holdlock|identity|identitycol|identity_insert|if|in|index|inner|insert|intersect|into|is|join|key|kill|left|like|lineno|load|match|merge|national|nocheck|nonclustered|not|null|nullif|of|off|offsets|on|open|opendatasource|openquery|openrowset|openxml|option|or|order|outer|over|percent|plan|precision|primary|print|proc|procedure|public|raiserror|read|readtext|reconfigure|references|replication|restore|restrict|return|revoke|right|rollback|rowcount|rowguidcol|rule|save|schema|select|session_user|set|setuser|shutdown|some|statistics|system_user|table|textsize|then|to|top|tran|transaction|trigger|truncate|tsequal|union|unique|update|updatetext|use|user|using|values|varying|view|waitfor|when|where|while|with|writetext)(?=[^\w-]|$)/i,
+null],["lit",/^[+-]?(?:0x[\da-f]+|(?:\.\d+|\d+(?:\.\d*)?)(?:e[+-]?\d+)?)/i],["pln",/^[_a-z][\w-]*/i],["pun",/^[^\w\t\n\r "'\xa0][^\w\t\n\r "'+\xa0-]*/]]),["sql"]);
diff --git a/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/lang-tex.js b/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/lang-tex.js
new file mode 100644
index 0000000..ce96fbb
--- /dev/null
+++ b/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/lang-tex.js
@@ -0,0 +1 @@
+PR.registerLangHandler(PR.createSimpleLexer([["pln",/^[\t\n\r \xa0]+/,null,"\t\n\r Â\xa0"],["com",/^%[^\n\r]*/,null,"%"]],[["kwd",/^\\[@-Za-z]+/],["kwd",/^\\./],["typ",/^[$&]/],["lit",/[+-]?(?:\.\d+|\d+(?:\.\d*)?)(cm|em|ex|in|pc|pt|bp|mm)/i],["pun",/^[()=[\]{}]+/]]),["latex","tex"]);
diff --git a/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/lang-vb.js b/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/lang-vb.js
new file mode 100644
index 0000000..07506b0
--- /dev/null
+++ b/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/lang-vb.js
@@ -0,0 +1,2 @@
+PR.registerLangHandler(PR.createSimpleLexer([["pln",/^[\t\n\r \xa0\u2028\u2029]+/,null,"\t\n\r Â\xa0

"],["str",/^(?:["\u201c\u201d](?:[^"\u201c\u201d]|["\u201c\u201d]{2})(?:["\u201c\u201d]c|$)|["\u201c\u201d](?:[^"\u201c\u201d]|["\u201c\u201d]{2})*(?:["\u201c\u201d]|$))/i,null,'"“”'],["com",/^['\u2018\u2019].*/,null,"'‘’"]],[["kwd",/^(?:addhandler|addressof|alias|and|andalso|ansi|as|assembly|auto|boolean|byref|byte|byval|call|case|catch|cbool|cbyte|cchar|cdate|cdbl|cdec|char|cint|class|clng|cobj|const|cshort|csng|cstr|ctype|date|decimal|declare|default|delegate|dim|directcast|do|double|each|else|elseif|end|endif|enum|erase|error|event|exit|finally|for|friend|function|get|gettype|gosub|goto|handles|if|implements|imports|in|inherits|integer|interface|is|let|lib|like|long|loop|me|mod|module|mustinherit|mustoverride|mybase|myclass|namespace|new|next|not|notinheritable|notoverridable|object|on|option|optional|or|orelse|overloads|overridable|overrides|paramarray|preserve|private|property|protected|public|raiseevent|readonly|redim|removehandler|resume|return|select|set|shadows|shared|short|single|static|step|stop|string|structure|sub|synclock|then|throw|to|try|typeof|unicode|until|variant|wend|when|while|with|withevents|writeonly|xor|endif|gosub|let|variant|wend)\b/i,
+null],["com",/^rem.*/i],["lit",/^(?:true\b|false\b|nothing\b|\d+(?:e[+-]?\d+[dfr]?|[dfilrs])?|(?:&h[\da-f]+|&o[0-7]+)[ils]?|\d*\.\d+(?:e[+-]?\d+)?[dfr]?|#\s+(?:\d+[/-]\d+[/-]\d+(?:\s+\d+:\d+(?::\d+)?(\s*(?:am|pm))?)?|\d+:\d+(?::\d+)?(\s*(?:am|pm))?)\s+#)/i],["pln",/^(?:(?:[a-z]|_\w)\w*|\[(?:[a-z]|_\w)\w*])/i],["pun",/^[^\w\t\n\r "'[\]\xa0\u2018\u2019\u201c\u201d\u2028\u2029]+/],["pun",/^(?:\[|])/]]),["vb","vbs"]);
diff --git a/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/lang-vhdl.js b/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/lang-vhdl.js
new file mode 100644
index 0000000..128b5b6
--- /dev/null
+++ b/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/lang-vhdl.js
@@ -0,0 +1,3 @@
+PR.registerLangHandler(PR.createSimpleLexer([["pln",/^[\t\n\r \xa0]+/,null,"\t\n\r Â\xa0"]],[["str",/^(?:[box]?"(?:[^"]|"")*"|'.')/i],["com",/^--[^\n\r]*/],["kwd",/^(?:abs|access|after|alias|all|and|architecture|array|assert|attribute|begin|block|body|buffer|bus|case|component|configuration|constant|disconnect|downto|else|elsif|end|entity|exit|file|for|function|generate|generic|group|guarded|if|impure|in|inertial|inout|is|label|library|linkage|literal|loop|map|mod|nand|new|next|nor|not|null|of|on|open|or|others|out|package|port|postponed|procedure|process|pure|range|record|register|reject|rem|report|return|rol|ror|select|severity|shared|signal|sla|sll|sra|srl|subtype|then|to|transport|type|unaffected|units|until|use|variable|wait|when|while|with|xnor|xor)(?=[^\w-]|$)/i,
+null],["typ",/^(?:bit|bit_vector|character|boolean|integer|real|time|string|severity_level|positive|natural|signed|unsigned|line|text|std_u?logic(?:_vector)?)(?=[^\w-]|$)/i,null],["typ",/^'(?:active|ascending|base|delayed|driving|driving_value|event|high|image|instance_name|last_active|last_event|last_value|left|leftof|length|low|path_name|pos|pred|quiet|range|reverse_range|right|rightof|simple_name|stable|succ|transaction|val|value)(?=[^\w-]|$)/i,null],["lit",/^\d+(?:_\d+)*(?:#[\w.\\]+#(?:[+-]?\d+(?:_\d+)*)?|(?:\.\d+(?:_\d+)*)?(?:e[+-]?\d+(?:_\d+)*)?)/i],
+["pln",/^(?:[a-z]\w*|\\[^\\]*\\)/i],["pun",/^[^\w\t\n\r "'\xa0][^\w\t\n\r "'\xa0-]*/]]),["vhdl","vhd"]);
diff --git a/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/lang-wiki.js b/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/lang-wiki.js
new file mode 100644
index 0000000..9b0b448
--- /dev/null
+++ b/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/lang-wiki.js
@@ -0,0 +1,2 @@
+PR.registerLangHandler(PR.createSimpleLexer([["pln",/^[\d\t a-gi-z\xa0]+/,null,"\t Â\xa0abcdefgijklmnopqrstuvwxyz0123456789"],["pun",/^[*=[\]^~]+/,null,"=*~^[]"]],[["lang-wiki.meta",/(?:^^|\r\n?|\n)(#[a-z]+)\b/],["lit",/^[A-Z][a-z][\da-z]+[A-Z][a-z][^\W_]+\b/],["lang-",/^{{{([\S\s]+?)}}}/],["lang-",/^`([^\n\r`]+)`/],["str",/^https?:\/\/[^\s#/?]*(?:\/[^\s#?]*)?(?:\?[^\s#]*)?(?:#\S*)?/i],["pln",/^(?:\r\n|[\S\s])[^\n\r#*=A-[^`h{~]*/]]),["wiki"]);
+PR.registerLangHandler(PR.createSimpleLexer([["kwd",/^#[a-z]+/i,null,"#"]],[]),["wiki.meta"]);
diff --git a/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/lang-xq.js b/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/lang-xq.js
new file mode 100644
index 0000000..e323ae3
--- /dev/null
+++ b/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/lang-xq.js
@@ -0,0 +1,3 @@
+PR.registerLangHandler(PR.createSimpleLexer([["var pln",/^\$[\w-]+/,null,"$"]],[["pln",/^[\s=][<>][\s=]/],["lit",/^@[\w-]+/],["tag",/^<\/?[a-z](?:[\w-.:]*\w)?|\/?>$/i],["com",/^\(:[\S\s]*?:\)/],["pln",/^[(),/;[\]{}]$/],["str",/^(?:"(?:[^"\\{]|\\[\S\s])*(?:"|$)|'(?:[^'\\{]|\\[\S\s])*(?:'|$))/,null,"\"'"],["kwd",/^(?:xquery|where|version|variable|union|typeswitch|treat|to|then|text|stable|sortby|some|self|schema|satisfies|returns|return|ref|processing-instruction|preceding-sibling|preceding|precedes|parent|only|of|node|namespace|module|let|item|intersect|instance|in|import|if|function|for|follows|following-sibling|following|external|except|every|else|element|descending|descendant-or-self|descendant|define|default|declare|comment|child|cast|case|before|attribute|assert|ascending|as|ancestor-or-self|ancestor|after|eq|order|by|or|and|schema-element|document-node|node|at)\b/],
+["typ",/^(?:xs:yearMonthDuration|xs:unsignedLong|xs:time|xs:string|xs:short|xs:QName|xs:Name|xs:long|xs:integer|xs:int|xs:gYearMonth|xs:gYear|xs:gMonthDay|xs:gDay|xs:float|xs:duration|xs:double|xs:decimal|xs:dayTimeDuration|xs:dateTime|xs:date|xs:byte|xs:boolean|xs:anyURI|xf:yearMonthDuration)\b/,null],["fun pln",/^(?:xp:dereference|xinc:node-expand|xinc:link-references|xinc:link-expand|xhtml:restructure|xhtml:clean|xhtml:add-lists|xdmp:zip-manifest|xdmp:zip-get|xdmp:zip-create|xdmp:xquery-version|xdmp:word-convert|xdmp:with-namespaces|xdmp:version|xdmp:value|xdmp:user-roles|xdmp:user-last-login|xdmp:user|xdmp:url-encode|xdmp:url-decode|xdmp:uri-is-file|xdmp:uri-format|xdmp:uri-content-type|xdmp:unquote|xdmp:unpath|xdmp:triggers-database|xdmp:trace|xdmp:to-json|xdmp:tidy|xdmp:subbinary|xdmp:strftime|xdmp:spawn-in|xdmp:spawn|xdmp:sleep|xdmp:shutdown|xdmp:set-session-field|xdmp:set-response-encoding|xdmp:set-response-content-type|xdmp:set-response-code|xdmp:set-request-time-limit|xdmp:set|xdmp:servers|xdmp:server-status|xdmp:server-name|xdmp:server|xdmp:security-database|xdmp:security-assert|xdmp:schema-database|xdmp:save|xdmp:role-roles|xdmp:role|xdmp:rethrow|xdmp:restart|xdmp:request-timestamp|xdmp:request-status|xdmp:request-cancel|xdmp:request|xdmp:redirect-response|xdmp:random|xdmp:quote|xdmp:query-trace|xdmp:query-meters|xdmp:product-edition|xdmp:privilege-roles|xdmp:privilege|xdmp:pretty-print|xdmp:powerpoint-convert|xdmp:platform|xdmp:permission|xdmp:pdf-convert|xdmp:path|xdmp:octal-to-integer|xdmp:node-uri|xdmp:node-replace|xdmp:node-kind|xdmp:node-insert-child|xdmp:node-insert-before|xdmp:node-insert-after|xdmp:node-delete|xdmp:node-database|xdmp:mul64|xdmp:modules-root|xdmp:modules-database|xdmp:merging|xdmp:merge-cancel|xdmp:merge|xdmp:md5|xdmp:logout|xdmp:login|xdmp:log-level|xdmp:log|xdmp:lock-release|xdmp:lock-acquire|xdmp:load|xdmp:invoke-in|xdmp:invoke|xdmp:integer-to-octal|xdmp:integer-to-hex|xdmp:http-put|xdmp:http-post|xdmp:http-options|xdmp:http-head|xdmp:http-get|xdmp:http-delete|xdmp:hosts|xdmp:host-status|xdmp:host-name|xdmp:host|xdmp:hex-to-integer|xdmp:hash64|xdmp:hash32|xdmp:has-privilege|xdmp:groups|xdmp:group-serves|xdmp:group-servers|xdmp:group-name|xdmp:group-hosts|xdmp:group|xdmp:get-session-field-names|xdmp:get-session-field|xdmp:get-response-encoding|xdmp:get-response-code|xdmp:get-request-username|xdmp:get-request-user|xdmp:get-request-url|xdmp:get-request-protocol|xdmp:get-request-path|xdmp:get-request-method|xdmp:get-request-header-names|xdmp:get-request-header|xdmp:get-request-field-names|xdmp:get-request-field-filename|xdmp:get-request-field-content-type|xdmp:get-request-field|xdmp:get-request-client-certificate|xdmp:get-request-client-address|xdmp:get-request-body|xdmp:get-current-user|xdmp:get-current-roles|xdmp:get|xdmp:function-name|xdmp:function-module|xdmp:function|xdmp:from-json|xdmp:forests|xdmp:forest-status|xdmp:forest-restore|xdmp:forest-restart|xdmp:forest-name|xdmp:forest-delete|xdmp:forest-databases|xdmp:forest-counts|xdmp:forest-clear|xdmp:forest-backup|xdmp:forest|xdmp:filesystem-file|xdmp:filesystem-directory|xdmp:exists|xdmp:excel-convert|xdmp:eval-in|xdmp:eval|xdmp:estimate|xdmp:email|xdmp:element-content-type|xdmp:elapsed-time|xdmp:document-set-quality|xdmp:document-set-property|xdmp:document-set-properties|xdmp:document-set-permissions|xdmp:document-set-collections|xdmp:document-remove-properties|xdmp:document-remove-permissions|xdmp:document-remove-collections|xdmp:document-properties|xdmp:document-locks|xdmp:document-load|xdmp:document-insert|xdmp:document-get-quality|xdmp:document-get-properties|xdmp:document-get-permissions|xdmp:document-get-collections|xdmp:document-get|xdmp:document-forest|xdmp:document-delete|xdmp:document-add-properties|xdmp:document-add-permissions|xdmp:document-add-collections|xdmp:directory-properties|xdmp:directory-locks|xdmp:directory-delete|xdmp:directory-create|xdmp:directory|xdmp:diacritic-less|xdmp:describe|xdmp:default-permissions|xdmp:default-collections|xdmp:databases|xdmp:database-restore-validate|xdmp:database-restore-status|xdmp:database-restore-cancel|xdmp:database-restore|xdmp:database-name|xdmp:database-forests|xdmp:database-backup-validate|xdmp:database-backup-status|xdmp:database-backup-purge|xdmp:database-backup-cancel|xdmp:database-backup|xdmp:database|xdmp:collection-properties|xdmp:collection-locks|xdmp:collection-delete|xdmp:collation-canonical-uri|xdmp:castable-as|xdmp:can-grant-roles|xdmp:base64-encode|xdmp:base64-decode|xdmp:architecture|xdmp:apply|xdmp:amp-roles|xdmp:amp|xdmp:add64|xdmp:add-response-header|xdmp:access|trgr:trigger-set-recursive|trgr:trigger-set-permissions|trgr:trigger-set-name|trgr:trigger-set-module|trgr:trigger-set-event|trgr:trigger-set-description|trgr:trigger-remove-permissions|trgr:trigger-module|trgr:trigger-get-permissions|trgr:trigger-enable|trgr:trigger-disable|trgr:trigger-database-online-event|trgr:trigger-data-event|trgr:trigger-add-permissions|trgr:remove-trigger|trgr:property-content|trgr:pre-commit|trgr:post-commit|trgr:get-trigger-by-id|trgr:get-trigger|trgr:document-scope|trgr:document-content|trgr:directory-scope|trgr:create-trigger|trgr:collection-scope|trgr:any-property-content|thsr:set-entry|thsr:remove-term|thsr:remove-synonym|thsr:remove-entry|thsr:query-lookup|thsr:lookup|thsr:load|thsr:insert|thsr:expand|thsr:add-synonym|spell:suggest-detailed|spell:suggest|spell:remove-word|spell:make-dictionary|spell:load|spell:levenshtein-distance|spell:is-correct|spell:insert|spell:double-metaphone|spell:add-word|sec:users-collection|sec:user-set-roles|sec:user-set-password|sec:user-set-name|sec:user-set-description|sec:user-set-default-permissions|sec:user-set-default-collections|sec:user-remove-roles|sec:user-privileges|sec:user-get-roles|sec:user-get-description|sec:user-get-default-permissions|sec:user-get-default-collections|sec:user-doc-permissions|sec:user-doc-collections|sec:user-add-roles|sec:unprotect-collection|sec:uid-for-name|sec:set-realm|sec:security-version|sec:security-namespace|sec:security-installed|sec:security-collection|sec:roles-collection|sec:role-set-roles|sec:role-set-name|sec:role-set-description|sec:role-set-default-permissions|sec:role-set-default-collections|sec:role-remove-roles|sec:role-privileges|sec:role-get-roles|sec:role-get-description|sec:role-get-default-permissions|sec:role-get-default-collections|sec:role-doc-permissions|sec:role-doc-collections|sec:role-add-roles|sec:remove-user|sec:remove-role-from-users|sec:remove-role-from-role|sec:remove-role-from-privileges|sec:remove-role-from-amps|sec:remove-role|sec:remove-privilege|sec:remove-amp|sec:protect-collection|sec:privileges-collection|sec:privilege-set-roles|sec:privilege-set-name|sec:privilege-remove-roles|sec:privilege-get-roles|sec:privilege-add-roles|sec:priv-doc-permissions|sec:priv-doc-collections|sec:get-user-names|sec:get-unique-elem-id|sec:get-role-names|sec:get-role-ids|sec:get-privilege|sec:get-distinct-permissions|sec:get-collection|sec:get-amp|sec:create-user-with-role|sec:create-user|sec:create-role|sec:create-privilege|sec:create-amp|sec:collections-collection|sec:collection-set-permissions|sec:collection-remove-permissions|sec:collection-get-permissions|sec:collection-add-permissions|sec:check-admin|sec:amps-collection|sec:amp-set-roles|sec:amp-remove-roles|sec:amp-get-roles|sec:amp-doc-permissions|sec:amp-doc-collections|sec:amp-add-roles|search:unparse|search:suggest|search:snippet|search:search|search:resolve-nodes|search:resolve|search:remove-constraint|search:parse|search:get-default-options|search:estimate|search:check-options|prof:value|prof:reset|prof:report|prof:invoke|prof:eval|prof:enable|prof:disable|prof:allowed|ppt:clean|pki:template-set-request|pki:template-set-name|pki:template-set-key-type|pki:template-set-key-options|pki:template-set-description|pki:template-in-use|pki:template-get-version|pki:template-get-request|pki:template-get-name|pki:template-get-key-type|pki:template-get-key-options|pki:template-get-id|pki:template-get-description|pki:need-certificate|pki:is-temporary|pki:insert-trusted-certificates|pki:insert-template|pki:insert-signed-certificates|pki:insert-certificate-revocation-list|pki:get-trusted-certificate-ids|pki:get-template-ids|pki:get-template-certificate-authority|pki:get-template-by-name|pki:get-template|pki:get-pending-certificate-requests-xml|pki:get-pending-certificate-requests-pem|pki:get-pending-certificate-request|pki:get-certificates-for-template-xml|pki:get-certificates-for-template|pki:get-certificates|pki:get-certificate-xml|pki:get-certificate-pem|pki:get-certificate|pki:generate-temporary-certificate-if-necessary|pki:generate-temporary-certificate|pki:generate-template-certificate-authority|pki:generate-certificate-request|pki:delete-template|pki:delete-certificate|pki:create-template|pdf:make-toc|pdf:insert-toc-headers|pdf:get-toc|pdf:clean|p:status-transition|p:state-transition|p:remove|p:pipelines|p:insert|p:get-by-id|p:get|p:execute|p:create|p:condition|p:collection|p:action|ooxml:runs-merge|ooxml:package-uris|ooxml:package-parts-insert|ooxml:package-parts|msword:clean|mcgm:polygon|mcgm:point|mcgm:geospatial-query-from-elements|mcgm:geospatial-query|mcgm:circle|math:tanh|math:tan|math:sqrt|math:sinh|math:sin|math:pow|math:modf|math:log10|math:log|math:ldexp|math:frexp|math:fmod|math:floor|math:fabs|math:exp|math:cosh|math:cos|math:ceil|math:atan2|math:atan|math:asin|math:acos|map:put|map:map|map:keys|map:get|map:delete|map:count|map:clear|lnk:to|lnk:remove|lnk:insert|lnk:get|lnk:from|lnk:create|kml:polygon|kml:point|kml:interior-polygon|kml:geospatial-query-from-elements|kml:geospatial-query|kml:circle|kml:box|gml:polygon|gml:point|gml:interior-polygon|gml:geospatial-query-from-elements|gml:geospatial-query|gml:circle|gml:box|georss:point|georss:geospatial-query|georss:circle|geo:polygon|geo:point|geo:interior-polygon|geo:geospatial-query-from-elements|geo:geospatial-query|geo:circle|geo:box|fn:zero-or-one|fn:years-from-duration|fn:year-from-dateTime|fn:year-from-date|fn:upper-case|fn:unordered|fn:true|fn:translate|fn:trace|fn:tokenize|fn:timezone-from-time|fn:timezone-from-dateTime|fn:timezone-from-date|fn:sum|fn:subtract-dateTimes-yielding-yearMonthDuration|fn:subtract-dateTimes-yielding-dayTimeDuration|fn:substring-before|fn:substring-after|fn:substring|fn:subsequence|fn:string-to-codepoints|fn:string-pad|fn:string-length|fn:string-join|fn:string|fn:static-base-uri|fn:starts-with|fn:seconds-from-time|fn:seconds-from-duration|fn:seconds-from-dateTime|fn:round-half-to-even|fn:round|fn:root|fn:reverse|fn:resolve-uri|fn:resolve-QName|fn:replace|fn:remove|fn:QName|fn:prefix-from-QName|fn:position|fn:one-or-more|fn:number|fn:not|fn:normalize-unicode|fn:normalize-space|fn:node-name|fn:node-kind|fn:nilled|fn:namespace-uri-from-QName|fn:namespace-uri-for-prefix|fn:namespace-uri|fn:name|fn:months-from-duration|fn:month-from-dateTime|fn:month-from-date|fn:minutes-from-time|fn:minutes-from-duration|fn:minutes-from-dateTime|fn:min|fn:max|fn:matches|fn:lower-case|fn:local-name-from-QName|fn:local-name|fn:last|fn:lang|fn:iri-to-uri|fn:insert-before|fn:index-of|fn:in-scope-prefixes|fn:implicit-timezone|fn:idref|fn:id|fn:hours-from-time|fn:hours-from-duration|fn:hours-from-dateTime|fn:floor|fn:false|fn:expanded-QName|fn:exists|fn:exactly-one|fn:escape-uri|fn:escape-html-uri|fn:error|fn:ends-with|fn:encode-for-uri|fn:empty|fn:document-uri|fn:doc-available|fn:doc|fn:distinct-values|fn:distinct-nodes|fn:default-collation|fn:deep-equal|fn:days-from-duration|fn:day-from-dateTime|fn:day-from-date|fn:data|fn:current-time|fn:current-dateTime|fn:current-date|fn:count|fn:contains|fn:concat|fn:compare|fn:collection|fn:codepoints-to-string|fn:codepoint-equal|fn:ceiling|fn:boolean|fn:base-uri|fn:avg|fn:adjust-time-to-timezone|fn:adjust-dateTime-to-timezone|fn:adjust-date-to-timezone|fn:abs|feed:unsubscribe|feed:subscription|feed:subscribe|feed:request|feed:item|feed:description|excel:clean|entity:enrich|dom:set-pipelines|dom:set-permissions|dom:set-name|dom:set-evaluation-context|dom:set-domain-scope|dom:set-description|dom:remove-pipeline|dom:remove-permissions|dom:remove|dom:get|dom:evaluation-context|dom:domains|dom:domain-scope|dom:create|dom:configuration-set-restart-user|dom:configuration-set-permissions|dom:configuration-set-evaluation-context|dom:configuration-set-default-domain|dom:configuration-get|dom:configuration-create|dom:collection|dom:add-pipeline|dom:add-permissions|dls:retention-rules|dls:retention-rule-remove|dls:retention-rule-insert|dls:retention-rule|dls:purge|dls:node-expand|dls:link-references|dls:link-expand|dls:documents-query|dls:document-versions-query|dls:document-version-uri|dls:document-version-query|dls:document-version-delete|dls:document-version-as-of|dls:document-version|dls:document-update|dls:document-unmanage|dls:document-set-quality|dls:document-set-property|dls:document-set-properties|dls:document-set-permissions|dls:document-set-collections|dls:document-retention-rules|dls:document-remove-properties|dls:document-remove-permissions|dls:document-remove-collections|dls:document-purge|dls:document-manage|dls:document-is-managed|dls:document-insert-and-manage|dls:document-include-query|dls:document-history|dls:document-get-permissions|dls:document-extract-part|dls:document-delete|dls:document-checkout-status|dls:document-checkout|dls:document-checkin|dls:document-add-properties|dls:document-add-permissions|dls:document-add-collections|dls:break-checkout|dls:author-query|dls:as-of-query|dbk:convert|dbg:wait|dbg:value|dbg:stopped|dbg:stop|dbg:step|dbg:status|dbg:stack|dbg:out|dbg:next|dbg:line|dbg:invoke|dbg:function|dbg:finish|dbg:expr|dbg:eval|dbg:disconnect|dbg:detach|dbg:continue|dbg:connect|dbg:clear|dbg:breakpoints|dbg:break|dbg:attached|dbg:attach|cvt:save-converted-documents|cvt:part-uri|cvt:destination-uri|cvt:basepath|cvt:basename|cts:words|cts:word-query-weight|cts:word-query-text|cts:word-query-options|cts:word-query|cts:word-match|cts:walk|cts:uris|cts:uri-match|cts:train|cts:tokenize|cts:thresholds|cts:stem|cts:similar-query-weight|cts:similar-query-nodes|cts:similar-query|cts:shortest-distance|cts:search|cts:score|cts:reverse-query-weight|cts:reverse-query-nodes|cts:reverse-query|cts:remainder|cts:registered-query-weight|cts:registered-query-options|cts:registered-query-ids|cts:registered-query|cts:register|cts:query|cts:quality|cts:properties-query-query|cts:properties-query|cts:polygon-vertices|cts:polygon|cts:point-longitude|cts:point-latitude|cts:point|cts:or-query-queries|cts:or-query|cts:not-query-weight|cts:not-query-query|cts:not-query|cts:near-query-weight|cts:near-query-queries|cts:near-query-options|cts:near-query-distance|cts:near-query|cts:highlight|cts:geospatial-co-occurrences|cts:frequency|cts:fitness|cts:field-words|cts:field-word-query-weight|cts:field-word-query-text|cts:field-word-query-options|cts:field-word-query-field-name|cts:field-word-query|cts:field-word-match|cts:entity-highlight|cts:element-words|cts:element-word-query-weight|cts:element-word-query-text|cts:element-word-query-options|cts:element-word-query-element-name|cts:element-word-query|cts:element-word-match|cts:element-values|cts:element-value-ranges|cts:element-value-query-weight|cts:element-value-query-text|cts:element-value-query-options|cts:element-value-query-element-name|cts:element-value-query|cts:element-value-match|cts:element-value-geospatial-co-occurrences|cts:element-value-co-occurrences|cts:element-range-query-weight|cts:element-range-query-value|cts:element-range-query-options|cts:element-range-query-operator|cts:element-range-query-element-name|cts:element-range-query|cts:element-query-query|cts:element-query-element-name|cts:element-query|cts:element-pair-geospatial-values|cts:element-pair-geospatial-value-match|cts:element-pair-geospatial-query-weight|cts:element-pair-geospatial-query-region|cts:element-pair-geospatial-query-options|cts:element-pair-geospatial-query-longitude-name|cts:element-pair-geospatial-query-latitude-name|cts:element-pair-geospatial-query-element-name|cts:element-pair-geospatial-query|cts:element-pair-geospatial-boxes|cts:element-geospatial-values|cts:element-geospatial-value-match|cts:element-geospatial-query-weight|cts:element-geospatial-query-region|cts:element-geospatial-query-options|cts:element-geospatial-query-element-name|cts:element-geospatial-query|cts:element-geospatial-boxes|cts:element-child-geospatial-values|cts:element-child-geospatial-value-match|cts:element-child-geospatial-query-weight|cts:element-child-geospatial-query-region|cts:element-child-geospatial-query-options|cts:element-child-geospatial-query-element-name|cts:element-child-geospatial-query-child-name|cts:element-child-geospatial-query|cts:element-child-geospatial-boxes|cts:element-attribute-words|cts:element-attribute-word-query-weight|cts:element-attribute-word-query-text|cts:element-attribute-word-query-options|cts:element-attribute-word-query-element-name|cts:element-attribute-word-query-attribute-name|cts:element-attribute-word-query|cts:element-attribute-word-match|cts:element-attribute-values|cts:element-attribute-value-ranges|cts:element-attribute-value-query-weight|cts:element-attribute-value-query-text|cts:element-attribute-value-query-options|cts:element-attribute-value-query-element-name|cts:element-attribute-value-query-attribute-name|cts:element-attribute-value-query|cts:element-attribute-value-match|cts:element-attribute-value-geospatial-co-occurrences|cts:element-attribute-value-co-occurrences|cts:element-attribute-range-query-weight|cts:element-attribute-range-query-value|cts:element-attribute-range-query-options|cts:element-attribute-range-query-operator|cts:element-attribute-range-query-element-name|cts:element-attribute-range-query-attribute-name|cts:element-attribute-range-query|cts:element-attribute-pair-geospatial-values|cts:element-attribute-pair-geospatial-value-match|cts:element-attribute-pair-geospatial-query-weight|cts:element-attribute-pair-geospatial-query-region|cts:element-attribute-pair-geospatial-query-options|cts:element-attribute-pair-geospatial-query-longitude-name|cts:element-attribute-pair-geospatial-query-latitude-name|cts:element-attribute-pair-geospatial-query-element-name|cts:element-attribute-pair-geospatial-query|cts:element-attribute-pair-geospatial-boxes|cts:document-query-uris|cts:document-query|cts:distance|cts:directory-query-uris|cts:directory-query-depth|cts:directory-query|cts:destination|cts:deregister|cts:contains|cts:confidence|cts:collections|cts:collection-query-uris|cts:collection-query|cts:collection-match|cts:classify|cts:circle-radius|cts:circle-center|cts:circle|cts:box-west|cts:box-south|cts:box-north|cts:box-east|cts:box|cts:bearing|cts:arc-intersection|cts:and-query-queries|cts:and-query-options|cts:and-query|cts:and-not-query-positive-query|cts:and-not-query-negative-query|cts:and-not-query|css:get|css:convert|cpf:success|cpf:failure|cpf:document-set-state|cpf:document-set-processing-status|cpf:document-set-last-updated|cpf:document-set-error|cpf:document-get-state|cpf:document-get-processing-status|cpf:document-get-last-updated|cpf:document-get-error|cpf:check-transition|alert:spawn-matching-actions|alert:rule-user-id-query|alert:rule-set-user-id|alert:rule-set-query|alert:rule-set-options|alert:rule-set-name|alert:rule-set-description|alert:rule-set-action|alert:rule-remove|alert:rule-name-query|alert:rule-insert|alert:rule-id-query|alert:rule-get-user-id|alert:rule-get-query|alert:rule-get-options|alert:rule-get-name|alert:rule-get-id|alert:rule-get-description|alert:rule-get-action|alert:rule-action-query|alert:remove-triggers|alert:make-rule|alert:make-log-action|alert:make-config|alert:make-action|alert:invoke-matching-actions|alert:get-my-rules|alert:get-all-rules|alert:get-actions|alert:find-matching-rules|alert:create-triggers|alert:config-set-uri|alert:config-set-trigger-ids|alert:config-set-options|alert:config-set-name|alert:config-set-description|alert:config-set-cpf-domain-names|alert:config-set-cpf-domain-ids|alert:config-insert|alert:config-get-uri|alert:config-get-trigger-ids|alert:config-get-options|alert:config-get-name|alert:config-get-id|alert:config-get-description|alert:config-get-cpf-domain-names|alert:config-get-cpf-domain-ids|alert:config-get|alert:config-delete|alert:action-set-options|alert:action-set-name|alert:action-set-module-root|alert:action-set-module-db|alert:action-set-module|alert:action-set-description|alert:action-remove|alert:action-insert|alert:action-get-options|alert:action-get-name|alert:action-get-module-root|alert:action-get-module-db|alert:action-get-module|alert:action-get-description|zero-or-one|years-from-duration|year-from-dateTime|year-from-date|upper-case|unordered|true|translate|trace|tokenize|timezone-from-time|timezone-from-dateTime|timezone-from-date|sum|subtract-dateTimes-yielding-yearMonthDuration|subtract-dateTimes-yielding-dayTimeDuration|substring-before|substring-after|substring|subsequence|string-to-codepoints|string-pad|string-length|string-join|string|static-base-uri|starts-with|seconds-from-time|seconds-from-duration|seconds-from-dateTime|round-half-to-even|round|root|reverse|resolve-uri|resolve-QName|replace|remove|QName|prefix-from-QName|position|one-or-more|number|not|normalize-unicode|normalize-space|node-name|node-kind|nilled|namespace-uri-from-QName|namespace-uri-for-prefix|namespace-uri|name|months-from-duration|month-from-dateTime|month-from-date|minutes-from-time|minutes-from-duration|minutes-from-dateTime|min|max|matches|lower-case|local-name-from-QName|local-name|last|lang|iri-to-uri|insert-before|index-of|in-scope-prefixes|implicit-timezone|idref|id|hours-from-time|hours-from-duration|hours-from-dateTime|floor|false|expanded-QName|exists|exactly-one|escape-uri|escape-html-uri|error|ends-with|encode-for-uri|empty|document-uri|doc-available|doc|distinct-values|distinct-nodes|default-collation|deep-equal|days-from-duration|day-from-dateTime|day-from-date|data|current-time|current-dateTime|current-date|count|contains|concat|compare|collection|codepoints-to-string|codepoint-equal|ceiling|boolean|base-uri|avg|adjust-time-to-timezone|adjust-dateTime-to-timezone|adjust-date-to-timezone|abs)\b/],
+["pln",/^[\w:-]+/],["pln",/^[\t\n\r \xa0]+/]]),["xq","xquery"]);
diff --git a/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/lang-yaml.js b/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/lang-yaml.js
new file mode 100644
index 0000000..c38729b
--- /dev/null
+++ b/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/lang-yaml.js
@@ -0,0 +1,2 @@
+var a=null;
+PR.registerLangHandler(PR.createSimpleLexer([["pun",/^[:>?|]+/,a,":|>?"],["dec",/^%(?:YAML|TAG)[^\n\r#]+/,a,"%"],["typ",/^&\S+/,a,"&"],["typ",/^!\S*/,a,"!"],["str",/^"(?:[^"\\]|\\.)*(?:"|$)/,a,'"'],["str",/^'(?:[^']|'')*(?:'|$)/,a,"'"],["com",/^#[^\n\r]*/,a,"#"],["pln",/^\s+/,a," \t\r\n"]],[["dec",/^(?:---|\.\.\.)(?:[\n\r]|$)/],["pun",/^-/],["kwd",/^\w+:[\n\r ]/],["pln",/^\w+/]]),["yaml","yml"]);
diff --git a/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/prettify.css b/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/prettify.css
new file mode 100644
index 0000000..d44b3a2
--- /dev/null
+++ b/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/prettify.css
@@ -0,0 +1 @@
+.pln{color:#000}@media screen{.str{color:#080}.kwd{color:#008}.com{color:#800}.typ{color:#606}.lit{color:#066}.pun,.opn,.clo{color:#660}.tag{color:#008}.atn{color:#606}.atv{color:#080}.dec,.var{color:#606}.fun{color:red}}@media print,projection{.str{color:#060}.kwd{color:#006;font-weight:bold}.com{color:#600;font-style:italic}.typ{color:#404;font-weight:bold}.lit{color:#044}.pun,.opn,.clo{color:#440}.tag{color:#006;font-weight:bold}.atn{color:#404}.atv{color:#060}}pre.prettyprint{padding:2px;border:1px solid #888}ol.linenums{margin-top:0;margin-bottom:0}li.L0,li.L1,li.L2,li.L3,li.L5,li.L6,li.L7,li.L8{list-style-type:none}li.L1,li.L3,li.L5,li.L7,li.L9{background:#eee}
\ No newline at end of file
diff --git a/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/prettify.js b/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/prettify.js
new file mode 100644
index 0000000..eef5ad7
--- /dev/null
+++ b/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/prettify.js
@@ -0,0 +1,28 @@
+var q=null;window.PR_SHOULD_USE_CONTINUATION=!0;
+(function(){function L(a){function m(a){var f=a.charCodeAt(0);if(f!==92)return f;var b=a.charAt(1);return(f=r[b])?f:"0"<=b&&b<="7"?parseInt(a.substring(1),8):b==="u"||b==="x"?parseInt(a.substring(2),16):a.charCodeAt(1)}function e(a){if(a<32)return(a<16?"\\x0":"\\x")+a.toString(16);a=String.fromCharCode(a);if(a==="\\"||a==="-"||a==="["||a==="]")a="\\"+a;return a}function h(a){for(var f=a.substring(1,a.length-1).match(/\\u[\dA-Fa-f]{4}|\\x[\dA-Fa-f]{2}|\\[0-3][0-7]{0,2}|\\[0-7]{1,2}|\\[\S\s]|[^\\]/g),a=
+[],b=[],o=f[0]==="^",c=o?1:0,i=f.length;c<i;++c){var j=f[c];if(/\\[bdsw]/i.test(j))a.push(j);else{var j=m(j),d;c+2<i&&"-"===f[c+1]?(d=m(f[c+2]),c+=2):d=j;b.push([j,d]);d<65||j>122||(d<65||j>90||b.push([Math.max(65,j)|32,Math.min(d,90)|32]),d<97||j>122||b.push([Math.max(97,j)&-33,Math.min(d,122)&-33]))}}b.sort(function(a,f){return a[0]-f[0]||f[1]-a[1]});f=[];j=[NaN,NaN];for(c=0;c<b.length;++c)i=b[c],i[0]<=j[1]+1?j[1]=Math.max(j[1],i[1]):f.push(j=i);b=["["];o&&b.push("^");b.push.apply(b,a);for(c=0;c<
+f.length;++c)i=f[c],b.push(e(i[0])),i[1]>i[0]&&(i[1]+1>i[0]&&b.push("-"),b.push(e(i[1])));b.push("]");return b.join("")}function y(a){for(var f=a.source.match(/\[(?:[^\\\]]|\\[\S\s])*]|\\u[\dA-Fa-f]{4}|\\x[\dA-Fa-f]{2}|\\\d+|\\[^\dux]|\(\?[!:=]|[()^]|[^()[\\^]+/g),b=f.length,d=[],c=0,i=0;c<b;++c){var j=f[c];j==="("?++i:"\\"===j.charAt(0)&&(j=+j.substring(1))&&j<=i&&(d[j]=-1)}for(c=1;c<d.length;++c)-1===d[c]&&(d[c]=++t);for(i=c=0;c<b;++c)j=f[c],j==="("?(++i,d[i]===void 0&&(f[c]="(?:")):"\\"===j.charAt(0)&&
+(j=+j.substring(1))&&j<=i&&(f[c]="\\"+d[i]);for(i=c=0;c<b;++c)"^"===f[c]&&"^"!==f[c+1]&&(f[c]="");if(a.ignoreCase&&s)for(c=0;c<b;++c)j=f[c],a=j.charAt(0),j.length>=2&&a==="["?f[c]=h(j):a!=="\\"&&(f[c]=j.replace(/[A-Za-z]/g,function(a){a=a.charCodeAt(0);return"["+String.fromCharCode(a&-33,a|32)+"]"}));return f.join("")}for(var t=0,s=!1,l=!1,p=0,d=a.length;p<d;++p){var g=a[p];if(g.ignoreCase)l=!0;else if(/[a-z]/i.test(g.source.replace(/\\u[\da-f]{4}|\\x[\da-f]{2}|\\[^UXux]/gi,""))){s=!0;l=!1;break}}for(var r=
+{b:8,t:9,n:10,v:11,f:12,r:13},n=[],p=0,d=a.length;p<d;++p){g=a[p];if(g.global||g.multiline)throw Error(""+g);n.push("(?:"+y(g)+")")}return RegExp(n.join("|"),l?"gi":"g")}function M(a){function m(a){switch(a.nodeType){case 1:if(e.test(a.className))break;for(var g=a.firstChild;g;g=g.nextSibling)m(g);g=a.nodeName;if("BR"===g||"LI"===g)h[s]="\n",t[s<<1]=y++,t[s++<<1|1]=a;break;case 3:case 4:g=a.nodeValue,g.length&&(g=p?g.replace(/\r\n?/g,"\n"):g.replace(/[\t\n\r ]+/g," "),h[s]=g,t[s<<1]=y,y+=g.length,
+t[s++<<1|1]=a)}}var e=/(?:^|\s)nocode(?:\s|$)/,h=[],y=0,t=[],s=0,l;a.currentStyle?l=a.currentStyle.whiteSpace:window.getComputedStyle&&(l=document.defaultView.getComputedStyle(a,q).getPropertyValue("white-space"));var p=l&&"pre"===l.substring(0,3);m(a);return{a:h.join("").replace(/\n$/,""),c:t}}function B(a,m,e,h){m&&(a={a:m,d:a},e(a),h.push.apply(h,a.e))}function x(a,m){function e(a){for(var l=a.d,p=[l,"pln"],d=0,g=a.a.match(y)||[],r={},n=0,z=g.length;n<z;++n){var f=g[n],b=r[f],o=void 0,c;if(typeof b===
+"string")c=!1;else{var i=h[f.charAt(0)];if(i)o=f.match(i[1]),b=i[0];else{for(c=0;c<t;++c)if(i=m[c],o=f.match(i[1])){b=i[0];break}o||(b="pln")}if((c=b.length>=5&&"lang-"===b.substring(0,5))&&!(o&&typeof o[1]==="string"))c=!1,b="src";c||(r[f]=b)}i=d;d+=f.length;if(c){c=o[1];var j=f.indexOf(c),k=j+c.length;o[2]&&(k=f.length-o[2].length,j=k-c.length);b=b.substring(5);B(l+i,f.substring(0,j),e,p);B(l+i+j,c,C(b,c),p);B(l+i+k,f.substring(k),e,p)}else p.push(l+i,b)}a.e=p}var h={},y;(function(){for(var e=a.concat(m),
+l=[],p={},d=0,g=e.length;d<g;++d){var r=e[d],n=r[3];if(n)for(var k=n.length;--k>=0;)h[n.charAt(k)]=r;r=r[1];n=""+r;p.hasOwnProperty(n)||(l.push(r),p[n]=q)}l.push(/[\S\s]/);y=L(l)})();var t=m.length;return e}function u(a){var m=[],e=[];a.tripleQuotedStrings?m.push(["str",/^(?:'''(?:[^'\\]|\\[\S\s]|''?(?=[^']))*(?:'''|$)|"""(?:[^"\\]|\\[\S\s]|""?(?=[^"]))*(?:"""|$)|'(?:[^'\\]|\\[\S\s])*(?:'|$)|"(?:[^"\\]|\\[\S\s])*(?:"|$))/,q,"'\""]):a.multiLineStrings?m.push(["str",/^(?:'(?:[^'\\]|\\[\S\s])*(?:'|$)|"(?:[^"\\]|\\[\S\s])*(?:"|$)|`(?:[^\\`]|\\[\S\s])*(?:`|$))/,
+q,"'\"`"]):m.push(["str",/^(?:'(?:[^\n\r'\\]|\\.)*(?:'|$)|"(?:[^\n\r"\\]|\\.)*(?:"|$))/,q,"\"'"]);a.verbatimStrings&&e.push(["str",/^@"(?:[^"]|"")*(?:"|$)/,q]);var h=a.hashComments;h&&(a.cStyleComments?(h>1?m.push(["com",/^#(?:##(?:[^#]|#(?!##))*(?:###|$)|.*)/,q,"#"]):m.push(["com",/^#(?:(?:define|elif|else|endif|error|ifdef|include|ifndef|line|pragma|undef|warning)\b|[^\n\r]*)/,q,"#"]),e.push(["str",/^<(?:(?:(?:\.\.\/)*|\/?)(?:[\w-]+(?:\/[\w-]+)+)?[\w-]+\.h|[a-z]\w*)>/,q])):m.push(["com",/^#[^\n\r]*/,
+q,"#"]));a.cStyleComments&&(e.push(["com",/^\/\/[^\n\r]*/,q]),e.push(["com",/^\/\*[\S\s]*?(?:\*\/|$)/,q]));a.regexLiterals&&e.push(["lang-regex",/^(?:^^\.?|[!+-]|!=|!==|#|%|%=|&|&&|&&=|&=|\(|\*|\*=|\+=|,|-=|->|\/|\/=|:|::|;|<|<<|<<=|<=|=|==|===|>|>=|>>|>>=|>>>|>>>=|[?@[^]|\^=|\^\^|\^\^=|{|\||\|=|\|\||\|\|=|~|break|case|continue|delete|do|else|finally|instanceof|return|throw|try|typeof)\s*(\/(?=[^*/])(?:[^/[\\]|\\[\S\s]|\[(?:[^\\\]]|\\[\S\s])*(?:]|$))+\/)/]);(h=a.types)&&e.push(["typ",h]);a=(""+a.keywords).replace(/^ | $/g,
+"");a.length&&e.push(["kwd",RegExp("^(?:"+a.replace(/[\s,]+/g,"|")+")\\b"),q]);m.push(["pln",/^\s+/,q," \r\n\t\xa0"]);e.push(["lit",/^@[$_a-z][\w$@]*/i,q],["typ",/^(?:[@_]?[A-Z]+[a-z][\w$@]*|\w+_t\b)/,q],["pln",/^[$_a-z][\w$@]*/i,q],["lit",/^(?:0x[\da-f]+|(?:\d(?:_\d+)*\d*(?:\.\d*)?|\.\d\+)(?:e[+-]?\d+)?)[a-z]*/i,q,"0123456789"],["pln",/^\\[\S\s]?/,q],["pun",/^.[^\s\w"-$'./@\\`]*/,q]);return x(m,e)}function D(a,m){function e(a){switch(a.nodeType){case 1:if(k.test(a.className))break;if("BR"===a.nodeName)h(a),
+a.parentNode&&a.parentNode.removeChild(a);else for(a=a.firstChild;a;a=a.nextSibling)e(a);break;case 3:case 4:if(p){var b=a.nodeValue,d=b.match(t);if(d){var c=b.substring(0,d.index);a.nodeValue=c;(b=b.substring(d.index+d[0].length))&&a.parentNode.insertBefore(s.createTextNode(b),a.nextSibling);h(a);c||a.parentNode.removeChild(a)}}}}function h(a){function b(a,d){var e=d?a.cloneNode(!1):a,f=a.parentNode;if(f){var f=b(f,1),g=a.nextSibling;f.appendChild(e);for(var h=g;h;h=g)g=h.nextSibling,f.appendChild(h)}return e}
+for(;!a.nextSibling;)if(a=a.parentNode,!a)return;for(var a=b(a.nextSibling,0),e;(e=a.parentNode)&&e.nodeType===1;)a=e;d.push(a)}var k=/(?:^|\s)nocode(?:\s|$)/,t=/\r\n?|\n/,s=a.ownerDocument,l;a.currentStyle?l=a.currentStyle.whiteSpace:window.getComputedStyle&&(l=s.defaultView.getComputedStyle(a,q).getPropertyValue("white-space"));var p=l&&"pre"===l.substring(0,3);for(l=s.createElement("LI");a.firstChild;)l.appendChild(a.firstChild);for(var d=[l],g=0;g<d.length;++g)e(d[g]);m===(m|0)&&d[0].setAttribute("value",
+m);var r=s.createElement("OL");r.className="linenums";for(var n=Math.max(0,m-1|0)||0,g=0,z=d.length;g<z;++g)l=d[g],l.className="L"+(g+n)%10,l.firstChild||l.appendChild(s.createTextNode("\xa0")),r.appendChild(l);a.appendChild(r)}function k(a,m){for(var e=m.length;--e>=0;){var h=m[e];A.hasOwnProperty(h)?window.console&&console.warn("cannot override language handler %s",h):A[h]=a}}function C(a,m){if(!a||!A.hasOwnProperty(a))a=/^\s*</.test(m)?"default-markup":"default-code";return A[a]}function E(a){var m=
+a.g;try{var e=M(a.h),h=e.a;a.a=h;a.c=e.c;a.d=0;C(m,h)(a);var k=/\bMSIE\b/.test(navigator.userAgent),m=/\n/g,t=a.a,s=t.length,e=0,l=a.c,p=l.length,h=0,d=a.e,g=d.length,a=0;d[g]=s;var r,n;for(n=r=0;n<g;)d[n]!==d[n+2]?(d[r++]=d[n++],d[r++]=d[n++]):n+=2;g=r;for(n=r=0;n<g;){for(var z=d[n],f=d[n+1],b=n+2;b+2<=g&&d[b+1]===f;)b+=2;d[r++]=z;d[r++]=f;n=b}for(d.length=r;h<p;){var o=l[h+2]||s,c=d[a+2]||s,b=Math.min(o,c),i=l[h+1],j;if(i.nodeType!==1&&(j=t.substring(e,b))){k&&(j=j.replace(m,"\r"));i.nodeValue=
+j;var u=i.ownerDocument,v=u.createElement("SPAN");v.className=d[a+1];var x=i.parentNode;x.replaceChild(v,i);v.appendChild(i);e<o&&(l[h+1]=i=u.createTextNode(t.substring(b,o)),x.insertBefore(i,v.nextSibling))}e=b;e>=o&&(h+=2);e>=c&&(a+=2)}}catch(w){"console"in window&&console.log(w&&w.stack?w.stack:w)}}var v=["break,continue,do,else,for,if,return,while"],w=[[v,"auto,case,char,const,default,double,enum,extern,float,goto,int,long,register,short,signed,sizeof,static,struct,switch,typedef,union,unsigned,void,volatile"],
+"catch,class,delete,false,import,new,operator,private,protected,public,this,throw,true,try,typeof"],F=[w,"alignof,align_union,asm,axiom,bool,concept,concept_map,const_cast,constexpr,decltype,dynamic_cast,explicit,export,friend,inline,late_check,mutable,namespace,nullptr,reinterpret_cast,static_assert,static_cast,template,typeid,typename,using,virtual,where"],G=[w,"abstract,boolean,byte,extends,final,finally,implements,import,instanceof,null,native,package,strictfp,super,synchronized,throws,transient"],
+H=[G,"as,base,by,checked,decimal,delegate,descending,dynamic,event,fixed,foreach,from,group,implicit,in,interface,internal,into,is,lock,object,out,override,orderby,params,partial,readonly,ref,sbyte,sealed,stackalloc,string,select,uint,ulong,unchecked,unsafe,ushort,var"],w=[w,"debugger,eval,export,function,get,null,set,undefined,var,with,Infinity,NaN"],I=[v,"and,as,assert,class,def,del,elif,except,exec,finally,from,global,import,in,is,lambda,nonlocal,not,or,pass,print,raise,try,with,yield,False,True,None"],
+J=[v,"alias,and,begin,case,class,def,defined,elsif,end,ensure,false,in,module,next,nil,not,or,redo,rescue,retry,self,super,then,true,undef,unless,until,when,yield,BEGIN,END"],v=[v,"case,done,elif,esac,eval,fi,function,in,local,set,then,until"],K=/^(DIR|FILE|vector|(de|priority_)?queue|list|stack|(const_)?iterator|(multi)?(set|map)|bitset|u?(int|float)\d*)/,N=/\S/,O=u({keywords:[F,H,w,"caller,delete,die,do,dump,elsif,eval,exit,foreach,for,goto,if,import,last,local,my,next,no,our,print,package,redo,require,sub,undef,unless,until,use,wantarray,while,BEGIN,END"+
+I,J,v],hashComments:!0,cStyleComments:!0,multiLineStrings:!0,regexLiterals:!0}),A={};k(O,["default-code"]);k(x([],[["pln",/^[^<?]+/],["dec",/^<!\w[^>]*(?:>|$)/],["com",/^<\!--[\S\s]*?(?:--\>|$)/],["lang-",/^<\?([\S\s]+?)(?:\?>|$)/],["lang-",/^<%([\S\s]+?)(?:%>|$)/],["pun",/^(?:<[%?]|[%?]>)/],["lang-",/^<xmp\b[^>]*>([\S\s]+?)<\/xmp\b[^>]*>/i],["lang-js",/^<script\b[^>]*>([\S\s]*?)(<\/script\b[^>]*>)/i],["lang-css",/^<style\b[^>]*>([\S\s]*?)(<\/style\b[^>]*>)/i],["lang-in.tag",/^(<\/?[a-z][^<>]*>)/i]]),
+["default-markup","htm","html","mxml","xhtml","xml","xsl"]);k(x([["pln",/^\s+/,q," \t\r\n"],["atv",/^(?:"[^"]*"?|'[^']*'?)/,q,"\"'"]],[["tag",/^^<\/?[a-z](?:[\w-.:]*\w)?|\/?>$/i],["atn",/^(?!style[\s=]|on)[a-z](?:[\w:-]*\w)?/i],["lang-uq.val",/^=\s*([^\s"'>]*(?:[^\s"'/>]|\/(?=\s)))/],["pun",/^[/<->]+/],["lang-js",/^on\w+\s*=\s*"([^"]+)"/i],["lang-js",/^on\w+\s*=\s*'([^']+)'/i],["lang-js",/^on\w+\s*=\s*([^\s"'>]+)/i],["lang-css",/^style\s*=\s*"([^"]+)"/i],["lang-css",/^style\s*=\s*'([^']+)'/i],["lang-css",
+/^style\s*=\s*([^\s"'>]+)/i]]),["in.tag"]);k(x([],[["atv",/^[\S\s]+/]]),["uq.val"]);k(u({keywords:F,hashComments:!0,cStyleComments:!0,types:K}),["c","cc","cpp","cxx","cyc","m"]);k(u({keywords:"null,true,false"}),["json"]);k(u({keywords:H,hashComments:!0,cStyleComments:!0,verbatimStrings:!0,types:K}),["cs"]);k(u({keywords:G,cStyleComments:!0}),["java"]);k(u({keywords:v,hashComments:!0,multiLineStrings:!0}),["bsh","csh","sh"]);k(u({keywords:I,hashComments:!0,multiLineStrings:!0,tripleQuotedStrings:!0}),
+["cv","py"]);k(u({keywords:"caller,delete,die,do,dump,elsif,eval,exit,foreach,for,goto,if,import,last,local,my,next,no,our,print,package,redo,require,sub,undef,unless,until,use,wantarray,while,BEGIN,END",hashComments:!0,multiLineStrings:!0,regexLiterals:!0}),["perl","pl","pm"]);k(u({keywords:J,hashComments:!0,multiLineStrings:!0,regexLiterals:!0}),["rb"]);k(u({keywords:w,cStyleComments:!0,regexLiterals:!0}),["js"]);k(u({keywords:"all,and,by,catch,class,else,extends,false,finally,for,if,in,is,isnt,loop,new,no,not,null,of,off,on,or,return,super,then,true,try,unless,until,when,while,yes",
+hashComments:3,cStyleComments:!0,multilineStrings:!0,tripleQuotedStrings:!0,regexLiterals:!0}),["coffee"]);k(x([],[["str",/^[\S\s]+/]]),["regex"]);window.prettyPrintOne=function(a,m,e){var h=document.createElement("PRE");h.innerHTML=a;e&&D(h,e);E({g:m,i:e,h:h});return h.innerHTML};window.prettyPrint=function(a){function m(){for(var e=window.PR_SHOULD_USE_CONTINUATION?l.now()+250:Infinity;p<h.length&&l.now()<e;p++){var n=h[p],k=n.className;if(k.indexOf("prettyprint")>=0){var k=k.match(g),f,b;if(b=
+!k){b=n;for(var o=void 0,c=b.firstChild;c;c=c.nextSibling)var i=c.nodeType,o=i===1?o?b:c:i===3?N.test(c.nodeValue)?b:o:o;b=(f=o===b?void 0:o)&&"CODE"===f.tagName}b&&(k=f.className.match(g));k&&(k=k[1]);b=!1;for(o=n.parentNode;o;o=o.parentNode)if((o.tagName==="pre"||o.tagName==="code"||o.tagName==="xmp")&&o.className&&o.className.indexOf("prettyprint")>=0){b=!0;break}b||((b=(b=n.className.match(/\blinenums\b(?::(\d+))?/))?b[1]&&b[1].length?+b[1]:!0:!1)&&D(n,b),d={g:k,h:n,i:b},E(d))}}p<h.length?setTimeout(m,
+250):a&&a()}for(var e=[document.getElementsByTagName("pre"),document.getElementsByTagName("code"),document.getElementsByTagName("xmp")],h=[],k=0;k<e.length;++k)for(var t=0,s=e[k].length;t<s;++t)h.push(e[k][t]);var e=q,l=Date;l.now||(l={now:function(){return+new Date}});var p=0,d,g=/\blang(?:uage)?-([\w.]+)(?!\S)/;m()};window.PR={createSimpleLexer:x,registerLangHandler:k,sourceDecorator:u,PR_ATTRIB_NAME:"atn",PR_ATTRIB_VALUE:"atv",PR_COMMENT:"com",PR_DECLARATION:"dec",PR_KEYWORD:"kwd",PR_LITERAL:"lit",
+PR_NOCODE:"nocode",PR_PLAIN:"pln",PR_PUNCTUATION:"pun",PR_SOURCE:"src",PR_STRING:"str",PR_TAG:"tag",PR_TYPE:"typ"}})();
diff --git a/gitiles-servlet/src/main/resources/com/google/gitiles/templates/Common.soy b/gitiles-servlet/src/main/resources/com/google/gitiles/templates/Common.soy
new file mode 100644
index 0000000..4297d31
--- /dev/null
+++ b/gitiles-servlet/src/main/resources/com/google/gitiles/templates/Common.soy
@@ -0,0 +1,95 @@
+// 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.
+{namespace gitiles autoescape="contextual"}
+
+/**
+ * Common header for Gitiles.
+ *
+ * @param title title for this page. Always suffixed with repository name and a
+ *     sitewide title.
+ * @param? repositoryName repository name for this page, if applicable.
+ * @param? menuEntries optional list of menu entries with "text" and optional
+ *     "url" keys.
+ * @param breadcrumbs navigation breadcrumbs for this page.
+ * @param? css optional list of CSS URLs to include.
+ * @param? js optional list of Javascript URLs to include.
+ * @param? onLoad optional Javascript to execute in the body's onLoad handler.
+ *     Warning: not autoescaped.
+ */
+{template .header}
+<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">
+<html>
+<head>
+  <title>
+    {$title}
+    {if $repositoryName}
+      {sp}- {$repositoryName}
+    {/if}
+    {sp}- {msg desc="name of the application"}Git at Google{/msg}
+  </title>
+  <link rel="stylesheet" type="text/css" href="//www.google.com/css/go.css" />
+
+  {if $css and length($css)}
+    {foreach $url in $css}
+      <link rel="stylesheet" type="text/css" href="{$url}" />
+    {/foreach}
+  {/if}
+  // Include default CSS after custom CSS so it can override defaults in third-
+  // party stylesheets (e.g. prettify).
+  <link rel="stylesheet" type="text/css" href="{gitiles.CSS_URL}" />
+
+  {if $js and length($js)}
+    {foreach $url in $js}
+      <script src="{$url}" type="text/javascript"></script>
+    {/foreach}
+  {/if}
+</head>
+<body {if $onLoad}onload="{$onLoad|id}"{/if}>
+  {call .customHeader /}
+
+  {if $menuEntries and length($menuEntries)}
+    <div class="menu">
+    {foreach $entry in $menuEntries}
+      {sp}
+      {if $entry.url}
+        <a href="{$entry.url}"{if not isLast($entry)} class="entry"{/if}>{$entry.text}</a>
+      {else}
+        <span{if not isLast($entry)} class="entry"{/if}>{$entry.text}</span>
+      {/if}
+    {/foreach}
+    {sp}
+    </div>
+  {/if}
+
+  {if $breadcrumbs and length($breadcrumbs)}
+    <div class="breadcrumbs">
+      {foreach $entry in $breadcrumbs}
+        {if not isFirst($entry)}{sp}/{sp}{/if}
+        {if not isLast($entry)}
+          <a href="{$entry.url}">{$entry.text}</a>
+        {else}
+          {$entry.text}
+        {/if}
+      {/foreach}
+    </div>
+  {/if}
+{/template}
+
+/**
+ * Standard footer.
+ */
+{template .footer}
+</body>
+</html>
+{/template}
diff --git a/gitiles-servlet/src/main/resources/com/google/gitiles/templates/DefaultCustomTemplates.soy b/gitiles-servlet/src/main/resources/com/google/gitiles/templates/DefaultCustomTemplates.soy
new file mode 100644
index 0000000..a1f5d9e
--- /dev/null
+++ b/gitiles-servlet/src/main/resources/com/google/gitiles/templates/DefaultCustomTemplates.soy
@@ -0,0 +1,21 @@
+// 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.
+{namespace gitiles autoescape="contextual"}
+
+/**
+ * Default custom header implementation for Gitiles.
+ */
+{template .customHeader}
+<h1>{msg desc="short name of the application"}Gitiles{/msg}</h1>
+{/template}
diff --git a/gitiles-servlet/src/main/resources/com/google/gitiles/templates/DiffDetail.soy b/gitiles-servlet/src/main/resources/com/google/gitiles/templates/DiffDetail.soy
new file mode 100644
index 0000000..dfd8af2
--- /dev/null
+++ b/gitiles-servlet/src/main/resources/com/google/gitiles/templates/DiffDetail.soy
@@ -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.
+{namespace gitiles autoescape="contextual"}
+
+/**
+ * Detail page showing diffs for a single commit.
+ *
+ * @param title human-readable revision name.
+ * @param repositoryName name of this repository.
+ * @param? menuEntries menu entries.
+ * @param breadcrumbs breadcrumbs for this page.
+ * @param? commit optional commit for which diffs are displayed, with keys
+ *     corresponding to the gitiles.commitDetail template (minus "diffTree").
+ */
+{template .diffDetail}
+{call .header data="all" /}
+
+{if $commit}
+  {call .commitDetail data="$commit" /}
+{/if}
+<div id="DIFF_OUTPUT_BLOCK" />
+
+{call .footer /}
+{/template}
+
+/**
+ * File header for a single unified diff patch.
+ *
+ * @param first the first line of the header, with no trailing LF.
+ * @param rest remaining lines of the header, if any.
+ * @param fileIndex position of the file within the difference.
+ */
+{template .diffHeader}
+<pre class="diff-header">
+<a name="F{$fileIndex}" class="diff-git">{$first}</a>{\n}
+{$rest}
+</pre>
+{/template}
diff --git a/gitiles-servlet/src/main/resources/com/google/gitiles/templates/HostIndex.soy b/gitiles-servlet/src/main/resources/com/google/gitiles/templates/HostIndex.soy
new file mode 100644
index 0000000..1ee9616
--- /dev/null
+++ b/gitiles-servlet/src/main/resources/com/google/gitiles/templates/HostIndex.soy
@@ -0,0 +1,77 @@
+// 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.
+{namespace gitiles autoescape="contextual"}
+
+/**
+ * HTML page for /.
+ *
+ * @param hostName host name.
+ * @param? menuEntries menu entries.
+ * @param baseUrl base URL for repositories.
+ * @param repositories list of repository description maps with name, cloneUrl,
+ *     and optional description values.
+ */
+{template .hostIndex}
+{call .header}
+  {param title: $hostName ? $hostName + ' Git repositories' : 'Git repositories' /}
+  {param menuEntries: $menuEntries /}
+  {param breadcrumbs: null /}
+{/call}
+
+{if length($repositories)}
+
+  <h2>
+    {msg desc="Git repositories available on the host"}
+      {$hostName} Git repositories
+    {/msg}
+  </h2>
+
+  <div class="instructions">
+    {msg desc="description on how to use this repository"}
+    To clone one of these repositories, install{sp}
+    <a href="http://www.git-scm.com/">git</a>, and run:
+    <pre>git clone {$baseUrl}<em>name</em></pre>
+    {/msg}
+  </div>
+
+  <table class="list">
+    <tr class="no-hover">
+      <th width="25%">
+        {msg desc="column header for repository name"}
+          Name
+        {/msg}
+      </th>
+      <th>
+        {msg desc="column header for repository description"}
+          Description
+        {/msg}
+      </th>
+    </tr>
+    {foreach $repo in $repositories}
+      <tr>
+        <td>
+          <a href="{$repo.url}">{$repo.name}</a>
+        </td>
+        <td>{$repo.description}</td>
+      </tr>
+    {/foreach}
+  </table>
+  <div class="footer">
+    <a href="?format=TEXT">{msg desc="text format"}TXT{/msg}</a>
+    {sp}
+    <a href="?format=JSON">{msg desc="JSON format"}JSON{/msg}</a>
+  </div>
+{/if}
+{call .footer /}
+{/template}
diff --git a/gitiles-servlet/src/main/resources/com/google/gitiles/templates/LogDetail.soy b/gitiles-servlet/src/main/resources/com/google/gitiles/templates/LogDetail.soy
new file mode 100644
index 0000000..b9ae9c7
--- /dev/null
+++ b/gitiles-servlet/src/main/resources/com/google/gitiles/templates/LogDetail.soy
@@ -0,0 +1,95 @@
+// 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.
+{namespace gitiles autoescape="contextual"}
+
+/**
+ * Detail page showing a shortlog for a commit.
+ *
+ * @param title human-readable revision name.
+ * @param repositoryName name of this repository.
+ * @param? menuEntries menu entries.
+ * @param breadcrumbs breadcrumbs for this page.
+ * @param? tags optional list of tags encountered when peeling this object, with
+ *     keys corresponding to gitiles.tagDetail.
+ * @param entries list of log entries; see .logEntry.
+ * @param? nextUrl URL for the next page of results.
+ * @param? previousUrl URL for the previous page of results.
+ */
+{template .logDetail}
+{call .header data="all" /}
+
+{if $tags}
+  {foreach $tag in $tags}
+    {call gitiles.tagDetail data="$tag" /}
+  {/foreach}
+{/if}
+
+{if $previousUrl}
+  <div class="log-nav">
+    <a href="{$previousUrl}">{msg desc="text for previous URL"}&laquo; Previous{/msg}</a>
+  </div>
+{/if}
+
+{if length($entries)}
+  <ol class="shortlog">
+    {foreach $entry in $entries}
+      <li{if $previousUrl and isFirst($entry)} class="first"{/if}>
+        {call .logEntry data="$entry" /}
+      </li>
+    {/foreach}
+  </ol>
+{else}
+  <p>{msg desc="informational text for when the log is empty"}No commits.{/msg}</p>
+{/if}
+
+{if $nextUrl}
+  <div class="log-nav">
+    <a href="{$nextUrl}">{msg desc="text for next URL"}Next &raquo;{/msg}</a>
+  </div>
+{/if}
+
+{call .footer /}
+{/template}
+
+/**
+ * Single shortlog entry.
+ *
+ * @param abbrevSha abbreviated SHA-1.
+ * @param url URL to commit detail page.
+ * @param shortMessage short commit message.
+ * @param author author information with at least "name" and "relativeTime" keys.
+ * @param branches list of branches for this entry, with "name" and "url" keys.
+ * @param tags list of tags for this entry, with "name" and "url" keys.
+ */
+{template .logEntry}
+<a href="{$url}">
+  <span class="sha1">{$abbrevSha}</span>
+  // nbsp instad of CSS padding/margin because those cause a break in the
+  // underline.
+  &nbsp;
+  {sp}<span class="commit-message">{$shortMessage}</span>
+</a>
+{sp}<span class="author">{msg desc="commit author name"}by {$author.name}{/msg}</span>
+{sp}<span class="time">- {$author.relativeTime}</span>
+{if length($branches)}
+  {foreach $branch in $branches}
+    {sp}<a href="{$branch.url}" class="branch-label">{$branch.name}</a>
+  {/foreach}
+{/if}
+{if length($tags)}
+  {foreach $tag in $tags}
+    {sp}<a href="{$tag.url}" class="tag-label">{$tag.name}</a>
+  {/foreach}
+{/if}
+{/template}
diff --git a/gitiles-servlet/src/main/resources/com/google/gitiles/templates/ObjectDetail.soy b/gitiles-servlet/src/main/resources/com/google/gitiles/templates/ObjectDetail.soy
new file mode 100644
index 0000000..badf43c
--- /dev/null
+++ b/gitiles-servlet/src/main/resources/com/google/gitiles/templates/ObjectDetail.soy
@@ -0,0 +1,294 @@
+// 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.
+{namespace gitiles autoescape="contextual"}
+
+/**
+ * Detailed listing of a commit.
+ *
+ * @param author map with "name", "email", and "time" keys for the commit author.
+ * @param committer map with "name", "email", and "time" keys for the committer.
+ * @param sha commit SHA-1.
+ * @param tree tree SHA-1.
+ * @param treeUrl tree URL.
+ * @param parents list of parent objects with the following keys:
+ *     sha: SHA-1.
+ *     url: URL to view the parent commit.
+ *     diffUrl: URL to display diffs relative to this parent.
+ * @param message list of commit message parts, where each part contains:
+ *     text: raw text of the part.
+ *     url: optional URL that should be linked to from the part.
+ * @param diffTree list of changed tree entries with the following keys:
+ *     changeType: string matching an org.eclipse.jgit.diff.DiffEntry.ChangeType
+ *         constant.
+ *     path: (new) path of the tree entry.
+ *     oldPath: old path, only for renames and copies.
+ *     url: URL to a detail page for the tree entry.
+ *     diffUrl: URL to a diff page for the tree entry's diff in this commit.
+ * @param logUrl URL to a log page starting at this commit.
+ */
+{template .commitDetail}
+<div class="git-commit">
+  <table>
+    <tr>
+      <th>{msg desc="Header for commit SHA entry"}commit{/msg}</th>
+      <td class="sha">
+        {$sha}
+        <span class="log-link">
+          [<a href="{$logUrl}">{msg desc="text for the log link"}log{/msg}</a>]
+        </span>
+      </td>
+      <td>{sp}</td>
+    </tr>
+    <tr>
+      <th>{msg desc="Header for commit author"}author{/msg}</th>
+      <td>{call .person_ data="$author" /}</td>
+      <td>{$author.time}</td>
+    </tr>
+    <tr>
+      <th>{msg desc="Header for committer"}committer{/msg}</th>
+      <td>{call .person_ data="$committer" /}</td>
+      <td>{$committer.time}</td>
+    </tr>
+    <tr>
+      <th>{msg desc="Header for tree SHA entry"}tree{/msg}</th>
+      <td class="sha"><a href="{$treeUrl}">{$tree}</a></td>
+    </tr>
+    {foreach $parent in $parents}
+      <tr>
+        <th>{msg desc="Header for parent SHA"}parent{/msg}</th>
+        <td>
+          <a href="{$parent.url}">{$parent.sha}</a>
+          <span class="diff-link">
+            [<a href="{$parent.diffUrl}">{msg desc="text for the parent diff link"}diff{/msg}</a>]
+          </span>
+        </td>
+      </tr>
+    {/foreach}
+  </table>
+</div>
+{call .message_}
+  {param className: 'commit-message' /}
+  {param message: $message /}
+{/call}
+
+{if $diffTree and length($diffTree)}
+  <ul class="diff-tree">
+    {foreach $entry in $diffTree}
+      <li>
+        <a href="{$entry.url}">{$entry.path}</a>
+        {switch $entry.changeType}
+          {case 'ADD'}
+            <span class="add">
+              {msg desc="Text for a new tree entry"}
+                [Added - <a href="{$entry.diffUrl}">diff</a>]
+              {/msg}
+            </span>
+          {case 'MODIFY'}
+            <span class="modify">
+              {msg desc="Text for a modified tree entry"}
+                [<a href="{$entry.diffUrl}">diff</a>]
+              {/msg}
+            </span>
+          {case 'DELETE'}
+            <span class="delete">
+              {msg desc="Text for a deleted tree entry"}
+                [Deleted - <a href="{$entry.diffUrl}">diff</a>]
+              {/msg}
+            </span>
+          {case 'RENAME'}
+            <span class="rename">
+              {msg desc="Text for a renamed tree entry"}
+                [Renamed from {$entry.oldPath} - <a href="{$entry.diffUrl}">diff</a>]
+              {/msg}
+            </span>
+          {case 'COPY'}
+            <span class="copy">
+              {msg desc="Text for a copied tree entry"}
+                [Copied from {$entry.oldPath} - <a href="{$entry.diffUrl}">diff</a>]
+              {/msg}
+            </span>
+          {default}
+        {/switch}
+      </li>
+    {/foreach}
+  </ul>
+  <div class="diff-summary">
+    {if length($diffTree) == 1}
+      {msg desc="1 file changed"}1 file changed{/msg}
+    {else}
+      {msg desc="number of files changed"}{length($diffTree)} files changed{/msg}
+    {/if}
+  </div>
+{/if}
+
+{/template}
+
+/**
+ * Detailed listing of a tree.
+ *
+ * @param sha SHA of this path's tree.
+ * @param? logUrl optional URL to a log for this path.
+ * @param entries list of entries with the following keys:
+ *     type: entry type, matching one of the constant names defined in
+ *         org.eclipse.jgit.lib.FileMode.
+ *     name: tree entry name.
+ *     url: URL to link to.
+ *     targetName: name of a symlink target, required only if type == 'SYMLINK'.
+ *     targetUrl: optional url of a symlink target, required only if
+ *         type == 'SYMLINK'.
+ */
+{template .treeDetail}
+<div class="sha1">
+  {msg desc="SHA-1 for the path's tree"}tree: {$sha}{/msg}
+  {if $logUrl}{sp}[<a href="{$logUrl}">{msg desc="history for a path"}path history{/msg}</a>]{/if}
+</div>
+
+{if length($entries)}
+  <ol class="list files">
+    {foreach $entry in $entries}
+      <li class="
+            {switch $entry.type}
+              {case 'TREE'}git-tree
+              {case 'SYMLINK'}symlink
+              {case 'REGULAR_FILE'}regular-file
+              {case 'EXECUTABLE_FILE'}executable-file
+              {case 'GITLINK'}gitlink
+              {default}regular-file
+            {/switch}
+            " title="
+            {switch $entry.type}
+              {case 'TREE'}{msg desc="Alt text for tree icon"}Tree{/msg}
+              {case 'SYMLINK'}{msg desc="Alt text for symlink icon"}Symlink{/msg}
+              {case 'REGULAR_FILE'}{msg desc="Alt text for regular file icon"}Regular file{/msg}
+              {case 'EXECUTABLE_FILE'}
+                {msg desc="Alt text for executable file icon"}Executable file{/msg}
+              {case 'GITLINK'}
+                {msg desc="Alt text for git submodule link icon"}Git submodule link{/msg}
+              {default}{msg desc="Alt text for other file icon"}Other{/msg}
+            {/switch}
+            - {$entry.name}">
+        <a href="{$entry.url}">{$entry.name}</a>
+        {if $entry.type == 'SYMLINK'}
+          {sp}&#x21e8;{sp}
+          {if $entry.targetUrl}
+            <a href="{$entry.targetUrl}">{$entry.targetName}</a>
+          {else}
+            {$entry.targetName}
+          {/if}
+        {/if}
+        // TODO(dborowitz): Something reasonable for gitlinks.
+      </li>
+    {/foreach}
+  </table>
+{else}
+  <p>{msg desc="Informational text for when a tree is empty"}This tree is empty.{/msg}</p>
+{/if}
+{/template}
+
+/**
+ * Detailed listing of a blob.
+ *
+ * @param sha SHA of this file's blob.
+ * @param? logUrl optional URL to a log for this file.
+ * @param data file data (may be empty), or null for a binary file.
+ * @param? lang prettyprint language extension for text file.
+ * @param? size for binary files only, size in bytes.
+ */
+{template .blobDetail}
+<div class="sha1">
+  {msg desc="SHA-1 for the file's blob"}blob: {$sha}{/msg}
+  {if $logUrl}{sp}[<a href="{$logUrl}">{msg desc="history for a file"}file history{/msg}</a>]{/if}
+</div>
+
+{if $data != null}
+  {if $data}
+    {if $lang != null}
+      <pre class="git-blob prettyprint linenums lang-{$lang}">{$data}</pre>
+    {else}
+      <pre class="git-blob">{$data}</pre>
+    {/if}
+  {else}
+    <div class="file-empty">Empty file</div>
+  {/if}
+{else}
+  <div class="file-binary">
+    {msg desc="size of binary file in bytes"}{$size}-byte binary file{/msg}
+  </div>
+{/if}
+{/template}
+
+/**
+ * Detailed listing of an annotated tag.
+ *
+ * @param sha SHA of this tag.
+ * @param? tagger optional map with "name", "email", and "time" keys for the
+ *     tagger.
+ * @param object SHA of the object this tag points to.
+ * @param message tag message.
+ */
+{template .tagDetail}
+<div class="git-tag">
+  <table>
+    <tr>
+      <th>{msg desc="Header for tag SHA entry"}tag{/msg}</th>
+      <td class="sha">{$sha}</td>
+      <td>{sp}</td>
+    </tr>
+    {if $tagger}
+      <tr>
+        <th>{msg desc="Header for tagger"}tagger{/msg}</th>
+        <td>{call .person_ data="$tagger" /}</td>
+        <td>{$tagger.time}</td>
+      </tr>
+    {/if}
+    <tr>
+      <th>{msg desc="Header for tagged object SHA"}object{/msg}</th>
+      <td class="sha">{$object}</td>
+      <td>{sp}</td>
+    </tr>
+  </table>
+</div>
+{if $message and length($message)}
+  {call .message_}
+    {param className: 'tag-message' /}
+    {param message: $message /}
+  {/call}
+{/if}
+{/template}
+
+/**
+ * Line about a git person identity.
+ *
+ * @param name name.
+ * @param email email.
+ */
+{template .person_ private="true"}
+{$name}{if $email} &lt;{$email}&gt;{/if}
+{/template}
+
+/**
+ * Preformatted message, possibly containing hyperlinks.
+ *
+ * @param className CSS class name for <pre> block.
+ * @param message list of message parts, where each part contains:
+ *     text: raw text of the part.
+ *     url: optional URL that should be linked to from the part.
+ */
+{template .message_ private="true"}
+<pre class="{$className|id}">
+  {foreach $part in $message}
+    {if $part.url}<a href="{$part.url}">{$part.text}</a>{else}{$part.text}{/if}
+  {/foreach}
+</pre>
+{/template}
diff --git a/gitiles-servlet/src/main/resources/com/google/gitiles/templates/PathDetail.soy b/gitiles-servlet/src/main/resources/com/google/gitiles/templates/PathDetail.soy
new file mode 100644
index 0000000..33d38a2
--- /dev/null
+++ b/gitiles-servlet/src/main/resources/com/google/gitiles/templates/PathDetail.soy
@@ -0,0 +1,84 @@
+// 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.
+{namespace gitiles autoescape="contextual"}
+
+/**
+ * Detail page for a path within a tree.
+ *
+ * @param title human-readable name of this path.
+ * @param repositoryName name of this repository.
+ * @param? menuEntries menu entries.
+ * @param breadcrumbs breadcrumbs for this page.
+ * @param type path type, matching one of the constant names defined in
+ *         org.eclipse.jgit.lib.FileMode.
+ * @param data path data, matching the params for one of .treeDetail,
+ *     .blobDetail, .symlinkDetail, or .gitlinkDetail as appropriate.
+ */
+{template .pathDetail}
+{if $type == 'REGULAR_FILE' or $type == 'EXECUTABLE_FILE'}
+  {call .header}
+    {param title: $title /}
+    {param repositoryName: $repositoryName /}
+    {param menuEntries: $menuEntries /}
+    {param breadcrumbs: $breadcrumbs /}
+    {param css: [gitiles.PRETTIFY_CSS_URL] /}
+    {param js: [gitiles.PRETTIFY_JS_URL] /}
+    {param onLoad: 'prettyPrint()' /}
+  {/call}
+{else}
+  {call .header data="all" /}
+{/if}
+
+{switch $type}
+  {case 'TREE'}{call .treeDetail data="$data" /}
+  {case 'SYMLINK'}{call .symlinkDetail data="$data" /}
+  {case 'REGULAR_FILE'}{call .blobDetail data="$data" /}
+  {case 'EXECUTABLE_FILE'}{call .blobDetail data="$data" /}
+  {case 'GITLINK'}{call .gitlinkDetail data="$data" /}
+  {default}
+    <div class="error">
+      {msg desc="Error message for an unknown object type"}Object has unknown type.{/msg}
+    </div>
+{/switch}
+
+{call .footer /}
+{/template}
+
+/**
+ * Detail for a symbolic link.
+ *
+ * @param target target of this symlink.
+ * @param? targetUrl optional URL for the target, if it is within this repo.
+ */
+{template .symlinkDetail}
+<div class="symlink-detail">
+  {msg desc="Lead-in text for the symbolic link target."}Symbolic link to{/msg}
+  {sp}{if $targetUrl}<a href="{$targetUrl}">{$target}</a>{else}{$target}{/if}
+</div>
+{/template}
+
+/**
+ * Detail for a git submodule link.
+ *
+ * @param sha submodule commit SHA.
+ * @param remoteUrl URL of the remote repository.
+ * @param? httpUrl optional HTTP URL pointing to a web-browser-compatible URL of
+ *     the remote repository.
+ */
+{template .gitlinkDetail}
+<div class="gitlink-detail">
+  {msg desc="Lead-in text for the git link URL"}Submodule link to {$sha} of{/msg}
+  {sp}{if $httpUrl}<a href="{$httpUrl}">{$remoteUrl}</a>{else}{$remoteUrl}{/if}
+</div>
+{/template}
diff --git a/gitiles-servlet/src/main/resources/com/google/gitiles/templates/RepositoryIndex.soy b/gitiles-servlet/src/main/resources/com/google/gitiles/templates/RepositoryIndex.soy
new file mode 100644
index 0000000..02bb4da
--- /dev/null
+++ b/gitiles-servlet/src/main/resources/com/google/gitiles/templates/RepositoryIndex.soy
@@ -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.
+{namespace gitiles autoescape="contextual"}
+
+/**
+ * Index page for a repository.
+ *
+ * @param repositoryName name of this repository.
+ * @param? menuEntries menu entries.
+ * @param breadcrumbs breadcrumbs for this page.
+ * @param cloneUrl clone URL for this repository.
+ * @param description description text of the repository.
+ * @param? branches list of branch objects with url and name keys.
+ * @param? tags list of tag objects with url and name keys.
+ */
+{template .repositoryIndex}
+{call .header}
+  {param title: $repositoryName /}
+  {param repositoryName: null /}
+  {param menuEntries: $menuEntries /}
+  {param breadcrumbs: $breadcrumbs /}
+{/call}
+
+{if $description}
+  <div class="repository-description">{$description}</div>
+{/if}
+
+<textarea rows="1" cols="150" class="clone-line"
+  onclick="this.focus();this.select();"
+  readonly="readonly">
+    git clone {$cloneUrl}
+</textarea>
+
+<div class="repository-refs">
+  {if $branches and length($branches)}
+    <div class="repository-branches">
+      <h3>Branches</h3>
+      <ul class="branch-list">
+      {foreach $branch in $branches}
+        {call .ref_ data="$branch" /}
+      {/foreach}
+      </ul>
+    </div>
+  {/if}
+
+  {if $tags and length($tags)}
+    <div class="repository-tags">
+      <h3>Tags</h3>
+      <ul class="branch-list">
+      {foreach $tag in $tags}
+        {call .ref_ data="$tag" /}
+      {/foreach}
+      </ul>
+    </div>
+  {/if}
+</div>
+{call .footer /}
+{/template}
+
+/**
+ * Detail for a single ref.
+ *
+ * @param url URL for ref detail page.
+ * @param name ref name.
+ */
+{template .ref_}
+<li><a href="{$url}">{$name}</a></li>
+{/template}
diff --git a/gitiles-servlet/src/main/resources/com/google/gitiles/templates/RevisionDetail.soy b/gitiles-servlet/src/main/resources/com/google/gitiles/templates/RevisionDetail.soy
new file mode 100644
index 0000000..1abed6b
--- /dev/null
+++ b/gitiles-servlet/src/main/resources/com/google/gitiles/templates/RevisionDetail.soy
@@ -0,0 +1,63 @@
+// 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.
+{namespace gitiles autoescape="contextual"}
+
+/**
+ * Detail page about a single revision.
+ *
+ * @param title human-readable revision name.
+ * @param repositoryName name of this repository.
+ * @param? menuEntries menu entries.
+ * @param breadcrumbs breadcrumbs for this page.
+ * @param? hasBlob set to true if the revision or its peeled value is a blob.
+ * @param objects list of objects encountered when peeling this object. Each
+ *     object has a "type" key with one of the
+ *     org.eclipse.jgit.lib.Contants.TYPE_* constant strings, and a "data" key
+ *     with an object whose keys correspond to the appropriate object detail
+ *     template from ObjectDetail.soy.
+ */
+{template .revisionDetail}
+{if $hasBlob}
+  {call .header}
+    {param title: $title /}
+    {param repositoryName: $repositoryName /}
+    {param menuEntries: $menuEntries /}
+    {param breadcrumbs: $breadcrumbs /}
+    {param css: [gitiles.PRETTIFY_CSS_URL] /}
+    {param js: [gitiles.PRETTIFY_JS_URL] /}
+    {param onLoad: 'prettyPrint()' /}
+  {/call}
+{else}
+  {call .header data="all" /}
+{/if}
+
+{foreach $object in $objects}
+  {switch $object.type}
+    {case 'commit'}
+      {call .commitDetail data="$object.data" /}
+    {case 'tree'}
+      {call .treeDetail data="$object.data" /}
+    {case 'blob'}
+      {call .blobDetail data="$object.data" /}
+    {case 'tag'}
+      {call .tagDetail data="$object.data" /}
+    {default}
+      <div class="error">
+        {msg desc="Error message for an unknown object type"}Object has unknown type.{/msg}
+      </div>
+  {/switch}
+{/foreach}
+
+{call .footer /}
+{/template}
diff --git a/gitiles-servlet/src/test/java/com/google/gitiles/ConfigUtilTest.java b/gitiles-servlet/src/test/java/com/google/gitiles/ConfigUtilTest.java
new file mode 100644
index 0000000..96439c2
--- /dev/null
+++ b/gitiles-servlet/src/test/java/com/google/gitiles/ConfigUtilTest.java
@@ -0,0 +1,43 @@
+// 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.ConfigUtil.getDuration;
+
+import junit.framework.TestCase;
+
+import org.eclipse.jgit.lib.Config;
+import org.joda.time.Duration;
+
+/** Tests for configuration utilities. */
+public class ConfigUtilTest extends TestCase {
+  public void testGetDuration() throws Exception {
+    Duration def = Duration.standardSeconds(2);
+    Config config = new Config();
+    Duration t;
+
+    config.setString("core", "dht", "timeout", "500 ms");
+    t = getDuration(config, "core", "dht", "timeout", def);
+    assertEquals(500, t.getMillis());
+
+    config.setString("core", "dht", "timeout", "5.2 sec");
+    t = getDuration(config, "core", "dht", "timeout", def);
+    assertEquals(5200, t.getMillis());
+
+    config.setString("core", "dht", "timeout", "1 min");
+    t = getDuration(config, "core", "dht", "timeout", def);
+    assertEquals(60000, t.getMillis());
+  }
+}
diff --git a/gitiles-servlet/src/test/java/com/google/gitiles/FakeHttpServletRequest.java b/gitiles-servlet/src/test/java/com/google/gitiles/FakeHttpServletRequest.java
new file mode 100644
index 0000000..c1afa74
--- /dev/null
+++ b/gitiles-servlet/src/test/java/com/google/gitiles/FakeHttpServletRequest.java
@@ -0,0 +1,389 @@
+// 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.Charsets.UTF_8;
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.gitiles.TestGitilesUrls.URLS;
+
+import com.google.common.base.Function;
+import com.google.common.base.Splitter;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.LinkedListMultimap;
+import com.google.common.collect.ListMultimap;
+import com.google.common.collect.Maps;
+
+import org.eclipse.jgit.http.server.ServletUtils;
+import org.eclipse.jgit.storage.dfs.DfsRepository;
+
+import java.io.BufferedReader;
+import java.io.UnsupportedEncodingException;
+import java.net.URLDecoder;
+import java.security.Principal;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Enumeration;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+
+import javax.servlet.RequestDispatcher;
+import javax.servlet.ServletInputStream;
+import javax.servlet.http.Cookie;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpSession;
+
+/** Simple fake implementation of {@link HttpServletRequest}. */
+public class FakeHttpServletRequest implements HttpServletRequest {
+  public static final String SERVLET_PATH = "/b";
+
+  public static FakeHttpServletRequest newRequest() {
+    return new FakeHttpServletRequest(
+        URLS.getHostName(null),
+        80,
+        "",
+        SERVLET_PATH,
+        "");
+  }
+
+  public static FakeHttpServletRequest newRequest(DfsRepository repo) {
+    FakeHttpServletRequest req = newRequest();
+    req.setAttribute(ServletUtils.ATTRIBUTE_REPOSITORY, repo);
+    return req;
+  }
+
+  private final Map<String, Object> attributes;
+  private final ListMultimap<String, String> headers;
+
+  private ListMultimap<String, String> parameters;
+  private String hostName;
+  private int port;
+  private String contextPath;
+  private String servletPath;
+  private String path;
+
+  private FakeHttpServletRequest(String hostName, int port, String contextPath, String servletPath,
+      String path) {
+    this.hostName = checkNotNull(hostName, "hostName");
+    checkArgument(port > 0);
+    this.port = port;
+    this.contextPath = checkNotNull(contextPath, "contextPath");
+    this.servletPath = checkNotNull(servletPath, "servletPath");
+    attributes = Maps.newConcurrentMap();
+    parameters = LinkedListMultimap.create();
+    headers = LinkedListMultimap.create();
+  }
+
+  @Override
+  public Object getAttribute(String name) {
+    return attributes.get(name);
+  }
+
+  @Override
+  public Enumeration<String> getAttributeNames() {
+    return Collections.enumeration(attributes.keySet());
+  }
+
+  @Override
+  public String getCharacterEncoding() {
+    return UTF_8.name();
+  }
+
+  @Override
+  public int getContentLength() {
+    return -1;
+  }
+
+  @Override
+  public String getContentType() {
+    return null;
+  }
+
+  @Override
+  public ServletInputStream getInputStream() {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public String getLocalAddr() {
+    return "1.2.3.4";
+  }
+
+  @Override
+  public String getLocalName() {
+    return hostName;
+  }
+
+  @Override
+  public int getLocalPort() {
+    return port;
+  }
+
+  @Override
+  public Locale getLocale() {
+    return Locale.US;
+  }
+
+  @Override
+  public Enumeration<Locale> getLocales() {
+    return Collections.enumeration(Collections.singleton(Locale.US));
+  }
+
+  @Override
+  public String getParameter(String name) {
+    return Iterables.getFirst(parameters.get(name), null);
+  }
+
+  private static final Function<Collection<String>, String[]> STRING_COLLECTION_TO_ARRAY =
+      new Function<Collection<String>, String[]>() {
+        @Override
+        public String[] apply(Collection<String> values) {
+          return values.toArray(new String[0]);
+        }
+      };
+
+  @Override
+  public Map<String, String[]> getParameterMap() {
+    return Collections.unmodifiableMap(
+        Maps.transformValues(parameters.asMap(), STRING_COLLECTION_TO_ARRAY));
+  }
+
+  @Override
+  public Enumeration<String> getParameterNames() {
+    return Collections.enumeration(parameters.keySet());
+  }
+
+  @Override
+  public String[] getParameterValues(String name) {
+    return STRING_COLLECTION_TO_ARRAY.apply(parameters.get(name));
+  }
+
+  public void setQueryString(String qs) {
+    ListMultimap<String, String> params = LinkedListMultimap.create();
+    for (String entry : Splitter.on('&').split(qs)) {
+      List<String> kv = ImmutableList.copyOf(Splitter.on('=').limit(2).split(entry));
+      try {
+        params.put(URLDecoder.decode(kv.get(0), UTF_8.name()),
+            kv.size() == 2 ? URLDecoder.decode(kv.get(1), UTF_8.name()) : "");
+      } catch (UnsupportedEncodingException e) {
+        throw new IllegalArgumentException(e);
+      }
+    }
+    parameters = params;
+  }
+
+  @Override
+  public String getProtocol() {
+    return "HTTP/1.1";
+  }
+
+  @Override
+  public BufferedReader getReader() {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  @Deprecated
+  public String getRealPath(String path) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public String getRemoteAddr() {
+    return "5.6.7.8";
+  }
+
+  @Override
+  public String getRemoteHost() {
+    return "remotehost";
+  }
+
+  @Override
+  public int getRemotePort() {
+    return 1234;
+  }
+
+  @Override
+  public RequestDispatcher getRequestDispatcher(String path) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public String getScheme() {
+    return port == 443 ? "https" : "http";
+  }
+
+  @Override
+  public String getServerName() {
+    return hostName;
+  }
+
+  @Override
+  public int getServerPort() {
+    return port;
+  }
+
+  @Override
+  public boolean isSecure() {
+    return port == 443;
+  }
+
+  @Override
+  public void removeAttribute(String name) {
+    attributes.remove(name);
+  }
+
+  @Override
+  public void setAttribute(String name, Object value) {
+    attributes.put(name, value);
+  }
+
+  @Override
+  public void setCharacterEncoding(String env) throws UnsupportedOperationException {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public String getAuthType() {
+    return null;
+  }
+
+  @Override
+  public String getContextPath() {
+    return contextPath;
+  }
+
+  @Override
+  public Cookie[] getCookies() {
+    return new Cookie[0];
+  }
+
+  @Override
+  public long getDateHeader(String name) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public String getHeader(String name) {
+    return Iterables.getFirst(headers.get(name), null);
+  }
+
+  @Override
+  public Enumeration<String> getHeaderNames() {
+    return Collections.enumeration(headers.keySet());
+  }
+
+  @Override
+  public Enumeration<String> getHeaders(String name) {
+    return Collections.enumeration(headers.get(name));
+  }
+
+  @Override
+  public int getIntHeader(String name) {
+    return Integer.parseInt(getHeader(name));
+  }
+
+  @Override
+  public String getMethod() {
+    return "GET";
+  }
+
+  @Override
+  public String getPathInfo() {
+    return path;
+  }
+
+  public void setPathInfo(String path) {
+    this.path = checkNotNull(path);
+  }
+
+  @Override
+  public String getPathTranslated() {
+    return path;
+  }
+
+  @Override
+  public String getQueryString() {
+    return null;
+  }
+
+  @Override
+  public String getRemoteUser() {
+    return null;
+  }
+
+  @Override
+  public String getRequestURI() {
+    return null;
+  }
+
+  @Override
+  public StringBuffer getRequestURL() {
+    return null;
+  }
+
+  @Override
+  public String getRequestedSessionId() {
+    return null;
+  }
+
+  @Override
+  public String getServletPath() {
+    return servletPath;
+  }
+
+  @Override
+  public HttpSession getSession() {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public HttpSession getSession(boolean create) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public Principal getUserPrincipal() {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public boolean isRequestedSessionIdFromCookie() {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public boolean isRequestedSessionIdFromURL() {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  @Deprecated
+  public boolean isRequestedSessionIdFromUrl() {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public boolean isRequestedSessionIdValid() {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public boolean isUserInRole(String role) {
+    throw new UnsupportedOperationException();
+  }
+}
diff --git a/gitiles-servlet/src/test/java/com/google/gitiles/FakeHttpServletResponse.java b/gitiles-servlet/src/test/java/com/google/gitiles/FakeHttpServletResponse.java
new file mode 100644
index 0000000..87a0099
--- /dev/null
+++ b/gitiles-servlet/src/test/java/com/google/gitiles/FakeHttpServletResponse.java
@@ -0,0 +1,201 @@
+// 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.Charsets.UTF_8;
+
+import java.io.PrintWriter;
+import java.util.Locale;
+
+import javax.servlet.ServletOutputStream;
+import javax.servlet.http.Cookie;
+import javax.servlet.http.HttpServletResponse;
+
+/** Simple fake implementation of {@link HttpServletResponse}. */
+public class FakeHttpServletResponse implements HttpServletResponse {
+
+  private volatile int status;
+
+  public FakeHttpServletResponse() {
+    status = 200;
+  }
+
+  @Override
+  public void flushBuffer() {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public int getBufferSize() {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public String getCharacterEncoding() {
+    return UTF_8.name();
+  }
+
+  @Override
+  public String getContentType() {
+    return null;
+  }
+
+  @Override
+  public Locale getLocale() {
+    return Locale.US;
+  }
+
+  @Override
+  public ServletOutputStream getOutputStream() {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public PrintWriter getWriter() {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public boolean isCommitted() {
+    return false;
+  }
+
+  @Override
+  public void reset() {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public void resetBuffer() {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public void setBufferSize(int sz) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public void setCharacterEncoding(String name) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public void setContentLength(int length) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public void setContentType(String type) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public void setLocale(Locale locale) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public void addCookie(Cookie cookie) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public void addDateHeader(String name, long value) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public void addHeader(String name, String value) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public void addIntHeader(String name, int value) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public boolean containsHeader(String name) {
+    return false;
+  }
+
+  @Override
+  public String encodeRedirectURL(String url) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  @Deprecated
+  public String encodeRedirectUrl(String url) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public String encodeURL(String url) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  @Deprecated
+  public String encodeUrl(String url) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public void sendError(int sc) {
+    status = sc;
+  }
+
+  @Override
+  public void sendError(int sc, String msg) {
+    status = sc;
+  }
+
+  @Override
+  public void sendRedirect(String msg) {
+    status = SC_FOUND;
+  }
+
+  @Override
+  public void setDateHeader(String name, long value) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public void setHeader(String name, String value) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public void setIntHeader(String name, int value) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public void setStatus(int sc) {
+    status = sc;
+  }
+
+  @Override
+  @Deprecated
+  public void setStatus(int sc, String msg) {
+    status = sc;
+  }
+
+  public int getStatus() {
+    return status;
+  }
+}
diff --git a/gitiles-servlet/src/test/java/com/google/gitiles/GitilesFilterTest.java b/gitiles-servlet/src/test/java/com/google/gitiles/GitilesFilterTest.java
new file mode 100644
index 0000000..0d32dd4
--- /dev/null
+++ b/gitiles-servlet/src/test/java/com/google/gitiles/GitilesFilterTest.java
@@ -0,0 +1,171 @@
+// 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.GitilesFilter.REPO_PATH_REGEX;
+import static com.google.gitiles.GitilesFilter.REPO_REGEX;
+import static com.google.gitiles.GitilesFilter.ROOT_REGEX;
+
+import junit.framework.TestCase;
+
+import java.util.regex.Matcher;
+
+/** Tests for the Gitiles filter. */
+public class GitilesFilterTest extends TestCase {
+  public void testRootUrls() throws Exception {
+    assertFalse(ROOT_REGEX.matcher("").matches());
+    assertFalse(ROOT_REGEX.matcher("/foo").matches());
+    assertFalse(ROOT_REGEX.matcher("/foo/").matches());
+    assertFalse(ROOT_REGEX.matcher("/foo/ ").matches());
+    assertFalse(ROOT_REGEX.matcher("/foo/+").matches());
+    assertFalse(ROOT_REGEX.matcher("/foo/+").matches());
+    assertFalse(ROOT_REGEX.matcher("/foo/ /").matches());
+    assertFalse(ROOT_REGEX.matcher("/foo/+/").matches());
+    assertFalse(ROOT_REGEX.matcher("/foo/+/bar").matches());
+    Matcher m;
+
+    m = ROOT_REGEX.matcher("/");
+    assertTrue(m.matches());
+    assertEquals("/", m.group(0));
+    assertEquals(m.group(0), m.group(1));
+    assertEquals("/", m.group(2));
+    assertEquals("", m.group(3));
+    assertEquals("", m.group(4));
+
+    m = ROOT_REGEX.matcher("//");
+    assertTrue(m.matches());
+    assertEquals("//", m.group(0));
+    assertEquals(m.group(0), m.group(1));
+    assertEquals("/", m.group(2));
+    assertEquals("", m.group(3));
+    assertEquals("", m.group(4));
+  }
+
+  public void testRepoUrls() throws Exception {
+    assertFalse(REPO_REGEX.matcher("").matches());
+
+    // These match the regex but are served by the root regex binder, which is
+    // matched first.
+    assertTrue(REPO_REGEX.matcher("/").matches());
+    assertTrue(REPO_REGEX.matcher("//").matches());
+
+    assertFalse(REPO_REGEX.matcher("/foo/+").matches());
+    assertFalse(REPO_REGEX.matcher("/foo/bar/+").matches());
+    assertFalse(REPO_REGEX.matcher("/foo/bar/+/").matches());
+    assertFalse(REPO_REGEX.matcher("/foo/bar/+/baz").matches());
+    Matcher m;
+
+    m = REPO_REGEX.matcher("/foo");
+    assertTrue(m.matches());
+    assertEquals("/foo", m.group(0));
+    assertEquals(m.group(0), m.group(1));
+    assertEquals("/foo", m.group(2));
+    assertEquals("", m.group(3));
+    assertEquals("", m.group(4));
+
+    m = REPO_REGEX.matcher("/foo/");
+    assertTrue(m.matches());
+    assertEquals("/foo/", m.group(0));
+    assertEquals(m.group(0), m.group(1));
+    assertEquals("/foo", m.group(2));
+    assertEquals("", m.group(3));
+    assertEquals("", m.group(4));
+
+    m = REPO_REGEX.matcher("/foo/bar");
+    assertTrue(m.matches());
+    assertEquals("/foo/bar", m.group(0));
+    assertEquals(m.group(0), m.group(1));
+    assertEquals("/foo/bar", m.group(2));
+    assertEquals("", m.group(3));
+    assertEquals("", m.group(4));
+
+    m = REPO_REGEX.matcher("/foo/bar+baz");
+    assertTrue(m.matches());
+    assertEquals("/foo/bar+baz", m.group(0));
+    assertEquals(m.group(0), m.group(1));
+    assertEquals("/foo/bar+baz", m.group(2));
+    assertEquals("", m.group(3));
+    assertEquals("", m.group(4));
+  }
+
+  public void testRepoPathUrls() throws Exception {
+    assertFalse(REPO_PATH_REGEX.matcher("").matches());
+    assertFalse(REPO_PATH_REGEX.matcher("/").matches());
+    assertFalse(REPO_PATH_REGEX.matcher("//").matches());
+    assertFalse(REPO_PATH_REGEX.matcher("/foo").matches());
+    assertFalse(REPO_PATH_REGEX.matcher("/foo/ ").matches());
+    assertFalse(REPO_PATH_REGEX.matcher("/foo/ /").matches());
+    assertFalse(REPO_PATH_REGEX.matcher("/foo/ /bar").matches());
+    assertFalse(REPO_PATH_REGEX.matcher("/foo/bar").matches());
+    assertFalse(REPO_PATH_REGEX.matcher("/foo/bar+baz").matches());
+    Matcher m;
+
+    m = REPO_PATH_REGEX.matcher("/foo/+");
+    assertTrue(m.matches());
+    assertEquals("/foo/+", m.group(0));
+    assertEquals(m.group(0), m.group(1));
+    assertEquals("/foo", m.group(2));
+    assertEquals("+", m.group(3));
+    assertEquals("", m.group(4));
+
+    m = REPO_PATH_REGEX.matcher("/foo/+/");
+    assertTrue(m.matches());
+    assertEquals("/foo/+/", m.group(0));
+    assertEquals(m.group(0), m.group(1));
+    assertEquals("/foo", m.group(2));
+    assertEquals("+", m.group(3));
+    assertEquals("/", m.group(4));
+
+    m = REPO_PATH_REGEX.matcher("/foo/+/bar/baz");
+    assertTrue(m.matches());
+    assertEquals("/foo/+/bar/baz", m.group(0));
+    assertEquals(m.group(0), m.group(1));
+    assertEquals("/foo", m.group(2));
+    assertEquals("+", m.group(3));
+    assertEquals("/bar/baz", m.group(4));
+
+    m = REPO_PATH_REGEX.matcher("/foo/+/bar/baz/");
+    assertTrue(m.matches());
+    assertEquals("/foo/+/bar/baz/", m.group(0));
+    assertEquals(m.group(0), m.group(1));
+    assertEquals("/foo", m.group(2));
+    assertEquals("+", m.group(3));
+    assertEquals("/bar/baz/", m.group(4));
+
+    m = REPO_PATH_REGEX.matcher("/foo/+/bar baz");
+    assertTrue(m.matches());
+    assertEquals("/foo/+/bar baz", m.group(0));
+    assertEquals(m.group(0), m.group(1));
+    assertEquals("/foo", m.group(2));
+    assertEquals("+", m.group(3));
+    assertEquals("/bar baz", m.group(4));
+
+    m = REPO_PATH_REGEX.matcher("/foo/+/bar/+/baz");
+    assertTrue(m.matches());
+    assertEquals("/foo/+/bar/+/baz", m.group(0));
+    assertEquals(m.group(0), m.group(1));
+    assertEquals("/foo", m.group(2));
+    assertEquals("+", m.group(3));
+    assertEquals("/bar/+/baz", m.group(4));
+
+    m = REPO_PATH_REGEX.matcher("/foo/+bar/baz");
+    assertTrue(m.matches());
+    assertEquals("/foo/+bar/baz", m.group(0));
+    assertEquals(m.group(0), m.group(1));
+    assertEquals("/foo", m.group(2));
+    assertEquals("+bar", m.group(3));
+    assertEquals("/baz", m.group(4));
+  }
+}
diff --git a/gitiles-servlet/src/test/java/com/google/gitiles/GitilesUrlsTest.java b/gitiles-servlet/src/test/java/com/google/gitiles/GitilesUrlsTest.java
new file mode 100644
index 0000000..4c2ff8b
--- /dev/null
+++ b/gitiles-servlet/src/test/java/com/google/gitiles/GitilesUrlsTest.java
@@ -0,0 +1,48 @@
+// 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.GitilesUrls.NAME_ESCAPER;
+
+import junit.framework.TestCase;
+
+/** Unit tests for {@link GitilesUrls}. */
+public class GitilesUrlsTest extends TestCase {
+  public void testNameEscaperEscapesAppropriateSpecialCharacters() throws Exception {
+    assertEquals("foo_bar", NAME_ESCAPER.apply("foo_bar"));
+    assertEquals("foo-bar", NAME_ESCAPER.apply("foo-bar"));
+    assertEquals("foo%25bar", NAME_ESCAPER.apply("foo%bar"));
+    assertEquals("foo%26bar", NAME_ESCAPER.apply("foo&bar"));
+    assertEquals("foo%28bar", NAME_ESCAPER.apply("foo(bar"));
+    assertEquals("foo%29bar", NAME_ESCAPER.apply("foo)bar"));
+    assertEquals("foo%3Abar", NAME_ESCAPER.apply("foo:bar"));
+    assertEquals("foo%3Bbar", NAME_ESCAPER.apply("foo;bar"));
+    assertEquals("foo%3Dbar", NAME_ESCAPER.apply("foo=bar"));
+    assertEquals("foo%3Fbar", NAME_ESCAPER.apply("foo?bar"));
+    assertEquals("foo%5Bbar", NAME_ESCAPER.apply("foo[bar"));
+    assertEquals("foo%5Dbar", NAME_ESCAPER.apply("foo]bar"));
+    assertEquals("foo%7Bbar", NAME_ESCAPER.apply("foo{bar"));
+    assertEquals("foo%7Dbar", NAME_ESCAPER.apply("foo}bar"));
+  }
+  public void testNameEscaperDoesNotEscapeSlashes() throws Exception {
+    assertEquals("foo/bar", NAME_ESCAPER.apply("foo/bar"));
+  }
+
+  public void testNameEscaperEscapesSpacesWithPercentInsteadOfPlus() throws Exception {
+    assertEquals("foo+bar", NAME_ESCAPER.apply("foo+bar"));
+    assertEquals("foo%20bar", NAME_ESCAPER.apply("foo bar"));
+    assertEquals("foo%2520bar", NAME_ESCAPER.apply("foo%20bar"));
+  }
+}
diff --git a/gitiles-servlet/src/test/java/com/google/gitiles/GitilesViewTest.java b/gitiles-servlet/src/test/java/com/google/gitiles/GitilesViewTest.java
new file mode 100644
index 0000000..a8a5c4f
--- /dev/null
+++ b/gitiles-servlet/src/test/java/com/google/gitiles/GitilesViewTest.java
@@ -0,0 +1,504 @@
+// 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.ImmutableList;
+import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.collect.ImmutableMap;
+import com.google.gitiles.GitilesView.Type;
+
+import junit.framework.TestCase;
+
+import org.eclipse.jgit.lib.ObjectId;
+
+/** Tests for Gitiles views. */
+public class GitilesViewTest extends TestCase {
+  private static final GitilesView HOST = GitilesView.hostIndex()
+      .setServletPath("/b")
+      .setHostName("host")
+      .build();
+
+  public void testEmptyServletPath() throws Exception {
+    GitilesView view = GitilesView.hostIndex()
+        .setServletPath("")
+        .setHostName("host")
+        .build();
+    assertEquals("", view.getServletPath());
+    assertEquals(Type.HOST_INDEX, view.getType());
+    assertEquals("host", view.getHostName());
+    assertNull(view.getRepositoryName());
+    assertEquals(Revision.NULL, view.getRevision());
+    assertNull(view.getTreePath());
+    assertTrue(HOST.getParameters().isEmpty());
+
+    assertEquals("/?format=HTML", view.toUrl());
+    assertEquals(ImmutableList.of(ImmutableMap.of("text", "host", "url", "/?format=HTML")),
+        view.getBreadcrumbs());
+  }
+
+  public void testHostIndex() throws Exception {
+    assertEquals("/b", HOST.getServletPath());
+    assertEquals(Type.HOST_INDEX, HOST.getType());
+    assertEquals("host", HOST.getHostName());
+    assertNull(HOST.getRepositoryName());
+    assertEquals(Revision.NULL, HOST.getRevision());
+    assertNull(HOST.getTreePath());
+    assertTrue(HOST.getParameters().isEmpty());
+
+    assertEquals("/b/?format=HTML", HOST.toUrl());
+    assertEquals(ImmutableList.of(ImmutableMap.of("text", "host", "url", "/b/?format=HTML")),
+        HOST.getBreadcrumbs());
+  }
+
+  public void testQueryParams() throws Exception {
+    GitilesView view = GitilesView.hostIndex().copyFrom(HOST)
+        .putParam("foo", "foovalue")
+        .putParam("bar", "barvalue")
+        .build();
+
+    assertEquals("/b", view.getServletPath());
+    assertEquals(Type.HOST_INDEX, view.getType());
+    assertEquals("host", view.getHostName());
+    assertNull(view.getRepositoryName());
+    assertEquals(Revision.NULL, view.getRevision());
+    assertNull(view.getTreePath());
+    assertEquals(
+        ImmutableListMultimap.of(
+            "foo", "foovalue",
+            "bar", "barvalue"),
+        view.getParameters());
+
+    assertEquals("/b/?format=HTML&foo=foovalue&bar=barvalue", view.toUrl());
+    assertEquals(ImmutableList.of(ImmutableMap.of("text", "host", "url", "/b/?format=HTML")),
+        view.getBreadcrumbs());
+  }
+
+  public void testQueryParamsNotCopied() throws Exception {
+    GitilesView view = GitilesView.hostIndex().copyFrom(HOST)
+        .putParam("foo", "foovalue")
+        .putParam("bar", "barvalue")
+        .build();
+    GitilesView copy = GitilesView.hostIndex().copyFrom(view).build();
+    assertFalse(view.getParameters().isEmpty());
+    assertTrue(copy.getParameters().isEmpty());
+  }
+
+  public void testRepositoryIndex() throws Exception {
+    GitilesView view = GitilesView.repositoryIndex()
+        .copyFrom(HOST)
+        .setRepositoryName("foo/bar")
+        .build();
+
+    assertEquals("/b", view.getServletPath());
+    assertEquals(Type.REPOSITORY_INDEX, view.getType());
+    assertEquals("host", view.getHostName());
+    assertEquals("foo/bar", view.getRepositoryName());
+    assertEquals(Revision.NULL, view.getRevision());
+    assertNull(view.getTreePath());
+    assertTrue(HOST.getParameters().isEmpty());
+
+    assertEquals("/b/foo/bar/", view.toUrl());
+    assertEquals(
+        ImmutableList.of(
+            breadcrumb("host", "/b/?format=HTML"),
+            breadcrumb("foo/bar", "/b/foo/bar/")),
+        view.getBreadcrumbs());
+  }
+
+  public void testRefWithRevision() throws Exception {
+    ObjectId id = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
+    GitilesView view = GitilesView.revision()
+        .copyFrom(HOST)
+        .setRepositoryName("foo/bar")
+        .setRevision(Revision.unpeeled("master", id))
+        .build();
+
+    assertEquals("/b", view.getServletPath());
+    assertEquals(Type.REVISION, view.getType());
+    assertEquals("host", view.getHostName());
+    assertEquals("foo/bar", view.getRepositoryName());
+    assertEquals(id, view.getRevision().getId());
+    assertEquals("master", view.getRevision().getName());
+    assertNull(view.getTreePath());
+    assertTrue(HOST.getParameters().isEmpty());
+
+    assertEquals("/b/foo/bar/+show/master", view.toUrl());
+    assertEquals(
+        ImmutableList.of(
+            breadcrumb("host", "/b/?format=HTML"),
+            breadcrumb("foo/bar", "/b/foo/bar/"),
+            breadcrumb("master", "/b/foo/bar/+show/master")),
+        view.getBreadcrumbs());
+  }
+
+  public void testNoPathComponents() throws Exception {
+    ObjectId id = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
+    GitilesView view = GitilesView.path()
+        .copyFrom(HOST)
+        .setRepositoryName("foo/bar")
+        .setRevision(Revision.unpeeled("master", id))
+        .setTreePath("/")
+        .build();
+
+    assertEquals("/b", view.getServletPath());
+    assertEquals(Type.PATH, view.getType());
+    assertEquals("host", view.getHostName());
+    assertEquals("foo/bar", view.getRepositoryName());
+    assertEquals(id, view.getRevision().getId());
+    assertEquals("master", view.getRevision().getName());
+    assertEquals("", view.getTreePath());
+    assertTrue(HOST.getParameters().isEmpty());
+
+    assertEquals("/b/foo/bar/+/master/", view.toUrl());
+    assertEquals(
+        ImmutableList.of(
+            breadcrumb("host", "/b/?format=HTML"),
+            breadcrumb("foo/bar", "/b/foo/bar/"),
+            breadcrumb("master", "/b/foo/bar/+show/master"),
+            breadcrumb(".", "/b/foo/bar/+/master/")),
+        view.getBreadcrumbs());
+  }
+
+  public void testOnePathComponent() throws Exception {
+    ObjectId id = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
+    GitilesView view = GitilesView.path()
+        .copyFrom(HOST)
+        .setRepositoryName("foo/bar")
+        .setRevision(Revision.unpeeled("master", id))
+        .setTreePath("/file")
+        .build();
+
+    assertEquals("/b", view.getServletPath());
+    assertEquals(Type.PATH, view.getType());
+    assertEquals("host", view.getHostName());
+    assertEquals("foo/bar", view.getRepositoryName());
+    assertEquals(id, view.getRevision().getId());
+    assertEquals("master", view.getRevision().getName());
+    assertEquals("file", view.getTreePath());
+    assertTrue(HOST.getParameters().isEmpty());
+
+    assertEquals("/b/foo/bar/+/master/file", view.toUrl());
+    assertEquals(
+        ImmutableList.of(
+            breadcrumb("host", "/b/?format=HTML"),
+            breadcrumb("foo/bar", "/b/foo/bar/"),
+            breadcrumb("master", "/b/foo/bar/+show/master"),
+            breadcrumb(".", "/b/foo/bar/+/master/"),
+            breadcrumb("file", "/b/foo/bar/+/master/file")),
+        view.getBreadcrumbs());
+  }
+
+  public void testMultiplePathComponents() throws Exception {
+    ObjectId id = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
+    GitilesView view = GitilesView.path()
+        .copyFrom(HOST)
+        .setRepositoryName("foo/bar")
+        .setRevision(Revision.unpeeled("master", id))
+        .setTreePath("/path/to/a/file")
+        .build();
+
+    assertEquals("/b", view.getServletPath());
+    assertEquals(Type.PATH, view.getType());
+    assertEquals("host", view.getHostName());
+    assertEquals("foo/bar", view.getRepositoryName());
+    assertEquals(id, view.getRevision().getId());
+    assertEquals("master", view.getRevision().getName());
+    assertEquals("path/to/a/file", view.getTreePath());
+    assertTrue(HOST.getParameters().isEmpty());
+
+    assertEquals("/b/foo/bar/+/master/path/to/a/file", view.toUrl());
+    assertEquals(
+        ImmutableList.of(
+            breadcrumb("host", "/b/?format=HTML"),
+            breadcrumb("foo/bar", "/b/foo/bar/"),
+            breadcrumb("master", "/b/foo/bar/+show/master"),
+            breadcrumb(".", "/b/foo/bar/+/master/"),
+            breadcrumb("path", "/b/foo/bar/+/master/path"),
+            breadcrumb("to", "/b/foo/bar/+/master/path/to"),
+            breadcrumb("a", "/b/foo/bar/+/master/path/to/a"),
+            breadcrumb("file", "/b/foo/bar/+/master/path/to/a/file")),
+        view.getBreadcrumbs());
+  }
+
+  public void testDiffAgainstFirstParent() throws Exception {
+    ObjectId id = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
+    ObjectId parent = ObjectId.fromString("efab5678efab5678efab5678efab5678efab5678");
+    GitilesView view = GitilesView.diff()
+        .copyFrom(HOST)
+        .setRepositoryName("foo/bar")
+        .setRevision(Revision.unpeeled("master", id))
+        .setOldRevision(Revision.unpeeled("master^", parent))
+        .setTreePath("/path/to/a/file")
+        .build();
+
+    assertEquals("/b", view.getServletPath());
+    assertEquals(Type.DIFF, view.getType());
+    assertEquals("host", view.getHostName());
+    assertEquals("foo/bar", view.getRepositoryName());
+    assertEquals(id, view.getRevision().getId());
+    assertEquals("master", view.getRevision().getName());
+    assertEquals("master^", view.getOldRevision().getName());
+    assertEquals("path/to/a/file", view.getTreePath());
+    assertTrue(HOST.getParameters().isEmpty());
+
+    assertEquals("/b/foo/bar/+/master%5E%21/path/to/a/file", view.toUrl());
+    assertEquals(
+        ImmutableList.of(
+            breadcrumb("host", "/b/?format=HTML"),
+            breadcrumb("foo/bar", "/b/foo/bar/"),
+            breadcrumb("master^!", "/b/foo/bar/+/master%5E%21/"),
+            breadcrumb(".", "/b/foo/bar/+/master%5E%21/"),
+            breadcrumb("path", "/b/foo/bar/+/master%5E%21/path"),
+            breadcrumb("to", "/b/foo/bar/+/master%5E%21/path/to"),
+            breadcrumb("a", "/b/foo/bar/+/master%5E%21/path/to/a"),
+            breadcrumb("file", "/b/foo/bar/+/master%5E%21/path/to/a/file")),
+        view.getBreadcrumbs());
+  }
+
+  public void testDiffAgainstEmptyRevision() throws Exception {
+    ObjectId id = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
+    GitilesView view = GitilesView.diff()
+        .copyFrom(HOST)
+        .setRepositoryName("foo/bar")
+        .setRevision(Revision.unpeeled("master", id))
+        .setTreePath("/path/to/a/file")
+        .build();
+
+    assertEquals("/b", view.getServletPath());
+    assertEquals(Type.DIFF, view.getType());
+    assertEquals("host", view.getHostName());
+    assertEquals("foo/bar", view.getRepositoryName());
+    assertEquals(id, view.getRevision().getId());
+    assertEquals("master", view.getRevision().getName());
+    assertEquals(Revision.NULL, view.getOldRevision());
+    assertEquals("path/to/a/file", view.getTreePath());
+    assertTrue(HOST.getParameters().isEmpty());
+
+    assertEquals("/b/foo/bar/+/master%5E%21/path/to/a/file", view.toUrl());
+    assertEquals(
+        ImmutableList.of(
+            breadcrumb("host", "/b/?format=HTML"),
+            breadcrumb("foo/bar", "/b/foo/bar/"),
+            breadcrumb("master^!", "/b/foo/bar/+/master%5E%21/"),
+            breadcrumb(".", "/b/foo/bar/+/master%5E%21/"),
+            breadcrumb("path", "/b/foo/bar/+/master%5E%21/path"),
+            breadcrumb("to", "/b/foo/bar/+/master%5E%21/path/to"),
+            breadcrumb("a", "/b/foo/bar/+/master%5E%21/path/to/a"),
+            breadcrumb("file", "/b/foo/bar/+/master%5E%21/path/to/a/file")),
+        view.getBreadcrumbs());
+  }
+
+  public void testDiffAgainstOther() throws Exception {
+    ObjectId id = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
+    ObjectId other = ObjectId.fromString("efab5678efab5678efab5678efab5678efab5678");
+    GitilesView view = GitilesView.diff()
+        .copyFrom(HOST)
+        .setRepositoryName("foo/bar")
+        .setRevision(Revision.unpeeled("master", id))
+        .setOldRevision(Revision.unpeeled("efab5678", other))
+        .setTreePath("/path/to/a/file")
+        .build();
+
+    assertEquals("/b", view.getServletPath());
+    assertEquals(Type.DIFF, view.getType());
+    assertEquals("host", view.getHostName());
+    assertEquals("foo/bar", view.getRepositoryName());
+    assertEquals(id, view.getRevision().getId());
+    assertEquals("master", view.getRevision().getName());
+    assertEquals("efab5678", view.getOldRevision().getName());
+    assertEquals("path/to/a/file", view.getTreePath());
+    assertTrue(HOST.getParameters().isEmpty());
+
+    assertEquals("/b/foo/bar/+/efab5678..master/path/to/a/file", view.toUrl());
+    assertEquals(
+        ImmutableList.of(
+            breadcrumb("host", "/b/?format=HTML"),
+            breadcrumb("foo/bar", "/b/foo/bar/"),
+            breadcrumb("efab5678..master", "/b/foo/bar/+/efab5678..master/"),
+            breadcrumb(".", "/b/foo/bar/+/efab5678..master/"),
+            breadcrumb("path", "/b/foo/bar/+/efab5678..master/path"),
+            breadcrumb("to", "/b/foo/bar/+/efab5678..master/path/to"),
+            breadcrumb("a", "/b/foo/bar/+/efab5678..master/path/to/a"),
+            breadcrumb("file", "/b/foo/bar/+/efab5678..master/path/to/a/file")),
+        view.getBreadcrumbs());
+  }
+
+  public void testBranchLogWithoutPath() throws Exception {
+    ObjectId id = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
+    GitilesView view = GitilesView.log()
+        .copyFrom(HOST)
+        .setRepositoryName("foo/bar")
+        .setRevision(Revision.unpeeled("master", id))
+        .build();
+
+    assertEquals("/b", view.getServletPath());
+    assertEquals(Type.LOG, view.getType());
+    assertEquals("host", view.getHostName());
+    assertEquals("foo/bar", view.getRepositoryName());
+    assertEquals(id, view.getRevision().getId());
+    assertEquals("master", view.getRevision().getName());
+    assertEquals(Revision.NULL, view.getOldRevision());
+    assertNull(view.getTreePath());
+    assertTrue(HOST.getParameters().isEmpty());
+
+    assertEquals("/b/foo/bar/+/master", view.toUrl());
+    assertEquals(
+        ImmutableList.of(
+            breadcrumb("host", "/b/?format=HTML"),
+            breadcrumb("foo/bar", "/b/foo/bar/"),
+            breadcrumb("master", "/b/foo/bar/+/master")),
+        view.getBreadcrumbs());
+  }
+
+  public void testIdLogWithoutPath() throws Exception {
+    ObjectId id = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
+    GitilesView view = GitilesView.log()
+        .copyFrom(HOST)
+        .setRepositoryName("foo/bar")
+        .setRevision(Revision.unpeeled("abcd1234", id))
+        .build();
+
+    assertEquals("/b", view.getServletPath());
+    assertEquals(Type.LOG, view.getType());
+    assertEquals("host", view.getHostName());
+    assertEquals("foo/bar", view.getRepositoryName());
+    assertEquals(id, view.getRevision().getId());
+    assertEquals("abcd1234", view.getRevision().getName());
+    assertEquals(Revision.NULL, view.getOldRevision());
+    assertNull(view.getTreePath());
+    assertTrue(HOST.getParameters().isEmpty());
+
+    assertEquals("/b/foo/bar/+log/abcd1234", view.toUrl());
+    assertEquals(
+        ImmutableList.of(
+            breadcrumb("host", "/b/?format=HTML"),
+            breadcrumb("foo/bar", "/b/foo/bar/"),
+            breadcrumb("abcd1234", "/b/foo/bar/+log/abcd1234")),
+        view.getBreadcrumbs());
+  }
+
+  public void testLogWithoutOldRevision() throws Exception {
+    ObjectId id = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
+    GitilesView view = GitilesView.log()
+        .copyFrom(HOST)
+        .setRepositoryName("foo/bar")
+        .setRevision(Revision.unpeeled("master", id))
+        .setTreePath("/path/to/a/file")
+        .build();
+
+    assertEquals("/b", view.getServletPath());
+    assertEquals(Type.LOG, view.getType());
+    assertEquals("host", view.getHostName());
+    assertEquals("foo/bar", view.getRepositoryName());
+    assertEquals(id, view.getRevision().getId());
+    assertEquals("master", view.getRevision().getName());
+    assertEquals(Revision.NULL, view.getOldRevision());
+    assertEquals("path/to/a/file", view.getTreePath());
+    assertTrue(HOST.getParameters().isEmpty());
+
+    assertEquals("/b/foo/bar/+log/master/path/to/a/file", view.toUrl());
+    assertEquals(
+        ImmutableList.of(
+            breadcrumb("host", "/b/?format=HTML"),
+            breadcrumb("foo/bar", "/b/foo/bar/"),
+            breadcrumb("master", "/b/foo/bar/+/master"),
+            breadcrumb("path", "/b/foo/bar/+log/master/path"),
+            breadcrumb("to", "/b/foo/bar/+log/master/path/to"),
+            breadcrumb("a", "/b/foo/bar/+log/master/path/to/a"),
+            breadcrumb("file", "/b/foo/bar/+log/master/path/to/a/file")),
+        view.getBreadcrumbs());
+  }
+
+  public void testLogWithOldRevision() throws Exception {
+    ObjectId id = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
+    ObjectId parent = ObjectId.fromString("efab5678efab5678efab5678efab5678efab5678");
+    GitilesView view = GitilesView.log()
+        .copyFrom(HOST)
+        .setRepositoryName("foo/bar")
+        .setRevision(Revision.unpeeled("master", id))
+        .setOldRevision(Revision.unpeeled("master^", parent))
+        .setTreePath("/path/to/a/file")
+        .build();
+
+    assertEquals("/b", view.getServletPath());
+    assertEquals(Type.LOG, view.getType());
+    assertEquals("host", view.getHostName());
+    assertEquals("foo/bar", view.getRepositoryName());
+    assertEquals(id, view.getRevision().getId());
+    assertEquals("master", view.getRevision().getName());
+    assertEquals("master^", view.getOldRevision().getName());
+    assertEquals("path/to/a/file", view.getTreePath());
+    assertTrue(HOST.getParameters().isEmpty());
+
+    assertEquals("/b/foo/bar/+log/master%5E..master/path/to/a/file", view.toUrl());
+    assertEquals(
+        ImmutableList.of(
+            breadcrumb("host", "/b/?format=HTML"),
+            breadcrumb("foo/bar", "/b/foo/bar/"),
+            breadcrumb("master^..master", "/b/foo/bar/+log/master%5E..master"),
+            breadcrumb("path", "/b/foo/bar/+log/master%5E..master/path"),
+            breadcrumb("to", "/b/foo/bar/+log/master%5E..master/path/to"),
+            breadcrumb("a", "/b/foo/bar/+log/master%5E..master/path/to/a"),
+            breadcrumb("file", "/b/foo/bar/+log/master%5E..master/path/to/a/file")),
+        view.getBreadcrumbs());
+  }
+
+  public void testEscaping() throws Exception {
+    ObjectId id = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
+    ObjectId parent = ObjectId.fromString("efab5678efab5678efab5678efab5678efab5678");
+    // Some of these values are not valid for Git, but check them anyway.
+    GitilesView view = GitilesView.log()
+        .copyFrom(HOST)
+        .setRepositoryName("foo?bar")
+        .setRevision(Revision.unpeeled("ba/d#name", id))
+        .setOldRevision(Revision.unpeeled("other\"na/me", parent))
+        .setTreePath("we ird/pa'th/name")
+        .putParam("k e y", "val/ue")
+        .setAnchor("anc#hor")
+        .build();
+
+    // Fields returned by getters are not escaped.
+    assertEquals("/b", view.getServletPath());
+    assertEquals(Type.LOG, view.getType());
+    assertEquals("host", view.getHostName());
+    assertEquals("foo?bar", view.getRepositoryName());
+    assertEquals(id, view.getRevision().getId());
+    assertEquals("ba/d#name", view.getRevision().getName());
+    assertEquals(parent, view.getOldRevision().getId());
+    assertEquals("other\"na/me", view.getOldRevision().getName());
+    assertEquals("we ird/pa'th/name", view.getTreePath());
+    assertEquals(ImmutableListMultimap.<String, String> of("k e y", "val/ue"),
+        view.getParameters());
+
+    assertEquals(
+        "/b/foo%3Fbar/+log/other%22na/me..ba/d%23name/we%20ird/pa%27th/name"
+        + "?k+e+y=val%2Fue#anc%23hor", view.toUrl());
+    assertEquals(
+        ImmutableList.of(
+            // Names are not escaped (auto-escaped by Soy) but values are.
+            breadcrumb("host", "/b/?format=HTML"),
+            breadcrumb("foo?bar", "/b/foo%3Fbar/"),
+            breadcrumb("other\"na/me..ba/d#name", "/b/foo%3Fbar/+log/other%22na/me..ba/d%23name"),
+            breadcrumb("we ird", "/b/foo%3Fbar/+log/other%22na/me..ba/d%23name/we%20ird"),
+            breadcrumb("pa'th", "/b/foo%3Fbar/+log/other%22na/me..ba/d%23name/we%20ird/pa%27th"),
+            breadcrumb("name",
+              "/b/foo%3Fbar/+log/other%22na/me..ba/d%23name/we%20ird/pa%27th/name")),
+        view.getBreadcrumbs());
+  }
+
+  private static ImmutableMap<String, String> breadcrumb(String text, String url) {
+    return ImmutableMap.of("text", text, "url", url);
+  }
+}
diff --git a/gitiles-servlet/src/test/java/com/google/gitiles/LinkifierTest.java b/gitiles-servlet/src/test/java/com/google/gitiles/LinkifierTest.java
new file mode 100644
index 0000000..6f6caf2
--- /dev/null
+++ b/gitiles-servlet/src/test/java/com/google/gitiles/LinkifierTest.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 com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+
+import junit.framework.TestCase;
+
+import javax.servlet.http.HttpServletRequest;
+
+/** Tests for {@link Linkifier}. */
+public class LinkifierTest extends TestCase {
+  private static final HttpServletRequest REQ = FakeHttpServletRequest.newRequest();
+
+  @Override
+  protected void setUp() throws Exception {
+  }
+
+  public void testlinkifyMessageNoMatch() throws Exception {
+    Linkifier l = new Linkifier(TestGitilesUrls.URLS);
+    assertEquals(ImmutableList.of(ImmutableMap.of("text", "some message text")),
+        l.linkify(FakeHttpServletRequest.newRequest(), "some message text"));
+  }
+
+  public void testlinkifyMessageUrl() throws Exception {
+    Linkifier l = new Linkifier(TestGitilesUrls.URLS);
+    assertEquals(ImmutableList.of(
+        ImmutableMap.of("text", "http://my/url", "url", "http://my/url")),
+        l.linkify(REQ, "http://my/url"));
+    assertEquals(ImmutableList.of(
+        ImmutableMap.of("text", "https://my/url", "url", "https://my/url")),
+        l.linkify(REQ, "https://my/url"));
+    assertEquals(ImmutableList.of(
+        ImmutableMap.of("text", "foo "),
+        ImmutableMap.of("text", "http://my/url", "url", "http://my/url"),
+        ImmutableMap.of("text", " bar")),
+        l.linkify(REQ, "foo http://my/url bar"));
+    assertEquals(ImmutableList.of(
+        ImmutableMap.of("text", "foo "),
+        ImmutableMap.of("text", "http://my/url", "url", "http://my/url"),
+        ImmutableMap.of("text", " bar "),
+        ImmutableMap.of("text", "http://my/other/url", "url", "http://my/other/url"),
+        ImmutableMap.of("text", " baz")),
+        l.linkify(REQ, "foo http://my/url bar http://my/other/url baz"));
+  }
+
+  public void testlinkifyMessageChangeIdNoGerrit() throws Exception {
+    Linkifier l = new Linkifier(new GitilesUrls() {
+      @Override
+      public String getBaseGerritUrl(HttpServletRequest req) {
+        return null;
+      }
+
+      @Override
+      public String getHostName(HttpServletRequest req) {
+        throw new UnsupportedOperationException();
+      }
+
+      @Override
+      public String getBaseGitUrl(HttpServletRequest req) {
+        throw new UnsupportedOperationException();
+      }
+    });
+    assertEquals(ImmutableList.of(ImmutableMap.of("text", "I0123456789")),
+        l.linkify(REQ, "I0123456789"));
+    assertEquals(ImmutableList.of(ImmutableMap.of("text", "Change-Id: I0123456789")),
+        l.linkify(REQ, "Change-Id: I0123456789"));
+    assertEquals(ImmutableList.of(ImmutableMap.of("text", "Change-Id: I0123456789 does not exist")),
+        l.linkify(REQ, "Change-Id: I0123456789 does not exist"));
+  }
+
+  public void testlinkifyMessageChangeId() throws Exception {
+    Linkifier l = new Linkifier(TestGitilesUrls.URLS);
+    assertEquals(ImmutableList.of(
+        ImmutableMap.of("text", "I0123456789",
+          "url", "http://test-host-review/foo/#/q/I0123456789,n,z")),
+        l.linkify(REQ, "I0123456789"));
+    assertEquals(ImmutableList.of(
+        ImmutableMap.of("text", "Change-Id: "),
+        ImmutableMap.of("text", "I0123456789",
+          "url", "http://test-host-review/foo/#/q/I0123456789,n,z")),
+        l.linkify(REQ, "Change-Id: I0123456789"));
+    assertEquals(ImmutableList.of(
+        ImmutableMap.of("text", "Change-Id: "),
+        ImmutableMap.of("text", "I0123456789",
+          "url", "http://test-host-review/foo/#/q/I0123456789,n,z"),
+        ImmutableMap.of("text", " exists")),
+        l.linkify(REQ, "Change-Id: I0123456789 exists"));
+  }
+
+  public void testlinkifyMessageUrlAndChangeId() throws Exception {
+    Linkifier l = new Linkifier(TestGitilesUrls.URLS);
+    assertEquals(ImmutableList.of(
+        ImmutableMap.of("text", "http://my/url/I0123456789", "url", "http://my/url/I0123456789"),
+        ImmutableMap.of("text", " is not change "),
+        ImmutableMap.of("text", "I0123456789",
+          "url", "http://test-host-review/foo/#/q/I0123456789,n,z")),
+        l.linkify(REQ, "http://my/url/I0123456789 is not change I0123456789"));
+  }
+}
diff --git a/gitiles-servlet/src/test/java/com/google/gitiles/PaginatorTest.java b/gitiles-servlet/src/test/java/com/google/gitiles/PaginatorTest.java
new file mode 100644
index 0000000..09661f2
--- /dev/null
+++ b/gitiles-servlet/src/test/java/com/google/gitiles/PaginatorTest.java
@@ -0,0 +1,155 @@
+// 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 com.google.common.collect.ImmutableList;
+import com.google.common.collect.Lists;
+
+import junit.framework.TestCase;
+
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.storage.dfs.DfsRepository;
+import org.eclipse.jgit.storage.dfs.DfsRepositoryDescription;
+import org.eclipse.jgit.storage.dfs.InMemoryRepository;
+
+import java.util.List;
+
+/** Unit tests for {@link LogServlet}. */
+public class PaginatorTest extends TestCase {
+  private TestRepository<DfsRepository> repo;
+  private RevWalk walk;
+
+  @Override
+  protected void setUp() throws Exception {
+    repo = new TestRepository<DfsRepository>(
+        new InMemoryRepository(new DfsRepositoryDescription("test")));
+    walk = new RevWalk(repo.getRepository());
+  }
+
+  @Override
+  protected void tearDown() throws Exception {
+    walk.release();
+  }
+
+  public void testStart() throws Exception {
+    List<RevCommit> commits = linearCommits(10);
+    walk.markStart(commits.get(9));
+    Paginator p = new Paginator(walk, 3, commits.get(9));
+    assertEquals(
+        ImmutableList.of(
+            commits.get(9),
+            commits.get(8),
+            commits.get(7)),
+        ImmutableList.copyOf(p));
+    assertNull(p.getPreviousStart());
+    assertEquals(commits.get(6), p.getNextStart());
+  }
+
+  public void testNoStartCommit() throws Exception {
+    List<RevCommit> commits = linearCommits(10);
+    walk.markStart(commits.get(9));
+    Paginator p = new Paginator(walk, 3, null);
+    assertEquals(
+        ImmutableList.of(
+            commits.get(9),
+            commits.get(8),
+            commits.get(7)),
+        ImmutableList.copyOf(p));
+    assertNull(p.getPreviousStart());
+    assertEquals(commits.get(6), p.getNextStart());
+  }
+
+  public void testLessThanOnePageIn() throws Exception {
+    List<RevCommit> commits = linearCommits(10);
+    walk.markStart(commits.get(9));
+    Paginator p = new Paginator(walk, 3, commits.get(8));
+    assertEquals(
+        ImmutableList.of(
+            commits.get(8),
+            commits.get(7),
+            commits.get(6)),
+        ImmutableList.copyOf(p));
+    assertEquals(commits.get(9), p.getPreviousStart());
+    assertEquals(commits.get(5), p.getNextStart());
+  }
+
+  public void testAtLeastOnePageIn() throws Exception {
+    List<RevCommit> commits = linearCommits(10);
+    walk.markStart(commits.get(9));
+    Paginator p = new Paginator(walk, 3, commits.get(7));
+    assertEquals(
+        ImmutableList.of(
+            commits.get(7),
+            commits.get(6),
+            commits.get(5)),
+        ImmutableList.copyOf(p));
+    assertEquals(commits.get(9), p.getPreviousStart());
+    assertEquals(commits.get(4), p.getNextStart());
+  }
+
+  public void testEnd() throws Exception {
+    List<RevCommit> commits = linearCommits(10);
+    walk.markStart(commits.get(9));
+    Paginator p = new Paginator(walk, 3, commits.get(2));
+    assertEquals(
+        ImmutableList.of(
+            commits.get(2),
+            commits.get(1),
+            commits.get(0)),
+        ImmutableList.copyOf(p));
+    assertEquals(commits.get(5), p.getPreviousStart());
+    assertNull(p.getNextStart());
+  }
+
+  public void testOnePastEnd() throws Exception {
+    List<RevCommit> commits = linearCommits(10);
+    walk.markStart(commits.get(9));
+    Paginator p = new Paginator(walk, 3, commits.get(1));
+    assertEquals(
+        ImmutableList.of(
+            commits.get(1),
+            commits.get(0)),
+        ImmutableList.copyOf(p));
+    assertEquals(commits.get(4), p.getPreviousStart());
+    assertNull(p.getNextStart());
+  }
+
+  public void testManyPastEnd() throws Exception {
+    List<RevCommit> commits = linearCommits(10);
+    walk.markStart(commits.get(9));
+    Paginator p = new Paginator(walk, 5, commits.get(1));
+    assertEquals(
+        ImmutableList.of(
+            commits.get(1),
+            commits.get(0)),
+        ImmutableList.copyOf(p));
+    assertEquals(commits.get(6), p.getPreviousStart());
+    assertNull(p.getNextStart());
+  }
+
+  private List<RevCommit> linearCommits(int n) throws Exception {
+    checkArgument(n > 0);
+    List<RevCommit> commits = Lists.newArrayList();
+    commits.add(repo.commit().create());
+    for (int i = 1; i < 10; i++) {
+      commits.add(repo.commit().parent(commits.get(commits.size() - 1)).create());
+    }
+    return commits;
+  }
+}
diff --git a/gitiles-servlet/src/test/java/com/google/gitiles/RepositoryIndexServletTest.java b/gitiles-servlet/src/test/java/com/google/gitiles/RepositoryIndexServletTest.java
new file mode 100644
index 0000000..3071f70
--- /dev/null
+++ b/gitiles-servlet/src/test/java/com/google/gitiles/RepositoryIndexServletTest.java
@@ -0,0 +1,132 @@
+// 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.TestGitilesUrls.URLS;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+
+import junit.framework.TestCase;
+
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.storage.dfs.DfsRepository;
+import org.eclipse.jgit.storage.dfs.DfsRepositoryDescription;
+import org.eclipse.jgit.storage.dfs.InMemoryRepository;
+
+import java.io.IOException;
+import java.util.Map;
+
+import javax.servlet.http.HttpServletRequest;
+
+/** Tests for {@link RepositoryIndexServlet}. */
+public class RepositoryIndexServletTest extends TestCase {
+  private TestRepository<DfsRepository> repo;
+  private RepositoryIndexServlet servlet;
+
+  @Override
+  protected void setUp() throws Exception {
+    repo = new TestRepository<DfsRepository>(
+        new InMemoryRepository(new DfsRepositoryDescription("test")));
+    servlet = new RepositoryIndexServlet(
+        new DefaultRenderer(),
+        new TestGitilesAccess(repo.getRepository()));
+  }
+
+  public void testEmpty() throws Exception {
+    Map<String, ?> data = buildData();
+    assertEquals(ImmutableList.of(), data.get("branches"));
+    assertEquals(ImmutableList.of(), data.get("tags"));
+  }
+
+  public void testBranchesAndTags() throws Exception {
+    repo.branch("refs/heads/foo").commit().create();
+    repo.branch("refs/heads/bar").commit().create();
+    repo.branch("refs/tags/baz").commit().create();
+    repo.branch("refs/nope/quux").commit().create();
+    Map<String, ?> data = buildData();
+
+    assertEquals(
+        ImmutableList.of(
+            ref("/b/test/+/bar", "bar"),
+            ref("/b/test/+/foo", "foo")),
+        data.get("branches"));
+    assertEquals(
+        ImmutableList.of(
+            ref("/b/test/+/baz", "baz")),
+        data.get("tags"));
+  }
+
+  public void testAmbiguousBranch() throws Exception {
+    repo.branch("refs/heads/foo").commit().create();
+    repo.branch("refs/heads/bar").commit().create();
+    repo.branch("refs/tags/foo").commit().create();
+    Map<String, ?> data = buildData();
+
+    assertEquals(
+        ImmutableList.of(
+            ref("/b/test/+/bar", "bar"),
+            ref("/b/test/+/refs/heads/foo", "foo")),
+        data.get("branches"));
+    assertEquals(
+        ImmutableList.of(
+            // refs/tags/ is searched before refs/heads/, so this does not
+            // appear ambiguous.
+            ref("/b/test/+/foo", "foo")),
+        data.get("tags"));
+  }
+
+  public void testAmbiguousRelativeToNonBranchOrTag() throws Exception {
+    repo.branch("refs/foo").commit().create();
+    repo.branch("refs/heads/foo").commit().create();
+    repo.branch("refs/tags/foo").commit().create();
+    Map<String, ?> data = buildData();
+
+    assertEquals(
+        ImmutableList.of(
+            ref("/b/test/+/refs/heads/foo", "foo")),
+        data.get("branches"));
+    assertEquals(
+        ImmutableList.of(
+            ref("/b/test/+/refs/tags/foo", "foo")),
+        data.get("tags"));
+  }
+
+  public void testRefsHeads() throws Exception {
+    repo.branch("refs/heads/foo").commit().create();
+    repo.branch("refs/heads/refs/heads/foo").commit().create();
+    Map<String, ?> data = buildData();
+
+    assertEquals(
+        ImmutableList.of(
+            ref("/b/test/+/foo", "foo"),
+            ref("/b/test/+/refs/heads/refs/heads/foo", "refs/heads/foo")),
+        data.get("branches"));
+  }
+
+  private Map<String, ?> buildData() throws IOException {
+    HttpServletRequest req = FakeHttpServletRequest.newRequest(repo.getRepository());
+    ViewFilter.setView(req, GitilesView.repositoryIndex()
+        .setHostName(URLS.getHostName(req))
+        .setServletPath(req.getServletPath())
+        .setRepositoryName("test")
+        .build());
+    return servlet.buildData(req);
+  }
+
+  private Map<String, String> ref(String url, String name) {
+    return ImmutableMap.of("url", url, "name", name);
+  }
+}
diff --git a/gitiles-servlet/src/test/java/com/google/gitiles/RevisionParserTest.java b/gitiles-servlet/src/test/java/com/google/gitiles/RevisionParserTest.java
new file mode 100644
index 0000000..23033e0
--- /dev/null
+++ b/gitiles-servlet/src/test/java/com/google/gitiles/RevisionParserTest.java
@@ -0,0 +1,273 @@
+// 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_BLOB;
+import static org.eclipse.jgit.lib.Constants.OBJ_COMMIT;
+import static org.eclipse.jgit.lib.Constants.OBJ_TAG;
+
+import com.google.common.cache.CacheBuilder;
+import com.google.gitiles.RevisionParser.Result;
+
+import junit.framework.TestCase;
+
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.revwalk.RevBlob;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevTag;
+import org.eclipse.jgit.storage.dfs.DfsRepository;
+import org.eclipse.jgit.storage.dfs.DfsRepositoryDescription;
+import org.eclipse.jgit.storage.dfs.InMemoryRepository;
+
+/** Tests for the revision parser. */
+public class RevisionParserTest extends TestCase {
+  private TestRepository<DfsRepository> repo;
+  private RevisionParser parser;
+
+  @Override
+  protected void setUp() throws Exception {
+    repo = new TestRepository<DfsRepository>(
+        new InMemoryRepository(new DfsRepositoryDescription("test")));
+    parser = new RevisionParser(
+        repo.getRepository(),
+        new TestGitilesAccess(repo.getRepository()).forRequest(null),
+        new VisibilityCache(false, CacheBuilder.newBuilder().maximumSize(0)));
+  }
+
+  public void testParseRef() throws Exception {
+    RevCommit master = repo.branch("refs/heads/master").commit().create();
+    assertEquals(new Result(Revision.peeled("master", master)),
+        parser.parse("master"));
+    assertEquals(new Result(Revision.peeled("refs/heads/master", master)),
+        parser.parse("refs/heads/master"));
+    assertNull(parser.parse("refs//heads//master"));
+  }
+
+  public void testParseRefParentExpression() throws Exception {
+    RevCommit root = repo.commit().create();
+    RevCommit parent1 = repo.commit().parent(root).create();
+    RevCommit parent2 = repo.commit().parent(root).create();
+    RevCommit merge = repo.branch("master").commit()
+        .parent(parent1)
+        .parent(parent2)
+        .create();
+    assertEquals(new Result(Revision.peeled("master", merge)), parser.parse("master"));
+    assertEquals(new Result(Revision.peeled("master^", parent1)), parser.parse("master^"));
+    assertEquals(new Result(Revision.peeled("master~1", parent1)), parser.parse("master~1"));
+    assertEquals(new Result(Revision.peeled("master^2", parent2)), parser.parse("master^2"));
+    assertEquals(new Result(Revision.peeled("master~2", root)), parser.parse("master~2"));
+  }
+
+  public void testParseCommitShaVisibleFromHead() throws Exception {
+    RevCommit parent = repo.commit().create();
+    RevCommit commit = repo.branch("master").commit().parent(parent).create();
+    assertEquals(new Result(Revision.peeled(commit.name(), commit)), parser.parse(commit.name()));
+    assertEquals(new Result(Revision.peeled(parent.name(), parent)), parser.parse(parent.name()));
+
+    String abbrev = commit.name().substring(0, 6);
+    assertEquals(new Result(Revision.peeled(abbrev, commit)), parser.parse(abbrev));
+  }
+
+  public void testParseCommitShaVisibleFromTag() throws Exception {
+    RevCommit parent = repo.commit().create();
+    RevCommit commit = repo.commit().parent(parent).create();
+    repo.branch("master").commit().create();
+    repo.update("refs/tags/tag", repo.tag("tag", commit));
+
+    assertEquals(new Result(Revision.peeled(commit.name(), commit)), parser.parse(commit.name()));
+    assertEquals(new Result(Revision.peeled(parent.name(), parent)), parser.parse(parent.name()));
+  }
+
+  public void testParseCommitShaVisibleFromOther() throws Exception {
+    RevCommit parent = repo.commit().create();
+    RevCommit commit = repo.commit().parent(parent).create();
+    repo.branch("master").commit().create();
+    repo.update("refs/tags/tag", repo.tag("tag", repo.commit().create()));
+    repo.update("refs/meta/config", commit);
+
+    assertEquals(new Result(Revision.peeled(commit.name(), commit)), parser.parse(commit.name()));
+    assertEquals(new Result(Revision.peeled(parent.name(), parent)), parser.parse(parent.name()));
+  }
+
+  public void testParseCommitShaVisibleFromChange() throws Exception {
+    RevCommit parent = repo.commit().create();
+    RevCommit commit = repo.commit().parent(parent).create();
+    repo.branch("master").commit().create();
+    repo.update("refs/changes/01/0001", commit);
+
+    // Matches exactly.
+    assertEquals(new Result(Revision.peeled(commit.name(), commit)), parser.parse(commit.name()));
+    // refs/changes/* is excluded from ancestry search.
+    assertEquals(null, parser.parse(parent.name()));
+  }
+
+  public void testParseNonVisibleCommitSha() throws Exception {
+    RevCommit other = repo.commit().create();
+    RevCommit master = repo.branch("master").commit().create();
+    assertEquals(null, parser.parse(other.name()));
+
+    repo.branch("other").update(other);
+    assertEquals(new Result(Revision.peeled(other.name(), other)), parser.parse(other.name()));
+  }
+
+  public void testParseDiffRevisions() throws Exception {
+    RevCommit parent = repo.commit().create();
+    RevCommit commit = repo.branch("master").commit().parent(parent).create();
+    RevCommit other = repo.branch("other").commit().create();
+
+    assertEquals(
+        new Result(
+            Revision.peeled("master", commit),
+            Revision.peeled("master^", parent),
+            15),
+        parser.parse("master^..master"));
+    assertEquals(
+        new Result(
+            Revision.peeled("master", commit),
+            Revision.peeled("master^", parent),
+            15),
+        parser.parse("master^..master/"));
+    assertEquals(
+        new Result(
+            Revision.peeled("master", commit),
+            Revision.peeled("master^", parent),
+            15),
+        parser.parse("master^..master/path/to/a/file"));
+    assertEquals(
+        new Result(
+            Revision.peeled("master", commit),
+            Revision.peeled("master^", parent),
+            15),
+        parser.parse("master^..master/path/to/a/..file"));
+    assertEquals(
+        new Result(
+            Revision.peeled("refs/heads/master", commit),
+            Revision.peeled("refs/heads/master^", parent),
+            37),
+      parser.parse("refs/heads/master^..refs/heads/master"));
+    assertEquals(
+        new Result(
+            Revision.peeled("master", commit),
+            Revision.peeled("master~1", parent),
+            16),
+        parser.parse("master~1..master"));
+    // TODO(dborowitz): 2a2362fbb in JGit causes master~2 to resolve to master
+    // rather than null. Uncomment when upstream regression is fixed.
+    //assertNull(parser.parse("master~2..master"));
+    assertEquals(
+        new Result(
+            Revision.peeled("master", commit),
+            Revision.peeled("other", other),
+            13),
+        parser.parse("other..master"));
+  }
+
+  public void testParseFirstParentExpression() throws Exception {
+    RevCommit parent = repo.commit().create();
+    RevCommit commit = repo.branch("master").commit().parent(parent).create();
+
+    assertEquals(
+        new Result(
+            Revision.peeled("master", commit),
+            Revision.peeled("master^", parent),
+            8),
+        parser.parse("master^!"));
+    assertEquals(
+        new Result(
+            Revision.peeled("master^", parent),
+            Revision.NULL,
+            9),
+        parser.parse("master^^!"));
+    assertEquals(
+        new Result(
+            Revision.peeled(parent.name(), parent),
+            Revision.NULL,
+            42),
+        parser.parse(parent.name() + "^!"));
+
+    RevTag tag = repo.update("refs/tags/tag", repo.tag("tag", commit));
+    assertEquals(
+        new Result(
+            Revision.peeled("tag", commit),
+            Revision.peeled("tag^", parent),
+            5),
+        parser.parse("tag^!"));
+    assertEquals(
+        new Result(
+            Revision.peeled("tag^", parent),
+            Revision.NULL,
+            6),
+        parser.parse("tag^^!"));
+  }
+
+  public void testNonVisibleDiffShas() throws Exception {
+    RevCommit other = repo.commit().create();
+    RevCommit master = repo.branch("master").commit().create();
+    assertEquals(null, parser.parse("other..master"));
+    assertEquals(null, parser.parse("master..other"));
+
+    repo.branch("other").update(other);
+    assertEquals(
+        new Result(
+            Revision.peeled("master", master),
+            Revision.peeled("other", other),
+            13),
+        parser.parse("other..master"));
+    assertEquals(
+        new Result(
+            Revision.peeled("other", other),
+            Revision.peeled("master", master),
+            13),
+        parser.parse("master..other"));
+  }
+
+  public void testParseTag() throws Exception {
+    RevCommit master = repo.branch("master").commit().create();
+    RevTag masterTag = repo.update("refs/tags/master-tag", repo.tag("master-tag", master));
+    RevTag masterTagTag = repo.update("refs/tags/master-tag-tag",
+        repo.tag("master-tag-tag", master));
+
+    assertEquals(new Result(
+            new Revision("master-tag", masterTag, OBJ_TAG, master, OBJ_COMMIT)),
+        parser.parse("master-tag"));
+    assertEquals(new Result(
+            new Revision("master-tag-tag", masterTagTag, OBJ_TAG, master, OBJ_COMMIT)),
+        parser.parse("master-tag-tag"));
+
+    RevBlob blob = repo.update("refs/tags/blob", repo.blob("blob"));
+    RevTag blobTag = repo.update("refs/tags/blob-tag", repo.tag("blob-tag", blob));
+    assertEquals(new Result(Revision.peeled("blob", blob)), parser.parse("blob"));
+    assertEquals(new Result(new Revision("blob-tag", blobTag, OBJ_TAG, blob, OBJ_BLOB)),
+        parser.parse("blob-tag"));
+  }
+
+  public void testParseUnsupportedRevisionExpressions() throws Exception {
+    RevBlob blob = repo.blob("blob contents");
+    RevCommit master = repo.branch("master").commit().add("blob", blob).create();
+
+    assertEquals(master, repo.getRepository().resolve("master^{}"));
+    assertEquals(null, parser.parse("master^{}"));
+
+    assertEquals(master, repo.getRepository().resolve("master^{commit}"));
+    assertEquals(null, parser.parse("master^{commit}"));
+
+    assertEquals(blob, repo.getRepository().resolve("master:blob"));
+    assertEquals(null, parser.parse("master:blob"));
+
+    // TestRepository has no simple way of setting the reflog.
+    //assertEquals(null, repo.getRepository().resolve("master@{0}"));
+    assertEquals(null, parser.parse("master@{0}"));
+  }
+}
diff --git a/gitiles-servlet/src/test/java/com/google/gitiles/TestGitilesAccess.java b/gitiles-servlet/src/test/java/com/google/gitiles/TestGitilesAccess.java
new file mode 100644
index 0000000..0b31e24
--- /dev/null
+++ b/gitiles-servlet/src/test/java/com/google/gitiles/TestGitilesAccess.java
@@ -0,0 +1,64 @@
+// 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 org.eclipse.jgit.storage.dfs.DfsRepository;
+
+import java.util.Map;
+import java.util.Set;
+
+import javax.servlet.http.HttpServletRequest;
+
+/** Gitiles access for testing. */
+public class TestGitilesAccess implements GitilesAccess.Factory {
+  private final DfsRepository repo;
+
+  public TestGitilesAccess(DfsRepository repo) {
+    this.repo = checkNotNull(repo);
+  }
+
+  @Override
+  public GitilesAccess forRequest(final HttpServletRequest req) {
+    return new GitilesAccess() {
+      @Override
+      public Map<String, RepositoryDescription> listRepositories(Set<String> branches) {
+        // TODO(dborowitz): Implement this, using the DfsRepositoryDescriptions to
+        // get the repository names.
+        throw new UnsupportedOperationException();
+      }
+
+      @Override
+      public Object getUserKey() {
+        return "a user";
+      }
+
+      @Override
+      public String getRepositoryName() {
+        return repo.getDescription().getRepositoryName();
+      }
+
+      @Override
+      public RepositoryDescription getRepositoryDescription() {
+        RepositoryDescription d = new RepositoryDescription();
+        d.name = getRepositoryName();
+        d.description = "a test data set";
+        d.cloneUrl = TestGitilesUrls.URLS.getBaseGitUrl(req) + "/" + d.name;
+        return d;
+      }
+    };
+  }
+}
diff --git a/gitiles-servlet/src/test/java/com/google/gitiles/TestGitilesUrls.java b/gitiles-servlet/src/test/java/com/google/gitiles/TestGitilesUrls.java
new file mode 100644
index 0000000..f8a0883
--- /dev/null
+++ b/gitiles-servlet/src/test/java/com/google/gitiles/TestGitilesUrls.java
@@ -0,0 +1,40 @@
+// 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 javax.servlet.http.HttpServletRequest;
+
+/** {@link GitilesUrls} for testing. */
+public class TestGitilesUrls implements GitilesUrls {
+  public static final GitilesUrls URLS = new TestGitilesUrls();
+
+  @Override
+  public String getHostName(HttpServletRequest req) {
+    return "test-host";
+  }
+
+  @Override
+  public String getBaseGitUrl(HttpServletRequest req) {
+    return "git://test-host/foo";
+  }
+
+  @Override
+  public String getBaseGerritUrl(HttpServletRequest req) {
+    return "http://test-host-review/foo/";
+  }
+
+  private TestGitilesUrls() {
+  }
+}
diff --git a/gitiles-servlet/src/test/java/com/google/gitiles/TreeSoyDataTest.java b/gitiles-servlet/src/test/java/com/google/gitiles/TreeSoyDataTest.java
new file mode 100644
index 0000000..85423d9
--- /dev/null
+++ b/gitiles-servlet/src/test/java/com/google/gitiles/TreeSoyDataTest.java
@@ -0,0 +1,61 @@
+// 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.getTargetDisplayName;
+import static com.google.gitiles.TreeSoyData.resolveTargetUrl;
+
+import com.google.common.base.Strings;
+
+import junit.framework.TestCase;
+
+import org.eclipse.jgit.lib.ObjectId;
+
+/** Tests for {@link TreeSoyData}. */
+public class TreeSoyDataTest extends TestCase {
+  public void testGetTargetDisplayName() throws Exception {
+    assertEquals("foo", getTargetDisplayName("foo"));
+    assertEquals("foo/bar", getTargetDisplayName("foo/bar"));
+    assertEquals("a/a/a/a/a/a/a/a/a/a/bar",
+        getTargetDisplayName(Strings.repeat("a/", 10) + "bar"));
+    assertEquals("a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/bar",
+        getTargetDisplayName(Strings.repeat("a/", 34) + "bar"));
+    assertEquals(".../bar", getTargetDisplayName(Strings.repeat("a/", 35) + "bar"));
+    assertEquals(".../bar", getTargetDisplayName(Strings.repeat("a/", 100) + "bar"));
+    assertEquals("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+        getTargetDisplayName(Strings.repeat("a", 80)));
+  }
+
+  public void testResolveTargetUrl() throws Exception {
+    ObjectId id = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
+    GitilesView view = GitilesView.path()
+        .setServletPath("/x")
+        .setHostName("host")
+        .setRepositoryName("repo")
+        .setRevision(Revision.unpeeled("m", id))
+        .setTreePath("a/b/c")
+        .build();
+    assertNull(resolveTargetUrl(view, "/foo"));
+    assertEquals("/x/repo/+/m/a", resolveTargetUrl(view, "../../"));
+    assertEquals("/x/repo/+/m/a", resolveTargetUrl(view, ".././../"));
+    assertEquals("/x/repo/+/m/a", resolveTargetUrl(view, "..//../"));
+    assertEquals("/x/repo/+/m/a/d", resolveTargetUrl(view, "../../d"));
+    assertEquals("/x/repo/+/m/", resolveTargetUrl(view, "../../.."));
+    assertEquals("/x/repo/+/m/a/d/e", resolveTargetUrl(view, "../../d/e"));
+    assertEquals("/x/repo/+/m/a/b", resolveTargetUrl(view, "../d/../e/../"));
+    assertNull(resolveTargetUrl(view, "../../../../"));
+    assertNull(resolveTargetUrl(view, "../../a/../../.."));
+  }
+}
diff --git a/gitiles-servlet/src/test/java/com/google/gitiles/ViewFilterTest.java b/gitiles-servlet/src/test/java/com/google/gitiles/ViewFilterTest.java
new file mode 100644
index 0000000..1427b9e
--- /dev/null
+++ b/gitiles-servlet/src/test/java/com/google/gitiles/ViewFilterTest.java
@@ -0,0 +1,346 @@
+// 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.FakeHttpServletRequest.newRequest;
+import static com.google.gitiles.GitilesFilter.REPO_PATH_REGEX;
+import static com.google.gitiles.GitilesFilter.REPO_REGEX;
+import static com.google.gitiles.GitilesFilter.ROOT_REGEX;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.util.concurrent.Atomics;
+import com.google.gitiles.GitilesView.Type;
+
+import junit.framework.TestCase;
+
+import org.eclipse.jgit.http.server.glue.MetaFilter;
+import org.eclipse.jgit.http.server.glue.MetaServlet;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.storage.dfs.DfsRepository;
+import org.eclipse.jgit.storage.dfs.DfsRepositoryDescription;
+import org.eclipse.jgit.storage.dfs.InMemoryRepository;
+
+import java.io.IOException;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.regex.Pattern;
+
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServlet;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+/** Tests for the view filter. */
+public class ViewFilterTest extends TestCase {
+  private TestRepository<DfsRepository> repo;
+
+  @Override
+  protected void setUp() throws Exception {
+    repo = new TestRepository<DfsRepository>(
+        new InMemoryRepository(new DfsRepositoryDescription("test")));
+  }
+
+  public void testNoCommand() throws Exception {
+    assertEquals(Type.HOST_INDEX, getView("/").getType());
+    assertEquals(Type.REPOSITORY_INDEX, getView("/repo").getType());
+    assertNull(getView("/repo/+"));
+    assertNull(getView("/repo/+/"));
+  }
+
+  public void testAutoCommand() throws Exception {
+    RevCommit parent = repo.commit().create();
+    RevCommit master = repo.branch("refs/heads/master").commit().parent(parent).create();
+    String hex = master.name();
+    String hexBranch = hex.substring(0, 10);
+    RevCommit hexCommit = repo.branch(hexBranch).commit().create();
+
+    assertEquals(Type.LOG, getView("/repo/+/master").getType());
+    assertEquals(Type.LOG, getView("/repo/+/" + hexBranch).getType());
+    assertEquals(Type.REVISION, getView("/repo/+/" + hex).getType());
+    assertEquals(Type.REVISION, getView("/repo/+/" + hex.substring(0, 7)).getType());
+    assertEquals(Type.PATH, getView("/repo/+/master/").getType());
+    assertEquals(Type.PATH, getView("/repo/+/" + hex + "/").getType());
+    assertEquals(Type.DIFF, getView("/repo/+/master^..master").getType());
+    assertEquals(Type.DIFF, getView("/repo/+/master^..master/").getType());
+    assertEquals(Type.DIFF, getView("/repo/+/" + parent.name() + ".." + hex + "/").getType());
+  }
+
+  public void testHostIndex() throws Exception {
+    GitilesView view = getView("/");
+    assertEquals(Type.HOST_INDEX, view.getType());
+    assertEquals("test-host", view.getHostName());
+    assertNull(view.getRepositoryName());
+    assertEquals(Revision.NULL, view.getRevision());
+    assertEquals(Revision.NULL, view.getOldRevision());
+    assertNull(view.getTreePath());
+  }
+
+  public void testRepositoryIndex() throws Exception {
+    GitilesView view = getView("/repo");
+    assertEquals(Type.REPOSITORY_INDEX, view.getType());
+    assertEquals("repo", view.getRepositoryName());
+    assertEquals(Revision.NULL, view.getRevision());
+    assertEquals(Revision.NULL, view.getOldRevision());
+    assertNull(view.getTreePath());
+  }
+
+  public void testBranches() throws Exception {
+    RevCommit master = repo.branch("refs/heads/master").commit().create();
+    RevCommit stable = repo.branch("refs/heads/stable").commit().create();
+    GitilesView view;
+
+    view = getView("/repo/+show/master");
+    assertEquals(Type.REVISION, view.getType());
+    assertEquals("master", view.getRevision().getName());
+    assertEquals(master, view.getRevision().getId());
+    assertNull(view.getTreePath());
+
+    view = getView("/repo/+show/heads/master");
+    assertEquals(Type.REVISION, view.getType());
+    assertEquals("heads/master", view.getRevision().getName());
+    assertEquals(master, view.getRevision().getId());
+    assertNull(view.getTreePath());
+
+    view = getView("/repo/+show/refs/heads/master");
+    assertEquals(Type.REVISION, view.getType());
+    assertEquals("refs/heads/master", view.getRevision().getName());
+    assertEquals(master, view.getRevision().getId());
+    assertNull(view.getTreePath());
+
+    view = getView("/repo/+show/stable");
+    assertEquals(Type.REVISION, view.getType());
+    assertEquals("stable", view.getRevision().getName());
+    assertEquals(stable, view.getRevision().getId());
+    assertNull(view.getTreePath());
+  }
+
+  public void testAmbiguousBranchAndTag() throws Exception {
+    RevCommit branch = repo.branch("refs/heads/name").commit().create();
+    RevCommit tag = repo.branch("refs/tags/name").commit().create();
+    GitilesView view;
+
+    view = getView("/repo/+show/name");
+    assertEquals(Type.REVISION, view.getType());
+    assertEquals("name", view.getRevision().getName());
+    assertEquals(tag, view.getRevision().getId());
+    assertNull(view.getTreePath());
+
+    view = getView("/repo/+show/heads/name");
+    assertEquals(Type.REVISION, view.getType());
+    assertEquals("heads/name", view.getRevision().getName());
+    assertEquals(branch, view.getRevision().getId());
+    assertNull(view.getTreePath());
+
+    view = getView("/repo/+show/refs/heads/name");
+    assertEquals(Type.REVISION, view.getType());
+    assertEquals("refs/heads/name", view.getRevision().getName());
+    assertEquals(branch, view.getRevision().getId());
+    assertNull(view.getTreePath());
+
+    view = getView("/repo/+show/tags/name");
+    assertEquals(Type.REVISION, view.getType());
+    assertEquals("tags/name", view.getRevision().getName());
+    assertEquals(tag, view.getRevision().getId());
+    assertNull(view.getTreePath());
+
+    view = getView("/repo/+show/refs/tags/name");
+    assertEquals(Type.REVISION, view.getType());
+    assertEquals("refs/tags/name", view.getRevision().getName());
+    assertEquals(tag, view.getRevision().getId());
+    assertNull(view.getTreePath());
+  }
+
+  public void testPath() throws Exception {
+    RevCommit master = repo.branch("refs/heads/master").commit().create();
+    GitilesView view;
+
+    view = getView("/repo/+show/master/");
+    assertEquals(Type.PATH, view.getType());
+    assertEquals(master, view.getRevision().getId());
+    assertEquals("", view.getTreePath());
+
+    view = getView("/repo/+show/master/foo");
+    assertEquals(Type.PATH, view.getType());
+    assertEquals(master, view.getRevision().getId());
+    assertEquals("foo", view.getTreePath());
+
+    view = getView("/repo/+show/master/foo/");
+    assertEquals(Type.PATH, view.getType());
+    assertEquals(master, view.getRevision().getId());
+    assertEquals("foo", view.getTreePath());
+
+    view = getView("/repo/+show/master/foo/bar");
+    assertEquals(Type.PATH, view.getType());
+    assertEquals(master, view.getRevision().getId());
+    assertEquals("foo/bar", view.getTreePath());
+  }
+
+  public void testMultipleSlashes() throws Exception {
+    RevCommit master = repo.branch("refs/heads/master").commit().create();
+    GitilesView view;
+
+    assertEquals(Type.HOST_INDEX, getView("//").getType());
+    assertEquals(Type.REPOSITORY_INDEX, getView("//repo").getType());
+    assertEquals(Type.REPOSITORY_INDEX, getView("//repo//").getType());
+    assertNull(getView("/repo/+//master"));
+    assertNull(getView("/repo/+/refs//heads//master"));
+    assertNull(getView("/repo/+//master//"));
+    assertNull(getView("/repo/+//master/foo//bar"));
+  }
+
+  public void testDiff() throws Exception {
+    RevCommit parent = repo.commit().create();
+    RevCommit master = repo.branch("refs/heads/master").commit().parent(parent).create();
+    GitilesView view;
+
+    view = getView("/repo/+diff/master^..master");
+    assertEquals(Type.DIFF, view.getType());
+    assertEquals("master", view.getRevision().getName());
+    assertEquals(master, view.getRevision().getId());
+    assertEquals("master^", view.getOldRevision().getName());
+    assertEquals(parent, view.getOldRevision().getId());
+    assertEquals("", view.getTreePath());
+
+    view = getView("/repo/+diff/master^..master/");
+    assertEquals(Type.DIFF, view.getType());
+    assertEquals("master", view.getRevision().getName());
+    assertEquals(master, view.getRevision().getId());
+    assertEquals("master^", view.getOldRevision().getName());
+    assertEquals(parent, view.getOldRevision().getId());
+    assertEquals("", view.getTreePath());
+
+    view = getView("/repo/+diff/master^..master/foo");
+    assertEquals(Type.DIFF, view.getType());
+    assertEquals("master", view.getRevision().getName());
+    assertEquals(master, view.getRevision().getId());
+    assertEquals("master^", view.getOldRevision().getName());
+    assertEquals(parent, view.getOldRevision().getId());
+    assertEquals("foo", view.getTreePath());
+
+    view = getView("/repo/+diff/refs/heads/master^..refs/heads/master");
+    assertEquals(Type.DIFF, view.getType());
+    assertEquals("refs/heads/master", view.getRevision().getName());
+    assertEquals(master, view.getRevision().getId());
+    assertEquals("refs/heads/master^", view.getOldRevision().getName());
+    assertEquals(parent, view.getOldRevision().getId());
+    assertEquals("", view.getTreePath());
+  }
+
+  public void testDiffAgainstEmptyCommit() throws Exception {
+    RevCommit master = repo.branch("refs/heads/master").commit().create();
+    GitilesView view = getView("/repo/+diff/master^!");
+    assertEquals(Type.DIFF, view.getType());
+    assertEquals("master", view.getRevision().getName());
+    assertEquals(master, view.getRevision().getId());
+    assertEquals(Revision.NULL, view.getOldRevision());
+    assertEquals("", view.getTreePath());
+  }
+
+  public void testLog() throws Exception {
+    RevCommit parent = repo.commit().create();
+    RevCommit master = repo.branch("refs/heads/master").commit().parent(parent).create();
+    GitilesView view;
+
+    assertNull(getView("/repo/+log"));
+    assertNull(getView("/repo/+log/"));
+
+    view = getView("/repo/+log/master");
+    assertEquals(Type.LOG, view.getType());
+    assertEquals("master", view.getRevision().getName());
+    assertEquals(master, view.getRevision().getId());
+    assertEquals(Revision.NULL, view.getOldRevision());
+    assertEquals("", view.getTreePath());
+
+    view = getView("/repo/+log/master/");
+    assertEquals(Type.LOG, view.getType());
+    assertEquals("master", view.getRevision().getName());
+    assertEquals(master, view.getRevision().getId());
+    assertEquals(Revision.NULL, view.getOldRevision());
+    assertEquals("", view.getTreePath());
+
+    view = getView("/repo/+log/master/foo");
+    assertEquals(Type.LOG, view.getType());
+    assertEquals("master", view.getRevision().getName());
+    assertEquals(master, view.getRevision().getId());
+    assertEquals(Revision.NULL, view.getOldRevision());
+    assertEquals("foo", view.getTreePath());
+
+    view = getView("/repo/+log/master^..master");
+    assertEquals(Type.LOG, view.getType());
+    assertEquals("master", view.getRevision().getName());
+    assertEquals(master, view.getRevision().getId());
+    assertEquals("master^", view.getOldRevision().getName());
+    assertEquals(parent, view.getOldRevision().getId());
+    assertEquals("", view.getTreePath());
+
+    view = getView("/repo/+log/master^..master/");
+    assertEquals(Type.LOG, view.getType());
+    assertEquals("master", view.getRevision().getName());
+    assertEquals(master, view.getRevision().getId());
+    assertEquals("master^", view.getOldRevision().getName());
+    assertEquals(parent, view.getOldRevision().getId());
+    assertEquals("", view.getTreePath());
+
+    view = getView("/repo/+log/master^..master/foo");
+    assertEquals(Type.LOG, view.getType());
+    assertEquals("master", view.getRevision().getName());
+    assertEquals(master, view.getRevision().getId());
+    assertEquals("master^", view.getOldRevision().getName());
+    assertEquals(parent, view.getOldRevision().getId());
+    assertEquals("foo", view.getTreePath());
+
+    view = getView("/repo/+log/refs/heads/master^..refs/heads/master");
+    assertEquals(Type.LOG, view.getType());
+    assertEquals("refs/heads/master", view.getRevision().getName());
+    assertEquals(master, view.getRevision().getId());
+    assertEquals("refs/heads/master^", view.getOldRevision().getName());
+    assertEquals(parent, view.getOldRevision().getId());
+    assertEquals("", view.getTreePath());
+  }
+
+  private GitilesView getView(String pathAndQuery) throws ServletException, IOException {
+    final AtomicReference<GitilesView> view = Atomics.newReference();
+    HttpServlet testServlet = new HttpServlet() {
+      @Override
+      protected void doGet(HttpServletRequest req, HttpServletResponse res) {
+        view.set(ViewFilter.getView(req));
+      }
+    };
+
+    ViewFilter vf = new ViewFilter(
+        new TestGitilesAccess(repo.getRepository()),
+        TestGitilesUrls.URLS,
+        new VisibilityCache(false));
+    MetaFilter mf = new MetaFilter();
+
+    for (Pattern p : ImmutableList.of(ROOT_REGEX, REPO_REGEX, REPO_PATH_REGEX)) {
+      mf.serveRegex(p)
+          .through(vf)
+          .with(testServlet);
+    }
+
+    FakeHttpServletRequest req = newRequest(repo.getRepository());
+    int q = pathAndQuery.indexOf('?');
+    if (q > 0) {
+      req.setPathInfo(pathAndQuery.substring(0, q));
+      req.setQueryString(pathAndQuery.substring(q + 1));
+    } else {
+      req.setPathInfo(pathAndQuery);
+    }
+    new MetaServlet(mf){}.service(req, new FakeHttpServletResponse());
+
+    return view.get();
+  }
+}
diff --git a/gitiles-war/pom.xml b/gitiles-war/pom.xml
new file mode 100644
index 0000000..9ad21f1
--- /dev/null
+++ b/gitiles-war/pom.xml
@@ -0,0 +1,73 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+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.
+-->
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+  xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
+  <modelVersion>4.0.0</modelVersion>
+
+  <parent>
+    <groupId>com.google.gitiles</groupId>
+    <artifactId>gitiles-parent</artifactId>
+    <version>1.0-SNAPSHOT</version>
+  </parent>
+
+  <artifactId>gitiles-war</artifactId>
+  <packaging>war</packaging>
+  <name>Gitiles - WAR</name>
+
+  <description>
+    Gitiles packaged as a standard web application archive
+  </description>
+
+  <dependencies>
+    <dependency>
+      <groupId>com.google.gitiles</groupId>
+      <artifactId>gitiles-servlet</artifactId>
+      <version>${project.version}</version>
+      <scope>runtime</scope>
+    </dependency>
+
+    <dependency>
+      <groupId>org.apache.tomcat</groupId>
+      <artifactId>servlet-api</artifactId>
+      <scope>provided</scope>
+    </dependency>
+
+    <dependency>
+      <groupId>org.eclipse.jetty</groupId>
+      <artifactId>jetty-server</artifactId>
+      <version>${jettyVersion}</version>
+      <scope>provided</scope>
+    </dependency>
+  </dependencies>
+
+  <build>
+    <plugins>
+      <plugin>
+        <groupId>org.apache.maven.plugins</groupId>
+        <artifactId>maven-war-plugin</artifactId>
+        <configuration>
+          <webResources>
+            <resource>
+              <directory>../gitiles-servlet/src/main/resources/com/google/gitiles/static</directory>
+              <targetPath>+static</targetPath>
+            </resource>
+          </webResources>
+        </configuration>
+      </plugin>
+    </plugins>
+  </build>
+</project>
diff --git a/gitiles-war/src/main/webapp/WEB-INF/web.xml b/gitiles-war/src/main/webapp/WEB-INF/web.xml
new file mode 100644
index 0000000..b4ad57d
--- /dev/null
+++ b/gitiles-war/src/main/webapp/WEB-INF/web.xml
@@ -0,0 +1,35 @@
+<!DOCTYPE web-app PUBLIC
+ "-//Sun Microsystems, Inc.//DTD Web Application 2.3//EN"
+ "http://java.sun.com/dtd/web-app_2_3.dtd" >
+<!--
+  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.
+-->
+<web-app>
+  <display-name>Gitiles</display-name>
+
+  <servlet>
+    <servlet-name>gitiles</servlet-name>
+    <servlet-class>com.google.gitiles.GitilesServlet</servlet-class>
+    <init-param>
+      <param-name>configPath</param-name>
+      <param-value>gitiles.config</param-value>
+    </init-param>
+  </servlet>
+
+  <servlet-mapping>
+    <servlet-name>gitiles</servlet-name>
+    <url-pattern>/*</url-pattern>
+  </servlet-mapping>
+</web-app>
diff --git a/gitiles-war/webdefault.xml b/gitiles-war/webdefault.xml
new file mode 100644
index 0000000..8eaddc4
--- /dev/null
+++ b/gitiles-war/webdefault.xml
@@ -0,0 +1,28 @@
+<!DOCTYPE web-app PUBLIC
+ "-//Sun Microsystems, Inc.//DTD Web Application 2.3//EN"
+ "http://java.sun.com/dtd/web-app_2_3.dtd" >
+<!--
+  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.
+-->
+<web-app>
+  <servlet>
+    <servlet-name>default</servlet-name>
+    <servlet-class>org.eclipse.jetty.servlet.DefaultServlet</servlet-class>
+  </servlet>
+  <servlet-mapping>
+    <servlet-name>default</servlet-name>
+    <url-pattern>/+static/*</url-pattern>
+  </servlet-mapping>
+</web-app>
diff --git a/pom.xml b/pom.xml
new file mode 100644
index 0000000..aedba11
--- /dev/null
+++ b/pom.xml
@@ -0,0 +1,204 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+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.
+-->
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+  xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
+  <modelVersion>4.0.0</modelVersion>
+
+  <groupId>com.google.gitiles</groupId>
+  <artifactId>gitiles-parent</artifactId>
+  <packaging>pom</packaging>
+  <version>1.0-SNAPSHOT</version>
+
+  <name>Gitiles - Parent</name>
+  <url>https://gerrit.googlesource.com/gitiles</url>
+
+  <description>
+    Gitiles - Simple Git Repository Browser
+  </description>
+
+  <properties>
+    <!-- Should track Gerrit's jgitVersion fairly closely. -->
+    <jgitVersion>2.0.0.201206130900-r.129-ge63f1c9</jgitVersion>
+    <jettyVersion>7.5.2.v20111006</jettyVersion>
+  </properties>
+
+  <modules>
+    <module>gitiles-servlet</module>
+    <module>gitiles-war</module>
+  </modules>
+
+  <dependencyManagement>
+    <dependencies>
+      <dependency>
+        <groupId>com.google.guava</groupId>
+        <artifactId>guava</artifactId>
+        <version>13.0</version>
+      </dependency>
+
+      <dependency>
+        <groupId>com.google.template</groupId>
+        <artifactId>soy</artifactId>
+        <version>2011-22-12</version>
+      </dependency>
+
+      <dependency>
+        <groupId>org.eclipse.jgit</groupId>
+        <artifactId>org.eclipse.jgit</artifactId>
+        <version>${jgitVersion}</version>
+      </dependency>
+
+      <dependency>
+        <groupId>org.eclipse.jgit</groupId>
+        <artifactId>org.eclipse.jgit.http.server</artifactId>
+        <version>${jgitVersion}</version>
+      </dependency>
+
+      <dependency>
+        <groupId>org.eclipse.jgit</groupId>
+        <artifactId>org.eclipse.jgit.junit</artifactId>
+        <version>${jgitVersion}</version>
+        <exclusions>
+          <exclusion>
+            <groupId>org.eclipse.jgit</groupId>
+            <artifactId>org.eclipse.jgit</artifactId>
+          </exclusion>
+        </exclusions>
+      </dependency>
+
+      <dependency>
+        <groupId>org.apache.tomcat</groupId>
+        <artifactId>servlet-api</artifactId>
+        <version>6.0.29</version>
+      </dependency>
+
+      <dependency>
+        <groupId>junit</groupId>
+        <artifactId>junit</artifactId>
+        <version>3.8.1</version>
+      </dependency>
+
+      <dependency>
+        <groupId>org.slf4j</groupId>
+        <artifactId>slf4j-api</artifactId>
+        <version>1.6.1</version>
+      </dependency>
+
+      <dependency>
+        <groupId>joda-time</groupId>
+        <artifactId>joda-time</artifactId>
+        <version>2.1</version>
+      </dependency>
+
+      <dependency>
+        <groupId>com.google.code.gson</groupId>
+        <artifactId>gson</artifactId>
+        <version>2.1</version>
+      </dependency>
+
+      <dependency>
+        <groupId>org.apache.commons</groupId>
+        <artifactId>commons-lang3</artifactId>
+        <version>3.1</version>
+      </dependency>
+
+      <dependency>
+        <groupId>org.eclipse.jetty</groupId>
+        <artifactId>jetty-server</artifactId>
+        <version>${jettyVersion}</version>
+      </dependency>
+    </dependencies>
+  </dependencyManagement>
+
+  <build>
+    <pluginManagement>
+      <plugins>
+        <plugin>
+          <groupId>org.apache.maven.plugins</groupId>
+          <artifactId>maven-compiler-plugin</artifactId>
+          <version>2.3.2</version>
+          <configuration>
+            <source>1.6</source>
+            <target>1.6</target>
+            <encoding>UTF-8</encoding>
+          </configuration>
+        </plugin>
+
+        <plugin>
+          <groupId>org.apache.maven.plugins</groupId>
+          <artifactId>maven-jar-plugin</artifactId>
+          <configuration>
+            <archive>
+              <manifestEntries>
+                <Implementation-Title>Gitiles - ${project.artifactId}</Implementation-Title>
+                <Implementation-Version>${project.version}</Implementation-Version>
+                <Implementation-Vendor>Gitiles</Implementation-Vendor>
+                <Implementation-Vendor-Id>com.google.gitiles</Implementation-Vendor-Id>
+                <Implementation-Vendor-URL>https://gerrit.googlesource.com/gitiles/</Implementation-Vendor-URL>
+              </manifestEntries>
+            </archive>
+          </configuration>
+        </plugin>
+
+        <plugin>
+          <groupId>org.apache.maven.plugins</groupId>
+          <artifactId>maven-war-plugin</artifactId>
+          <version>2.1.1</version>
+        </plugin>
+
+        <plugin>
+          <groupId>org.apache.maven.plugins</groupId>
+          <artifactId>maven-source-plugin</artifactId>
+          <version>2.1.2</version>
+        </plugin>
+      </plugins>
+    </pluginManagement>
+
+    <plugins>
+      <plugin>
+        <groupId>org.mortbay.jetty</groupId>
+        <artifactId>jetty-maven-plugin</artifactId>
+        <version>${jettyVersion}</version>
+        <configuration>
+          <webApp>
+            <defaultsDescriptor>gitiles-war/webdefault.xml</defaultsDescriptor>
+          </webApp>
+          <war>gitiles-war/target/gitiles-war-${project.version}.war</war>
+        </configuration>
+        <!-- TODO(dborowitz): Separate execution with reloadable static
+          resources and templates. -->
+      </plugin>
+    </plugins>
+  </build>
+
+  <repositories>
+    <!-- For JGit and Soy snapshots. -->
+    <repository>
+      <id>gerrit-maven</id>
+      <url>https://gerrit-maven.commondatastorage.googleapis.com</url>
+    </repository>
+
+    <repository>
+      <id>jgit-repository</id>
+      <url>http://download.eclipse.org/jgit/maven</url>
+    </repository>
+
+    <repository>
+      <id>java.net-repository</id>
+      <url>http://download.java.net/maven/2/</url>
+    </repository>
+  </repositories>
+</project>