Gitiles is a simple browser for Git repositories

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

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

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

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

Change-Id: I0ab8875b6c50f7df03b9a42b4a60923a4827bde7
diff --git a/gitiles-servlet/src/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();
+  }
+}