Merge branch 'stable-0.2'

* stable-0.2:
  Bump version to 0.2-10
  Navbar: Fix handling of [home] and [logo] metalinks

Change-Id: Idf61c0bc4af1923f0bb4732350e9dc7d2d92864d
diff --git a/WORKSPACE b/WORKSPACE
index 1d6e531..3ee16c0 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -46,8 +46,8 @@
 
 maven_jar(
     name = "guava",
-    artifact = "com.google.guava:guava:27.1-jre",
-    sha1 = "e47b59c893079b87743cdcfb6f17ca95c08c592c",
+    artifact = "com.google.guava:guava:28.0-jre",
+    sha1 = "54fed371b4b8a8cce1e94a9abd9620982d3aa54b",
 )
 
 maven_jar(
@@ -130,8 +130,8 @@
 
 maven_jar(
     name = "soy",
-    artifact = "com.google.template:soy:2019-03-11",
-    sha1 = "119ac4b3eb0e2c638526ca99374013965c727097",
+    artifact = "com.google.template:soy:2019-04-18",
+    sha1 = "5750208855562d74f29eee39ee497d5cf6df1490",
 )
 
 maven_jar(
@@ -194,17 +194,17 @@
 # corresponding version
 maven_jar(
     name = "commons-compress",
-    artifact = "org.apache.commons:commons-compress:1.15",
-    sha1 = "b686cd04abaef1ea7bc5e143c080563668eec17e",
+    artifact = "org.apache.commons:commons-compress:1.18",
+    sha1 = "1191f9f2bc0c47a8cce69193feb1ff0a8bcb37d5",
 )
 
 # Transitive dependency of commons_compress. Should only be
 # upgraded at the same time as commons_compress.
 maven_jar(
     name = "tukaani-xz",
-    artifact = "org.tukaani:xz:1.6",
+    artifact = "org.tukaani:xz:1.8",
     attach_source = False,
-    sha1 = "05b6f921f1810bdf90e25471968f741f87168b64",
+    sha1 = "c4f7d054303948eb6a4066194253886c8af07128",
 )
 
 maven_jar(
@@ -259,46 +259,46 @@
     sha1 = "6975da39a7040257bd51d21a231b76c915872d38",
 )
 
-JETTY_VERSION = "9.4.12.v20180830"
+JETTY_VERSION = "9.4.18.v20190429"
 
 maven_jar(
     name = "servlet",
     artifact = "org.eclipse.jetty:jetty-servlet:" + JETTY_VERSION,
-    sha1 = "4c1149328eda9fa39a274262042420f66d9ffd5f",
+    sha1 = "290f7a88f351950d51ebc9fb4a794752c62d7de5",
 )
 
 maven_jar(
     name = "security",
     artifact = "org.eclipse.jetty:jetty-security:" + JETTY_VERSION,
-    sha1 = "299e0602a9c0b753ba232cc1c1dda72ddd9addcf",
+    sha1 = "01aceff3608ca1b223bfd275a497797cfe675ef4",
 )
 
 maven_jar(
     name = "server",
     artifact = "org.eclipse.jetty:jetty-server:" + JETTY_VERSION,
-    sha1 = "b0f25df0d32a445fd07d5f16fff1411c16b888fa",
+    sha1 = "b76ef50e04635f11d4d43bc6ccb7c4482a8384f0",
 )
 
 maven_jar(
     name = "continuation",
     artifact = "org.eclipse.jetty:jetty-continuation:" + JETTY_VERSION,
-    sha1 = "5f6d6e06f95088a3a7118b9065bc49ce7c014b75",
+    sha1 = "3c421a3be5be5805e32b1a7f9c6046526524181d",
 )
 
 maven_jar(
     name = "http",
     artifact = "org.eclipse.jetty:jetty-http:" + JETTY_VERSION,
-    sha1 = "1341796dde4e16df69bca83f3e87688ba2e7d703",
+    sha1 = "c2e73db2db5c369326b717da71b6587b3da11e0e",
 )
 
 maven_jar(
     name = "io",
     artifact = "org.eclipse.jetty:jetty-io:" + JETTY_VERSION,
-    sha1 = "e93f5adaa35a9a6a85ba130f589c5305c6ecc9e3",
+    sha1 = "844af5efe58ab23fd0166a796efef123f4cb06b0",
 )
 
 maven_jar(
     name = "util",
     artifact = "org.eclipse.jetty:jetty-util:" + JETTY_VERSION,
-    sha1 = "cb4ccec9bd1fe4b10a04a0fb25d7053c1050188a",
+    sha1 = "13e6148bfda7ae511f69ae7e5e3ea898bc9b0e33",
 )
diff --git a/java/com/google/gitiles/ArchiveFormat.java b/java/com/google/gitiles/ArchiveFormat.java
index 76c4efc..8e2ad8e 100644
--- a/java/com/google/gitiles/ArchiveFormat.java
+++ b/java/com/google/gitiles/ArchiveFormat.java
@@ -53,7 +53,9 @@
     }
   }
 
+  @SuppressWarnings("ImmutableEnumChecker") // ArchiveCommand.Format is effectively immutable.
   private final ArchiveCommand.Format<?> format;
+
   private final String mimeType;
 
   ArchiveFormat(String mimeType, ArchiveCommand.Format<?> format) {
diff --git a/java/com/google/gitiles/BaseServlet.java b/java/com/google/gitiles/BaseServlet.java
index 02a3f1a..6a2e5eb 100644
--- a/java/com/google/gitiles/BaseServlet.java
+++ b/java/com/google/gitiles/BaseServlet.java
@@ -145,6 +145,7 @@
    *
    * @param req in-progress request.
    * @param res in-progress response.
+   * @throws IOException if there was an error rendering the result.
    */
   protected void doGetHtml(HttpServletRequest req, HttpServletResponse res) throws IOException {
     throw new GitilesRequestFailureException(FailureReason.UNSUPPORTED_RESPONSE_FORMAT);
@@ -155,6 +156,7 @@
    *
    * @param req in-progress request.
    * @param res in-progress response.
+   * @throws IOException if there was an error rendering the result.
    */
   protected void doGetText(HttpServletRequest req, HttpServletResponse res) throws IOException {
     throw new GitilesRequestFailureException(FailureReason.UNSUPPORTED_RESPONSE_FORMAT);
@@ -165,6 +167,7 @@
    *
    * @param req in-progress request.
    * @param res in-progress response.
+   * @throws IOException if there was an error rendering the result.
    */
   protected void doGetJson(HttpServletRequest req, HttpServletResponse res) throws IOException {
     throw new GitilesRequestFailureException(FailureReason.UNSUPPORTED_RESPONSE_FORMAT);
diff --git a/java/com/google/gitiles/DateFormatter.java b/java/com/google/gitiles/DateFormatter.java
index b14483f..69e8a86 100644
--- a/java/com/google/gitiles/DateFormatter.java
+++ b/java/com/google/gitiles/DateFormatter.java
@@ -14,6 +14,7 @@
 
 package com.google.gitiles;
 
+import com.google.common.annotations.VisibleForTesting;
 import java.io.IOException;
 import java.text.DateFormat;
 import java.text.SimpleDateFormat;
@@ -63,7 +64,8 @@
   private final Optional<TimeZone> fixedTz;
   private final Format format;
 
-  public DateFormatter(Optional<TimeZone> fixedTz, Format format) {
+  @VisibleForTesting
+  protected DateFormatter(Optional<TimeZone> fixedTz, Format format) {
     this.fixedTz = fixedTz;
     this.format = format;
   }
diff --git a/java/com/google/gitiles/DefaultErrorHandlingFilter.java b/java/com/google/gitiles/DefaultErrorHandlingFilter.java
index 958b800..f558c0d 100644
--- a/java/com/google/gitiles/DefaultErrorHandlingFilter.java
+++ b/java/com/google/gitiles/DefaultErrorHandlingFilter.java
@@ -13,12 +13,12 @@
 // limitations under the License.
 package com.google.gitiles;
 
-import static javax.servlet.http.HttpServletResponse.SC_BAD_REQUEST;
-import static javax.servlet.http.HttpServletResponse.SC_INTERNAL_SERVER_ERROR;
-import static javax.servlet.http.HttpServletResponse.SC_NOT_FOUND;
-import static org.eclipse.jgit.http.server.GitSmartHttpTools.sendError;
+import static java.nio.charset.StandardCharsets.UTF_8;
 
+import com.google.common.collect.ImmutableMap;
+import com.google.gitiles.GitilesRequestFailureException.FailureReason;
 import java.io.IOException;
+import java.util.Map;
 import javax.servlet.FilterChain;
 import javax.servlet.ServletException;
 import javax.servlet.http.HttpServletRequest;
@@ -36,28 +36,56 @@
   /** HTTP header that indicates an error detail. */
   public static final String GITILES_ERROR = "X-Gitiles-Error";
 
+  private Renderer renderer;
+
+  public DefaultErrorHandlingFilter(Renderer renderer) {
+    this.renderer = renderer;
+  }
+
   @Override
   public void doFilter(HttpServletRequest req, HttpServletResponse res, FilterChain chain)
       throws IOException, ServletException {
+    int status = -1;
+    String message = null;
     try {
       chain.doFilter(req, res);
     } catch (GitilesRequestFailureException e) {
       res.setHeader(GITILES_ERROR, e.getReason().toString());
-      String publicMessage = e.getPublicErrorMessage();
-      if (publicMessage != null) {
-        res.sendError(e.getReason().getHttpStatusCode(), publicMessage);
-      } else {
-        res.sendError(e.getReason().getHttpStatusCode());
-      }
+      status = e.getReason().getHttpStatusCode();
+      message = e.getPublicErrorMessage();
     } catch (RepositoryNotFoundException e) {
-      res.sendError(SC_NOT_FOUND);
+      status = FailureReason.REPOSITORY_NOT_FOUND.getHttpStatusCode();
+      message = FailureReason.REPOSITORY_NOT_FOUND.getMessage();
     } catch (AmbiguousObjectException e) {
-      res.sendError(SC_BAD_REQUEST);
+      status = FailureReason.AMBIGUOUS_OBJECT.getHttpStatusCode();
+      message = FailureReason.AMBIGUOUS_OBJECT.getMessage();
     } catch (ServiceMayNotContinueException e) {
-      sendError(req, res, e.getStatusCode(), e.getMessage());
+      status = e.getStatusCode();
+      message = e.getMessage();
     } catch (IOException | ServletException err) {
       log.warn("Internal server error", err);
-      res.sendError(SC_INTERNAL_SERVER_ERROR);
+      status = FailureReason.INTERNAL_SERVER_ERROR.getHttpStatusCode();
+      message = FailureReason.INTERNAL_SERVER_ERROR.getMessage();
     }
+    if (status != -1) {
+      res.setStatus(status);
+      renderHtml(req, res, "gitiles.error", ImmutableMap.of("title", message));
+    }
+  }
+
+  protected void renderHtml(
+      HttpServletRequest req, HttpServletResponse res, String templateName, Map<String, ?> soyData)
+      throws IOException {
+    renderer.render(req, res, templateName, startHtmlResponse(req, res, soyData));
+  }
+
+  private Map<String, ?> startHtmlResponse(
+      HttpServletRequest req, HttpServletResponse res, Map<String, ?> soyData) throws IOException {
+    res.setContentType(FormatType.HTML.getMimeType());
+    res.setCharacterEncoding(UTF_8.name());
+    BaseServlet.setNotCacheable(res);
+    Map<String, Object> allData = BaseServlet.getData(req);
+    allData.putAll(soyData);
+    return allData;
   }
 }
diff --git a/java/com/google/gitiles/DiffServlet.java b/java/com/google/gitiles/DiffServlet.java
index 5b1801d..5a9f07b 100644
--- a/java/com/google/gitiles/DiffServlet.java
+++ b/java/com/google/gitiles/DiffServlet.java
@@ -152,7 +152,7 @@
     if (newCommit.getParentCount() > 0) {
       return Arrays.asList(newCommit.getParents()).contains(oldRevision.getId());
     }
-    return oldRevision == Revision.NULL;
+    return Revision.isNull(oldRevision);
   }
 
   private static boolean isFile(TreeWalk tw) {
diff --git a/java/com/google/gitiles/GitilesFilter.java b/java/com/google/gitiles/GitilesFilter.java
index 2c810bb..254fe22 100644
--- a/java/com/google/gitiles/GitilesFilter.java
+++ b/java/com/google/gitiles/GitilesFilter.java
@@ -420,7 +420,7 @@
 
   private void setDefaultErrorHandler() {
     if (errorHandler == null) {
-      errorHandler = new DefaultErrorHandlingFilter();
+      errorHandler = new DefaultErrorHandlingFilter(renderer);
     }
   }
 
diff --git a/java/com/google/gitiles/GitilesRequestFailureException.java b/java/com/google/gitiles/GitilesRequestFailureException.java
index 316c023..dd990a8 100644
--- a/java/com/google/gitiles/GitilesRequestFailureException.java
+++ b/java/com/google/gitiles/GitilesRequestFailureException.java
@@ -20,6 +20,7 @@
 import static javax.servlet.http.HttpServletResponse.SC_NOT_FOUND;
 import static javax.servlet.http.HttpServletResponse.SC_UNAUTHORIZED;
 
+import java.util.Optional;
 import javax.annotation.Nullable;
 
 /**
@@ -112,53 +113,64 @@
 
   @Nullable
   public String getPublicErrorMessage() {
-    return publicErrorMessage;
+    return Optional.ofNullable(publicErrorMessage).orElse(reason.getMessage());
   }
 
   /** The request failure reason. */
   public enum FailureReason {
     /** The object specified by the URL is ambiguous and Gitiles cannot identify one object. */
-    AMBIGUOUS_OBJECT(SC_BAD_REQUEST),
+    AMBIGUOUS_OBJECT(
+        SC_BAD_REQUEST,
+        "The object specified by the URL is ambiguous and Gitiles cannot identify one object"),
     /** There's nothing to show for blame (e.g. the file is empty). */
-    BLAME_REGION_NOT_FOUND(SC_NOT_FOUND),
+    BLAME_REGION_NOT_FOUND(SC_NOT_FOUND, "There's nothing to show for blame"),
     /** Cannot parse URL as a Gitiles URL. */
-    CANNOT_PARSE_GITILES_VIEW(SC_NOT_FOUND),
+    CANNOT_PARSE_GITILES_VIEW(SC_NOT_FOUND, "Cannot parse URL as a Gitiles URL"),
     /** URL parameters are not valid. */
-    INCORECT_PARAMETER(SC_BAD_REQUEST),
+    INCORECT_PARAMETER(SC_BAD_REQUEST, "URL parameters are not valid"),
     /**
      * The object specified by the URL is not suitable for the view (e.g. trying to show a blob as a
      * tree).
      */
-    INCORRECT_OBJECT_TYPE(SC_BAD_REQUEST),
+    INCORRECT_OBJECT_TYPE(
+        SC_BAD_REQUEST, "The object specified by the URL is not suitable for the view"),
     /** Markdown rendering is not enabled. */
-    MARKDOWN_NOT_ENABLED(SC_NOT_FOUND),
+    MARKDOWN_NOT_ENABLED(SC_NOT_FOUND, "Markdown rendering is not enabled"),
     /** Request is not authorized. */
-    NOT_AUTHORIZED(SC_UNAUTHORIZED),
+    NOT_AUTHORIZED(SC_UNAUTHORIZED, "Request is not authorized"),
     /** Object is not found. */
-    OBJECT_NOT_FOUND(SC_NOT_FOUND),
+    OBJECT_NOT_FOUND(SC_NOT_FOUND, "Object is not found"),
     /** Object is too large to show. */
-    OBJECT_TOO_LARGE(SC_INTERNAL_SERVER_ERROR),
+    OBJECT_TOO_LARGE(SC_INTERNAL_SERVER_ERROR, "Object is too large to show"),
     /** Repository is not found. */
-    REPOSITORY_NOT_FOUND(SC_NOT_FOUND),
+    REPOSITORY_NOT_FOUND(SC_NOT_FOUND, "Repository is not found"),
     /** Gitiles is not enabled for the repository. */
-    SERVICE_NOT_ENABLED(SC_FORBIDDEN),
+    SERVICE_NOT_ENABLED(SC_FORBIDDEN, "Gitiles is not enabled for the repository"),
     /** GitWeb URL cannot be converted to Gitiles URL. */
-    UNSUPPORTED_GITWEB_URL(SC_GONE),
+    UNSUPPORTED_GITWEB_URL(SC_GONE, "GitWeb URL cannot be converted to Gitiles URL"),
     /** The specified object's type is not supported. */
-    UNSUPPORTED_OBJECT_TYPE(SC_NOT_FOUND),
+    UNSUPPORTED_OBJECT_TYPE(SC_NOT_FOUND, "The specified object's type is not supported"),
     /** The specified format type is not supported. */
-    UNSUPPORTED_RESPONSE_FORMAT(SC_BAD_REQUEST),
+    UNSUPPORTED_RESPONSE_FORMAT(SC_BAD_REQUEST, "The specified format type is not supported"),
     /** The specified revision names are not supported. */
-    UNSUPPORTED_REVISION_NAMES(SC_BAD_REQUEST);
+    UNSUPPORTED_REVISION_NAMES(SC_BAD_REQUEST, "The specified revision names are not supported"),
+    /** Internal server error. */
+    INTERNAL_SERVER_ERROR(SC_INTERNAL_SERVER_ERROR, "Internal server error");
 
     private final int httpStatusCode;
+    private final String message;
 
-    FailureReason(int httpStatusCode) {
+    FailureReason(int httpStatusCode, String message) {
       this.httpStatusCode = httpStatusCode;
+      this.message = message;
     }
 
     public int getHttpStatusCode() {
       return httpStatusCode;
     }
+
+    public String getMessage() {
+      return message;
+    }
   }
 }
diff --git a/java/com/google/gitiles/GitilesView.java b/java/com/google/gitiles/GitilesView.java
index 02020ee..e721c26 100644
--- a/java/com/google/gitiles/GitilesView.java
+++ b/java/com/google/gitiles/GitilesView.java
@@ -228,7 +228,7 @@
     public Builder setOldRevision(Revision revision) {
       if (type != Type.DIFF && type != Type.LOG) {
         revision = firstNonNull(revision, Revision.NULL);
-        checkState(revision == Revision.NULL, "cannot set old revision on %s view", type);
+        checkState(Revision.isNull(revision), "cannot set old revision on %s view", type);
       }
       this.oldRevision = revision;
       return this;
@@ -399,7 +399,7 @@
     }
 
     private void checkRevision() {
-      checkView(revision != Revision.NULL, "missing revision on %s view", type);
+      checkView(!Revision.isNull(revision), "missing revision on %s view", type);
       checkRepositoryIndex();
     }
 
@@ -427,7 +427,7 @@
     private void checkRootedDoc() {
       checkView(hostName != null, "missing hostName on %s view", type);
       checkView(servletPath != null, "missing hostName on %s view", type);
-      checkView(revision != Revision.NULL, "missing revision on %s view", type);
+      checkView(!Revision.isNull(revision), "missing revision on %s view", type);
       checkView(path != null, "missing path on %s view", type);
     }
   }
@@ -561,7 +561,7 @@
   }
 
   public String getRevisionRange() {
-    if (oldRevision == Revision.NULL) {
+    if (Revision.isNull(oldRevision)) {
       if (type == Type.LOG || type == Type.DIFF) {
         // For types that require two revisions, NULL indicates the empty
         // tree/commit.
@@ -676,9 +676,9 @@
         break;
       case LOG:
         url.append(repositoryName).append("/+log");
-        if (revision != Revision.NULL) {
+        if (!Revision.isNull(revision)) {
           url.append('/');
-          if (oldRevision != Revision.NULL) {
+          if (!Revision.isNull(oldRevision)) {
             url.append(oldRevision.getName()).append("..");
           }
           url.append(revision.getName());
@@ -764,10 +764,10 @@
       // separate links in "old..new".
       breadcrumbs.add(breadcrumb(getRevisionRange(), diff().copyFrom(this).setPathPart("")));
     } else if (type == Type.LOG) {
-      if (revision != Revision.NULL) {
+      if (!Revision.isNull(revision)) {
         // TODO(dborowitz): Add something in the navigation area (probably not
         // a breadcrumb) to allow switching between /+log/ and /+/.
-        if (oldRevision == Revision.NULL) {
+        if (Revision.isNull(oldRevision)) {
           breadcrumbs.add(breadcrumb(revision.getName(), log().copyFrom(this).setPathPart(null)));
         } else {
           breadcrumbs.add(breadcrumb(getRevisionRange(), log().copyFrom(this).setPathPart(null)));
@@ -776,7 +776,7 @@
         breadcrumbs.add(breadcrumb(Constants.HEAD, log().copyFrom(this)));
       }
       path = Strings.emptyToNull(path);
-    } else if (revision != Revision.NULL) {
+    } else if (!Revision.isNull(revision)) {
       breadcrumbs.add(breadcrumb(revision.getName(), revision().copyFrom(this)));
     }
     if (path != null) {
@@ -850,7 +850,7 @@
   }
 
   private static boolean isFirstParent(Revision rev1, Revision rev2) {
-    return rev2 == Revision.NULL
+    return Revision.isNull(rev2)
         || rev2.getName().equals(rev1.getName() + "^")
         || rev2.getName().equals(rev1.getName() + "~1");
   }
diff --git a/java/com/google/gitiles/LogServlet.java b/java/com/google/gitiles/LogServlet.java
index a91aeab..4d038ae 100644
--- a/java/com/google/gitiles/LogServlet.java
+++ b/java/com/google/gitiles/LogServlet.java
@@ -113,7 +113,7 @@
       }
 
       String title = "Log - ";
-      if (view.getOldRevision() != Revision.NULL) {
+      if (!Revision.isNull(view.getOldRevision())) {
         title += view.getRevisionRange();
       } else {
         title += view.getRevision().getName();
@@ -175,7 +175,7 @@
 
   private static GitilesView getView(HttpServletRequest req, Repository repo) throws IOException {
     GitilesView view = ViewFilter.getView(req);
-    if (view.getRevision() != Revision.NULL) {
+    if (!Revision.isNull(view.getRevision())) {
       return view;
     }
     Ref headRef = repo.exactRef(Constants.HEAD);
@@ -225,7 +225,7 @@
     RevWalk walk = new RevWalk(repo);
     try {
       walk.markStart(walk.parseCommit(view.getRevision().getId()));
-      if (view.getOldRevision() != Revision.NULL) {
+      if (!Revision.isNull(view.getOldRevision())) {
         walk.markUninteresting(walk.parseCommit(view.getOldRevision().getId()));
       }
     } catch (IncorrectObjectTypeException iote) {
diff --git a/java/com/google/gitiles/LogSoyData.java b/java/com/google/gitiles/LogSoyData.java
index 8bc243f..96ef6ae 100644
--- a/java/com/google/gitiles/LogSoyData.java
+++ b/java/com/google/gitiles/LogSoyData.java
@@ -176,14 +176,14 @@
   private GitilesView.Builder copyAndCanonicalizeView(String revision) {
     // Canonicalize the view by using full SHAs.
     GitilesView.Builder copy = GitilesView.log().copyFrom(view);
-    if (view.getRevision() != Revision.NULL) {
+    if (!Revision.isNull(view.getRevision())) {
       copy.setRevision(view.getRevision());
     } else if (revision != null) {
       copy.setRevision(Revision.named(revision));
     } else {
       copy.setRevision(Revision.NULL);
     }
-    if (view.getOldRevision() != Revision.NULL) {
+    if (!Revision.isNull(view.getOldRevision())) {
       copy.setOldRevision(view.getOldRevision());
     }
     return copy;
diff --git a/java/com/google/gitiles/PathServlet.java b/java/com/google/gitiles/PathServlet.java
index 34e0a76..df24062 100644
--- a/java/com/google/gitiles/PathServlet.java
+++ b/java/com/google/gitiles/PathServlet.java
@@ -95,6 +95,7 @@
     EXECUTABLE_FILE(FileMode.EXECUTABLE_FILE),
     GITLINK(FileMode.GITLINK);
 
+    @SuppressWarnings("ImmutableEnumChecker") // FileMode is effectively immutable.
     private final FileMode mode;
 
     FileType(FileMode mode) {
diff --git a/java/com/google/gitiles/Renderer.java b/java/com/google/gitiles/Renderer.java
index 9ead8bf..bc85290 100644
--- a/java/com/google/gitiles/Renderer.java
+++ b/java/com/google/gitiles/Renderer.java
@@ -60,6 +60,7 @@
           "Common.soy",
           "DiffDetail.soy",
           "Doc.soy",
+          "Error.soy",
           "HostIndex.soy",
           "LogDetail.soy",
           "ObjectDetail.soy",
diff --git a/java/com/google/gitiles/Revision.java b/java/com/google/gitiles/Revision.java
index 3fc8d37..02a04f8 100644
--- a/java/com/google/gitiles/Revision.java
+++ b/java/com/google/gitiles/Revision.java
@@ -88,6 +88,11 @@
     this.peeledType = peeledType;
   }
 
+  @SuppressWarnings("ReferenceEquality")
+  public static boolean isNull(Revision r) {
+    return r == NULL;
+  }
+
   public String getName() {
     return name;
   }
diff --git a/java/com/google/gitiles/RevisionParser.java b/java/com/google/gitiles/RevisionParser.java
index 89311a3..d530cdd 100644
--- a/java/com/google/gitiles/RevisionParser.java
+++ b/java/com/google/gitiles/RevisionParser.java
@@ -225,7 +225,7 @@
     if (!cache.isVisible(repo, walk, access, id)) {
       return false;
     }
-    if (result.getOldRevision() != null && result.getOldRevision() != Revision.NULL) {
+    if (result.getOldRevision() != null && !Revision.isNull(result.getOldRevision())) {
       return cache.isVisible(repo, walk, access, result.getOldRevision().getId(), id);
     }
     return true;
diff --git a/java/com/google/gitiles/ViewFilter.java b/java/com/google/gitiles/ViewFilter.java
index f400ff0..25d6d7e 100644
--- a/java/com/google/gitiles/ViewFilter.java
+++ b/java/com/google/gitiles/ViewFilter.java
@@ -119,12 +119,12 @@
   }
 
   private boolean normalize(GitilesView.Builder view, HttpServletResponse res) throws IOException {
-    if (view.getOldRevision() != Revision.NULL) {
+    if (!Revision.isNull(view.getOldRevision())) {
       return false;
     }
     Revision r = view.getRevision();
     Revision nr = Revision.normalizeParentExpressions(r);
-    if (r != nr) {
+    if (!r.equals(nr)) {
       res.sendRedirect(view.setRevision(nr).toUrl());
       return true;
     }
diff --git a/java/com/google/gitiles/VisibilityCache.java b/java/com/google/gitiles/VisibilityCache.java
index fbb3a45..fe1c07e 100644
--- a/java/com/google/gitiles/VisibilityCache.java
+++ b/java/com/google/gitiles/VisibilityCache.java
@@ -22,6 +22,7 @@
 import static org.eclipse.jgit.lib.Constants.R_HEADS;
 import static org.eclipse.jgit.lib.Constants.R_TAGS;
 
+import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Throwables;
 import com.google.common.cache.Cache;
 import com.google.common.cache.CacheBuilder;
@@ -34,17 +35,16 @@
 import java.util.concurrent.TimeUnit;
 import java.util.stream.Stream;
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
-import org.eclipse.jgit.errors.MissingObjectException;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.RefDatabase;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevSort;
 import org.eclipse.jgit.revwalk.RevWalk;
 
 /** Cache of per-user object visibility. */
 public class VisibilityCache {
+
   private static class Key {
     private final Object user;
     private final String repositoryName;
@@ -83,7 +83,7 @@
   }
 
   private final Cache<Key, Boolean> cache;
-  private final boolean topoSort;
+  private final VisibilityChecker checker;
 
   public static CacheBuilder<Object, Object> defaultBuilder() {
     return CacheBuilder.newBuilder().maximumSize(1 << 10).expireAfterWrite(30, TimeUnit.MINUTES);
@@ -94,14 +94,37 @@
   }
 
   public VisibilityCache(boolean topoSort, CacheBuilder<Object, Object> builder) {
+    this(new VisibilityChecker(topoSort), builder);
+  }
+
+  /**
+   * Use the constructors with a boolean parameter (e.g. {@link #VisibilityCache(boolean)}). The
+   * default visibility checker should cover all common use cases.
+   *
+   * <p>This constructor is useful to use a checker with additional logging or metrics collection,
+   * for example.
+   */
+  public VisibilityCache(VisibilityChecker checker) {
+    this(checker, defaultBuilder());
+  }
+
+  /**
+   * Use the constructors with a boolean parameter (e.g. {@link #VisibilityCache(boolean)}). The
+   * default visibility checker should cover all common use cases.
+   *
+   * <p>This constructor is useful to use a checker with additional logging or metrics collection,
+   * for example.
+   */
+  public VisibilityCache(VisibilityChecker checker, CacheBuilder<Object, Object> builder) {
     this.cache = builder.build();
-    this.topoSort = topoSort;
+    this.checker = checker;
   }
 
   public Cache<?, Boolean> getCache() {
     return cache;
   }
 
+  @VisibleForTesting
   boolean isVisible(
       final Repository repo,
       final RevWalk walk,
@@ -126,8 +149,7 @@
     }
   }
 
-  private boolean isVisible(
-      Repository repo, RevWalk walk, ObjectId id, Collection<ObjectId> knownReachable)
+  boolean isVisible(Repository repo, RevWalk walk, ObjectId id, Collection<ObjectId> knownReachable)
       throws IOException {
     RevCommit commit;
     try {
@@ -137,23 +159,18 @@
     }
 
     RefDatabase refDb = repo.getRefDatabase();
-
-    // If any reference directly points at the requested object, permit display. Common for displays
-    // of pending patch sets in Gerrit Code Review, or bookmarks to the commit a tag points at.
-    for (Ref ref : repo.getRefDatabase().getRefs()) {
-      ref = repo.getRefDatabase().peel(ref);
-      if (id.equals(ref.getObjectId()) || id.equals(ref.getPeeledObjectId())) {
-        return true;
-      }
+    if (checker.isTipOfBranch(refDb, id)) {
+      return true;
     }
 
     // Check heads first under the assumption that most requests are for refs close to a head. Tags
     // tend to be much further back in history and just clutter up the priority queue in the common
     // case.
-    return isReachableFrom(walk, commit, knownReachable)
-        || isReachableFromRefs(walk, commit, refDb.getRefsByPrefix(R_HEADS).stream())
-        || isReachableFromRefs(walk, commit, refDb.getRefsByPrefix(R_TAGS).stream())
-        || isReachableFromRefs(walk, commit, refDb.getRefs().stream().filter(r -> otherRefs(r)));
+    return checker.isReachableFrom("knownReachable", walk, commit, knownReachable)
+        || isReachableFromRefs("heads", walk, commit, refDb.getRefsByPrefix(R_HEADS).stream())
+        || isReachableFromRefs("tags", walk, commit, refDb.getRefsByPrefix(R_TAGS).stream())
+        || isReachableFromRefs(
+            "other", walk, commit, refDb.getRefs().stream().filter(r -> otherRefs(r)));
   }
 
   private static boolean refStartsWith(Ref ref, String prefix) {
@@ -166,43 +183,14 @@
         || refStartsWith(r, "refs/changes/"));
   }
 
-  private boolean isReachableFromRefs(RevWalk walk, RevCommit commit, Stream<Ref> refs)
+  private boolean isReachableFromRefs(String desc, RevWalk walk, RevCommit commit, Stream<Ref> refs)
       throws IOException {
     return isReachableFrom(
-        walk, commit, refs.map(r -> firstNonNull(r.getPeeledObjectId(), r.getObjectId())));
+        desc, walk, commit, refs.map(r -> firstNonNull(r.getPeeledObjectId(), r.getObjectId())));
   }
 
-  private boolean isReachableFrom(RevWalk walk, RevCommit commit, Stream<ObjectId> ids)
+  private boolean isReachableFrom(String desc, RevWalk walk, RevCommit commit, Stream<ObjectId> ids)
       throws IOException {
-    return isReachableFrom(walk, commit, ids.collect(toList()));
-  }
-
-  private boolean isReachableFrom(RevWalk walk, RevCommit commit, Collection<ObjectId> ids)
-      throws IOException {
-    if (ids.isEmpty()) {
-      return false;
-    }
-    walk.reset();
-    if (topoSort) {
-      walk.sort(RevSort.TOPO);
-    }
-    walk.markStart(commit);
-    for (ObjectId id : ids) {
-      markUninteresting(walk, id);
-    }
-    // If the commit is reachable from any given tip, it will appear to be
-    // uninteresting to the RevWalk and no output will be produced.
-    return walk.next() == null;
-  }
-
-  private static void markUninteresting(RevWalk walk, ObjectId id) throws IOException {
-    if (id == null) {
-      return;
-    }
-    try {
-      walk.markUninteresting(walk.parseCommit(id));
-    } catch (IncorrectObjectTypeException | MissingObjectException e) {
-      // Do nothing, doesn't affect reachability.
-    }
+    return checker.isReachableFrom(desc, walk, commit, ids.collect(toList()));
   }
 }
diff --git a/java/com/google/gitiles/VisibilityChecker.java b/java/com/google/gitiles/VisibilityChecker.java
new file mode 100644
index 0000000..22d08bc
--- /dev/null
+++ b/java/com/google/gitiles/VisibilityChecker.java
@@ -0,0 +1,136 @@
+/*
+ * Copyright (C) 2019, Google LLC.
+ * and other copyright owners as documented in the project's IP log.
+ *
+ * This program and the accompanying materials are made available
+ * under the terms of the Eclipse Distribution License v1.0 which
+ * accompanies this distribution, is reproduced below, and is
+ * available at http://www.eclipse.org/org/documents/edl-v10.php
+ *
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or
+ * without modification, are permitted provided that the following
+ * conditions are met:
+ *
+ * - Redistributions of source code must retain the above copyright
+ *   notice, this list of conditions and the following disclaimer.
+ *
+ * - Redistributions in binary form must reproduce the above
+ *   copyright notice, this list of conditions and the following
+ *   disclaimer in the documentation and/or other materials provided
+ *   with the distribution.
+ *
+ * - Neither the name of the Eclipse Foundation, Inc. nor the
+ *   names of its contributors may be used to endorse or promote
+ *   products derived from this software without specific prior
+ *   written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
+ * CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES,
+ * INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
+ * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
+ * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
+ * NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+ * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+ * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
+ * STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF
+ * ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package com.google.gitiles;
+
+import java.io.IOException;
+import java.util.Collection;
+import org.eclipse.jgit.errors.IncorrectObjectTypeException;
+import org.eclipse.jgit.errors.MissingObjectException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.RefDatabase;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevSort;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+/**
+ * Checks for object visibility
+ *
+ * <p>Objects are visible if they are reachable from any of the references visible to the user.
+ */
+public class VisibilityChecker {
+
+  private final boolean topoSort;
+
+  /**
+   * @param topoSort whether to use a more thorough reachability check by sorting in topological
+   *     order
+   */
+  public VisibilityChecker(boolean topoSort) {
+    this.topoSort = topoSort;
+  }
+
+  /**
+   * Check if any of the refs in {@code refDb} points to the object {@code id}.
+   *
+   * @param refDb a reference database
+   * @param id object we are looking for
+   * @return true if the any of the references in the db points directly to the id
+   * @throws IOException the reference space cannot be accessed
+   */
+  protected boolean isTipOfBranch(RefDatabase refDb, ObjectId id) throws IOException {
+    // If any reference directly points at the requested object, permit display. Common for displays
+    // of pending patch sets in Gerrit Code Review, or bookmarks to the commit a tag points at.
+    for (Ref ref : refDb.getRefs()) {
+      ref = refDb.peel(ref);
+      if (id.equals(ref.getObjectId()) || id.equals(ref.getPeeledObjectId())) {
+        return true;
+      }
+    }
+
+    return false;
+  }
+
+  /**
+   * Check if {@code commit} is reachable starting from {@code starters}.
+   *
+   * @param description Description of the ids (e.g. "heads"). Mainly for tracing.
+   * @param walk The walk to use for the reachability check
+   * @param commit The starting commit. It *MUST* come from the walk in use
+   * @param starters visible commits. Anything reachable from these commits is visible. Missing ids
+   *     or ids pointing to wrong kind of objects are ignored.
+   * @return true if we can get to {@code commit} from the {@code starters}
+   * @throws IOException a pack file or loose object could not be read
+   */
+  protected boolean isReachableFrom(
+      String description, RevWalk walk, RevCommit commit, Collection<ObjectId> starters)
+      throws IOException {
+    if (starters.isEmpty()) {
+      return false;
+    }
+
+    walk.reset();
+    if (topoSort) {
+      walk.sort(RevSort.TOPO);
+    }
+
+    walk.markStart(commit);
+    for (ObjectId id : starters) {
+      markUninteresting(walk, id);
+    }
+    // If the commit is reachable from any given tip, it will appear to be
+    // uninteresting to the RevWalk and no output will be produced.
+    return walk.next() == null;
+  }
+
+  private static void markUninteresting(RevWalk walk, ObjectId id) throws IOException {
+    if (id == null) {
+      return;
+    }
+    try {
+      walk.markUninteresting(walk.parseCommit(id));
+    } catch (IncorrectObjectTypeException | MissingObjectException e) {
+      // Do nothing, doesn't affect reachability.
+    }
+  }
+}
diff --git a/java/com/google/gitiles/doc/MarkdownConfig.java b/java/com/google/gitiles/doc/MarkdownConfig.java
index b4add94..4758654 100644
--- a/java/com/google/gitiles/doc/MarkdownConfig.java
+++ b/java/com/google/gitiles/doc/MarkdownConfig.java
@@ -78,7 +78,7 @@
     if (safeHtml) {
       f = cfg.getStringList("markdown", null, "allowiframe");
     }
-    allowAnyIFrame = f.length == 1 && StringUtils.toBooleanOrNull(f[0]) == Boolean.TRUE;
+    allowAnyIFrame = f.length == 1 && Boolean.TRUE.equals(StringUtils.toBooleanOrNull(f[0]));
     if (allowAnyIFrame) {
       allowIFrame = ImmutableList.of();
     } else {
diff --git a/javatests/com/google/gitiles/DefaultErrorHandlingFilterTest.java b/javatests/com/google/gitiles/DefaultErrorHandlingFilterTest.java
index b96e43d..0dc152a 100644
--- a/javatests/com/google/gitiles/DefaultErrorHandlingFilterTest.java
+++ b/javatests/com/google/gitiles/DefaultErrorHandlingFilterTest.java
@@ -3,7 +3,9 @@
 import static com.google.common.truth.Truth.assertThat;
 import static javax.servlet.http.HttpServletResponse.SC_BAD_REQUEST;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gitiles.GitilesRequestFailureException.FailureReason;
+import java.net.URL;
 import javax.servlet.http.HttpServlet;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
@@ -19,7 +21,12 @@
 
   @Before
   public void setUp() {
-    mf.serve("*").through(new DefaultErrorHandlingFilter()).with(new TestServlet());
+    mf.serve("*")
+        .through(
+            new DefaultErrorHandlingFilter(
+                new DefaultRenderer(
+                    GitilesServlet.STATIC_PREFIX, ImmutableList.<URL>of(), "test site")))
+        .with(new TestServlet());
   }
 
   @Test
diff --git a/javatests/com/google/gitiles/MoreAssert.java b/javatests/com/google/gitiles/MoreAssert.java
index 3f79874..3814be5 100644
--- a/javatests/com/google/gitiles/MoreAssert.java
+++ b/javatests/com/google/gitiles/MoreAssert.java
@@ -15,13 +15,10 @@
 
 /** Assertion methods for Gitiles. */
 public class MoreAssert {
-  private MoreAssert() {}
-
   /** Simple version of assertThrows that will be introduced in JUnit 4.13. */
   public static <T extends Throwable> T assertThrows(Class<T> expected, ThrowingRunnable r) {
     try {
       r.run();
-      throw new AssertionError("Expected " + expected.getSimpleName() + " to be thrown");
     } catch (Throwable actual) {
       if (expected.isAssignableFrom(actual.getClass())) {
         @SuppressWarnings("unchecked")
@@ -32,9 +29,12 @@
           "Expected " + expected.getSimpleName() + ", but got " + actual.getClass().getSimpleName(),
           actual);
     }
+    throw new AssertionError("Expected " + expected.getSimpleName() + " to be thrown");
   }
 
   public interface ThrowingRunnable {
     void run() throws Throwable;
   }
+
+  private MoreAssert() {}
 }
diff --git a/javatests/com/google/gitiles/VisibilityCacheTest.java b/javatests/com/google/gitiles/VisibilityCacheTest.java
index 1633803..bcc2b40 100644
--- a/javatests/com/google/gitiles/VisibilityCacheTest.java
+++ b/javatests/com/google/gitiles/VisibilityCacheTest.java
@@ -95,17 +95,18 @@
      * </pre>
      */
     repo = new InMemoryRepository(new DfsRepositoryDescription());
-    TestRepository<InMemoryRepository> git = new TestRepository<>(repo);
-    baseCommit = git.commit().message("baseCommit").create();
-    commit1 = git.commit().parent(baseCommit).message("commit1").create();
-    commit2 = git.commit().parent(commit1).message("commit2").create();
+    try (TestRepository<InMemoryRepository> git = new TestRepository<>(repo)) {
+      baseCommit = git.commit().message("baseCommit").create();
+      commit1 = git.commit().parent(baseCommit).message("commit1").create();
+      commit2 = git.commit().parent(commit1).message("commit2").create();
 
-    commitA = git.commit().parent(baseCommit).message("commitA").create();
-    commitB = git.commit().parent(commitA).message("commitB").create();
-    commitC = git.commit().parent(commitB).message("commitC").create();
+      commitA = git.commit().parent(baseCommit).message("commitA").create();
+      commitB = git.commit().parent(commitA).message("commitB").create();
+      commitC = git.commit().parent(commitB).message("commitC").create();
 
-    git.update("master", commit2);
-    git.update("refs/tags/v0.1", commitA);
+      git.update("master", commit2);
+      git.update("refs/tags/v0.1", commitA);
+    }
 
     visibilityCache = new VisibilityCache(true);
     walk = new RevWalk(repo);
diff --git a/javatests/com/google/gitiles/VisibilityCheckerTest.java b/javatests/com/google/gitiles/VisibilityCheckerTest.java
new file mode 100644
index 0000000..3171459
--- /dev/null
+++ b/javatests/com/google/gitiles/VisibilityCheckerTest.java
@@ -0,0 +1,120 @@
+/*
+ * Copyright (C) 2019, Google LLC.
+ * and other copyright owners as documented in the project's IP log.
+ *
+ * This program and the accompanying materials are made available
+ * under the terms of the Eclipse Distribution License v1.0 which
+ * accompanies this distribution, is reproduced below, and is
+ * available at http://www.eclipse.org/org/documents/edl-v10.php
+ *
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or
+ * without modification, are permitted provided that the following
+ * conditions are met:
+ *
+ * - Redistributions of source code must retain the above copyright
+ *   notice, this list of conditions and the following disclaimer.
+ *
+ * - Redistributions in binary form must reproduce the above
+ *   copyright notice, this list of conditions and the following
+ *   disclaimer in the documentation and/or other materials provided
+ *   with the distribution.
+ *
+ * - Neither the name of the Eclipse Foundation, Inc. nor the
+ *   names of its contributors may be used to endorse or promote
+ *   products derived from this software without specific prior
+ *   written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
+ * CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES,
+ * INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
+ * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
+ * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
+ * NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+ * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+ * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
+ * STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF
+ * ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package com.google.gitiles;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.List;
+import org.eclipse.jgit.internal.storage.dfs.DfsRepositoryDescription;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public class VisibilityCheckerTest {
+  private InMemoryRepository repo;
+
+  private RevCommit baseCommit;
+  private RevCommit commit1;
+  private RevCommit commit2;
+  private RevCommit commitA;
+  private RevCommit commitB;
+  private RevCommit commitC;
+
+  private VisibilityChecker visibilityChecker;
+  private RevWalk walk;
+
+  @Before
+  public void setUp() throws Exception {
+    repo = new InMemoryRepository(new DfsRepositoryDescription());
+    try (TestRepository<InMemoryRepository> git = new TestRepository<>(repo)) {
+      baseCommit = git.commit().message("baseCommit").create();
+      commit1 = git.commit().parent(baseCommit).message("commit1").create();
+      commit2 = git.commit().parent(commit1).message("commit2").create();
+
+      commitA = git.commit().parent(baseCommit).message("commitA").create();
+      commitB = git.commit().parent(commitA).message("commitB").create();
+      commitC = git.commit().parent(commitB).message("commitC").create();
+
+      git.update("master", commit2);
+      git.update("refs/tags/v0.1", commitA);
+    }
+
+    visibilityChecker = new VisibilityChecker(true);
+    walk = new RevWalk(repo);
+    walk.setRetainBody(false);
+  }
+
+  @Test
+  public void isTip() throws IOException {
+    assertTrue(visibilityChecker.isTipOfBranch(repo.getRefDatabase(), commit2.getId()));
+  }
+
+  @Test
+  public void isNotTip() throws IOException {
+    assertFalse(visibilityChecker.isTipOfBranch(repo.getRefDatabase(), commit1.getId()));
+  }
+
+  @Test
+  public void reachableFromRef() throws IOException {
+    List<ObjectId> starters = Arrays.asList(commitC.getId());
+    assertTrue(
+        visibilityChecker.isReachableFrom("test", walk, walk.parseCommit(commitB), starters));
+  }
+
+  @Test
+  public void unreachableFromRef() throws IOException {
+    List<ObjectId> starters = Arrays.asList(commit2.getId(), commitA.getId());
+    assertFalse(
+        visibilityChecker.isReachableFrom("test", walk, walk.parseCommit(commitC), starters));
+  }
+}
diff --git a/resources/com/google/gitiles/templates/Error.soy b/resources/com/google/gitiles/templates/Error.soy
new file mode 100644
index 0000000..39fcef3
--- /dev/null
+++ b/resources/com/google/gitiles/templates/Error.soy
@@ -0,0 +1,39 @@
+// Copyright 2019 Google Inc. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+{namespace gitiles}
+
+/**
+ * HTML page for error.
+ */
+{template .error stricthtml="false"}
+  {@param? title: ?}  /** page title. */
+  {@param? menuEntries: ?}  /** menu entries. */
+  {@param? customVariant: ?}  /** variant name for custom styling. */
+  {@param? breadcrumbs: ?}  /** map of breadcrumbs for header. */
+{call .header}
+  {param title: $title /}
+  {param menuEntries: $menuEntries /}
+  {param breadcrumbs: $breadcrumbs /}
+  {param customVariant: $customVariant /}
+{/call}
+<h1>
+  {msg desc="title"}
+    {$title}
+  {/msg}
+</h1>
+
+{call .footer}
+  {param customVariant: $customVariant /}
+{/call}
+{/template}
diff --git a/tools/BUILD b/tools/BUILD
index 6d15a21..9294fcf 100644
--- a/tools/BUILD
+++ b/tools/BUILD
@@ -29,11 +29,12 @@
         "-Xep:CannotMockFinalClass:ERROR",
         "-Xep:ClassCanBeStatic:ERROR",
         "-Xep:ClassNewInstance:ERROR",
+        "-Xep:DateFormatConstant:ERROR",
         "-Xep:DefaultCharset:ERROR",
         "-Xep:DoubleCheckedLocking:ERROR",
-        "-Xep:ElementsCountedInLoop:ERROR",
         "-Xep:DoubleCheckedLocking:ERROR",
         "-Xep:ElementsCountedInLoop:ERROR",
+        "-Xep:ElementsCountedInLoop:ERROR",
         "-Xep:EqualsHashCode:ERROR",
         "-Xep:EqualsIncompatibleType:ERROR",
         "-Xep:ExpectedExceptionChecker:ERROR",
@@ -44,7 +45,7 @@
         "-Xep:FunctionalInterfaceClash:ERROR",
         "-Xep:FutureReturnValueIgnored:ERROR",
         "-Xep:GetClassOnEnum:ERROR",
-        "-Xep:ImmutableAnnotationChecker:WARN",
+        "-Xep:ImmutableAnnotationChecker:ERROR",
         "-Xep:ImmutableEnumChecker:WARN",
         "-Xep:IncompatibleModifiers:ERROR",
         "-Xep:InjectOnConstructorOfAbstractClass:ERROR",
@@ -68,7 +69,7 @@
         "-Xep:PreconditionsInvalidPlaceholder:ERROR",
         "-Xep:ProtoFieldPreconditionsCheckNotNull:ERROR",
         "-Xep:ProtocolBufferOrdinal:ERROR",
-        "-Xep:ReferenceEquality:WARN",
+        "-Xep:ReferenceEquality:ERROR",
         "-Xep:RequiredModifiers:ERROR",
         "-Xep:ShortCircuitBoolean:ERROR",
         "-Xep:SimpleDateFormatConstant:ERROR",
diff --git a/version.bzl b/version.bzl
index 29a6a55..1bae3cf 100644
--- a/version.bzl
+++ b/version.bzl
@@ -4,4 +4,4 @@
 # we currently have no stable releases, we use the "build number" scheme
 # described at:
 # https://www.mojohaus.org/versions-maven-plugin/version-rules.html
-GITILES_VERSION = "0.2-10"
+GITILES_VERSION = "0.3-1"