Merge branch 'stable-0.2'

* stable-0.2:
  Bump version to 0.2-11
  BlameCacheImpl: Avoid NPE if path does not exist
  VisibilityCacheTest: Open TestRepository in try-with-resource
  Backport all build-related commits from master to stable-0.2

Change-Id: I0c28c63abe2643c51232b27a299cf02777951610
diff --git a/WORKSPACE b/WORKSPACE
index 883ec60e..9b003bc 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -58,8 +58,8 @@
 
 maven_jar(
     name = "guava",
-    artifact = "com.google.guava:guava:27.1-jre",
-    sha1 = "e47b59c893079b87743cdcfb6f17ca95c08c592c",
+    artifact = "com.google.guava:guava:28.1-jre",
+    sha1 = "b0e91dcb6a44ffb6221b5027e12a5cb34b841145",
 )
 
 maven_jar(
@@ -129,8 +129,8 @@
 
 maven_jar(
     name = "truth",
-    artifact = "com.google.truth:truth:0.44",
-    sha1 = "11eff954c0c14da7d43276d7b3bcf71463105368",
+    artifact = "com.google.truth:truth:1.0",
+    sha1 = "998e5fb3fa31df716574b4c9e8d374855e800451",
 )
 
 # Indirect dependency of truth
@@ -142,8 +142,8 @@
 
 maven_jar(
     name = "soy",
-    artifact = "com.google.template:soy:2019-03-11",
-    sha1 = "119ac4b3eb0e2c638526ca99374013965c727097",
+    artifact = "com.google.template:soy:2019-10-08",
+    sha1 = "4518bf8bac2dbbed684849bc209c39c4cb546237",
 )
 
 maven_jar(
@@ -164,7 +164,7 @@
     sha1 = "198ea005f41219f038f4291f0b0e9f3259730e92",
 )
 
-JGIT_VERS = "5.3.1.201904271842-r"
+JGIT_VERS = "5.4.3.201909031940-r"
 
 JGIT_REPO = MAVEN_CENTRAL
 
@@ -172,28 +172,28 @@
     name = "jgit-lib",
     artifact = "org.eclipse.jgit:org.eclipse.jgit:" + JGIT_VERS,
     repository = JGIT_REPO,
-    sha1 = "dba85014483315fa426259bc1b8ccda9373a624b",
+    sha1 = "10322c4e103485f8b4873cbbf982342f9c3d7989",
 )
 
 maven_jar(
     name = "jgit-servlet",
     artifact = "org.eclipse.jgit:org.eclipse.jgit.http.server:" + JGIT_VERS,
     repository = JGIT_REPO,
-    sha1 = "3287341fca859340a00b51cb5dd3b78b8e532b39",
+    sha1 = "59d0c943343f30612e4e2a5a3bf1b95b56e00207",
 )
 
 maven_jar(
     name = "jgit-junit",
     artifact = "org.eclipse.jgit:org.eclipse.jgit.junit:" + JGIT_VERS,
     repository = JGIT_REPO,
-    sha1 = "3d9ba7e610d6ab5d08dcb1e4ba448b592a34de77",
+    sha1 = "71659fc1a1729b7c67846dac8cd6a762fa72002a",
 )
 
 maven_jar(
     name = "jgit-archive",
     artifact = "org.eclipse.jgit:org.eclipse.jgit.archive:" + JGIT_VERS,
     repository = JGIT_REPO,
-    sha1 = "3585027e83fb44a5de2c10ae9ddbf976593bf080",
+    sha1 = "21dc4a10882dc667c83bf82a563a6fc4d7719456",
 )
 
 maven_jar(
@@ -206,17 +206,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(
@@ -271,46 +271,78 @@
     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",
+)
+
+OW2_VERS = "7.0"
+
+maven_jar(
+    name = "ow2-asm",
+    artifact = "org.ow2.asm:asm:" + OW2_VERS,
+    sha1 = "d74d4ba0dee443f68fb2dcb7fcdb945a2cd89912",
+)
+
+maven_jar(
+    name = "ow2-asm-analysis",
+    artifact = "org.ow2.asm:asm-analysis:" + OW2_VERS,
+    sha1 = "4b310d20d6f1c6b7197a75f1b5d69f169bc8ac1f",
+)
+
+maven_jar(
+    name = "ow2-asm-commons",
+    artifact = "org.ow2.asm:asm-commons:" + OW2_VERS,
+    sha1 = "478006d07b7c561ae3a92ddc1829bca81ae0cdd1",
+)
+
+maven_jar(
+    name = "ow2-asm-tree",
+    artifact = "org.ow2.asm:asm-tree:" + OW2_VERS,
+    sha1 = "29bc62dcb85573af6e62e5b2d735ef65966c4180",
+)
+
+maven_jar(
+    name = "ow2-asm-util",
+    artifact = "org.ow2.asm:asm-util:" + OW2_VERS,
+    sha1 = "18d4d07010c24405129a6dbb0e92057f8779fb9d",
 )
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..91c66bb 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);
@@ -201,7 +204,7 @@
   protected void renderHtml(
       HttpServletRequest req, HttpServletResponse res, String templateName, Map<String, ?> soyData)
       throws IOException {
-    renderer.render(req, res, templateName, startHtmlResponse(req, res, soyData));
+    renderer.renderHtml(req, res, templateName, startHtmlResponse(req, res, soyData));
   }
 
   /**
@@ -224,7 +227,8 @@
       HttpServletRequest req, HttpServletResponse res, String templateName, Map<String, ?> soyData)
       throws IOException {
     req.setAttribute(STREAMING_ATTRIBUTE, true);
-    return renderer.renderStreaming(res, false, templateName, startHtmlResponse(req, res, soyData));
+    return renderer.renderHtmlStreaming(
+        res, false, templateName, startHtmlResponse(req, res, soyData));
   }
 
   /**
@@ -256,7 +260,8 @@
       res.setHeader(HttpHeaders.CONTENT_ENCODING, "gzip");
       gzip = true;
     }
-    return renderer.renderStreaming(res, gzip, templateName, startHtmlResponse(req, res, soyData));
+    return renderer.renderHtmlStreaming(
+        res, gzip, templateName, startHtmlResponse(req, res, soyData));
   }
 
   private Map<String, ?> startHtmlResponse(
diff --git a/java/com/google/gitiles/BlobSoyData.java b/java/com/google/gitiles/BlobSoyData.java
index c505ed4..f1710d1 100644
--- a/java/com/google/gitiles/BlobSoyData.java
+++ b/java/com/google/gitiles/BlobSoyData.java
@@ -49,6 +49,13 @@
    */
   private static final int MAX_FILE_SIZE = 10 << 20;
 
+  /**
+   * Maximum number of lines to be displayed. Files larger than this will be displayed as binary
+   * files, even on a text content. For example really big XML files may be above this limit and
+   * will get displayed as binary.
+   */
+  private static final int MAX_LINE_COUNT = 50000;
+
   private final GitilesView view;
   private final ObjectReader reader;
 
@@ -71,6 +78,9 @@
     try {
       byte[] raw = loader.getCachedBytes(MAX_FILE_SIZE);
       content = !RawText.isBinary(raw) ? RawParseUtils.decode(raw) : null;
+      if (isContentTooLargeForDisplay(content)) {
+        content = null;
+      }
     } catch (LargeObjectException.OutOfMemory e) {
       throw e;
     } catch (LargeObjectException e) {
@@ -188,4 +198,21 @@
       return ext;
     }
   }
+
+  private static boolean isContentTooLargeForDisplay(String content) {
+    if (content == null) {
+      return false;
+    }
+
+    int lines = 0;
+    int nl = -1;
+    while (true) {
+      nl = nextLineBreak(content, nl + 1, content.length());
+      if (nl < 0) {
+        return false;
+      } else if (++lines == MAX_LINE_COUNT) {
+        return true;
+      }
+    }
+  }
 }
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/DebugRenderer.java b/java/com/google/gitiles/DebugRenderer.java
index 791067b..5e35e57 100644
--- a/java/com/google/gitiles/DebugRenderer.java
+++ b/java/com/google/gitiles/DebugRenderer.java
@@ -21,7 +21,7 @@
 import com.google.common.collect.Streams;
 import com.google.common.hash.HashCode;
 import com.google.template.soy.SoyFileSet;
-import com.google.template.soy.tofu.SoyTofu;
+import com.google.template.soy.jbcsrc.api.SoySauce;
 import java.io.File;
 import java.net.URISyntaxException;
 import java.net.URL;
@@ -47,7 +47,7 @@
   }
 
   @Override
-  protected SoyTofu getTofu() {
+  protected SoySauce getSauce() {
     SoyFileSet.Builder builder = SoyFileSet.builder().setCompileTimeGlobals(globals);
     for (URL template : templates.values()) {
       try {
@@ -57,6 +57,6 @@
       }
       builder.add(template);
     }
-    return builder.build().compileToTofu();
+    return builder.build().compileTemplates();
   }
 }
diff --git a/java/com/google/gitiles/DefaultErrorHandlingFilter.java b/java/com/google/gitiles/DefaultErrorHandlingFilter.java
index 958b800..ec52bb9 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,83 @@
   /** 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 {
     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());
+      try {
+        res.setHeader(GITILES_ERROR, e.getReason().toString());
+        renderHtml(req, res, e.getReason().getHttpStatusCode(), e.getPublicErrorMessage());
+      } catch (IOException e2) {
+        e.addSuppressed(e2);
+        throw e;
       }
     } catch (RepositoryNotFoundException e) {
-      res.sendError(SC_NOT_FOUND);
+      try {
+        renderHtml(req, res, FailureReason.REPOSITORY_NOT_FOUND);
+      } catch (IOException e2) {
+        e.addSuppressed(e2);
+        throw e;
+      }
     } catch (AmbiguousObjectException e) {
-      res.sendError(SC_BAD_REQUEST);
+      try {
+        renderHtml(req, res, FailureReason.AMBIGUOUS_OBJECT);
+      } catch (IOException e2) {
+        e.addSuppressed(e2);
+        throw e;
+      }
     } catch (ServiceMayNotContinueException e) {
-      sendError(req, res, e.getStatusCode(), e.getMessage());
-    } catch (IOException | ServletException err) {
-      log.warn("Internal server error", err);
-      res.sendError(SC_INTERNAL_SERVER_ERROR);
+      try {
+        renderHtml(req, res, e.getStatusCode(), e.getMessage());
+      } catch (IOException e2) {
+        e.addSuppressed(e2);
+        throw e;
+      }
+    } catch (IOException | ServletException e) {
+      try {
+        log.warn("Internal server error", e);
+        renderHtml(req, res, FailureReason.INTERNAL_SERVER_ERROR);
+      } catch (IOException e2) {
+        e.addSuppressed(e2);
+        throw e;
+      }
     }
   }
+
+  private void renderHtml(HttpServletRequest req, HttpServletResponse res, FailureReason reason)
+      throws IOException {
+    res.setHeader(GITILES_ERROR, reason.toString());
+    renderHtml(req, res, reason.getHttpStatusCode(), reason.getMessage());
+  }
+
+  private void renderHtml(
+      HttpServletRequest req, HttpServletResponse res, int status, String message)
+      throws IOException {
+    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.renderHtml(req, res, templateName, startHtmlResponse(req, res, soyData));
+  }
+
+  private Map<String, ?> startHtmlResponse(
+      HttpServletRequest req, HttpServletResponse res, Map<String, ?> soyData) {
+    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/DefaultRenderer.java b/java/com/google/gitiles/DefaultRenderer.java
index 297350e..0307862 100644
--- a/java/com/google/gitiles/DefaultRenderer.java
+++ b/java/com/google/gitiles/DefaultRenderer.java
@@ -18,13 +18,13 @@
 import com.google.common.collect.ImmutableMap;
 import com.google.common.io.Resources;
 import com.google.template.soy.SoyFileSet;
-import com.google.template.soy.tofu.SoyTofu;
+import com.google.template.soy.jbcsrc.api.SoySauce;
 import java.net.URL;
 import java.util.Map;
 
 /** Renderer that precompiles Soy and uses static precompiled CSS. */
 public class DefaultRenderer extends Renderer {
-  private final SoyTofu tofu;
+  private final SoySauce sauce;
 
   DefaultRenderer() {
     this("", ImmutableList.<URL>of(), "");
@@ -49,11 +49,11 @@
     for (URL template : templates.values()) {
       builder.add(template);
     }
-    tofu = builder.build().compileToTofu();
+    sauce = builder.build().compileTemplates();
   }
 
   @Override
-  protected SoyTofu getTofu() {
-    return tofu;
+  protected SoySauce getSauce() {
+    return sauce;
   }
 }
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/HostIndexServlet.java b/java/com/google/gitiles/HostIndexServlet.java
index 8b8a252..6851688 100644
--- a/java/com/google/gitiles/HostIndexServlet.java
+++ b/java/com/google/gitiles/HostIndexServlet.java
@@ -17,12 +17,11 @@
 import static com.google.common.base.Preconditions.checkNotNull;
 
 import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Sets;
 import com.google.gitiles.GitilesRequestFailureException.FailureReason;
 import com.google.gson.reflect.TypeToken;
-import com.google.template.soy.data.SoyListData;
-import com.google.template.soy.data.SoyMapData;
 import com.google.template.soy.data.restricted.NullData;
 import java.io.IOException;
 import java.io.Writer;
@@ -66,12 +65,15 @@
     return descs;
   }
 
-  private SoyMapData toSoyMapData(
+  private Map<String, Object> toMapData(
       RepositoryDescription desc, @Nullable String prefix, GitilesView view) {
-    return new SoyMapData(
-        "name", stripPrefix(prefix, desc.name),
-        "description", Strings.nullToEmpty(desc.description),
-        "url", GitilesView.repositoryIndex().copyFrom(view).setRepositoryName(desc.name).toUrl());
+    return ImmutableMap.<String, Object>builder()
+        .put("name", stripPrefix(prefix, desc.name))
+        .put("description", Strings.nullToEmpty(desc.description))
+        .put(
+            "url",
+            GitilesView.repositoryIndex().copyFrom(view).setRepositoryName(desc.name).toUrl())
+        .build();
   }
 
   @Override
@@ -111,10 +113,10 @@
       return;
     }
 
-    SoyListData repos = new SoyListData();
+    ImmutableList.Builder<Map<String, Object>> repos = ImmutableList.builder();
     for (RepositoryDescription desc : descs.values()) {
       if (prefix == null || desc.name.startsWith(prefix)) {
-        repos.add(toSoyMapData(desc, prefix, view));
+        repos.add(toMapData(desc, prefix, view));
       }
     }
 
@@ -132,11 +134,11 @@
             "hostName",
             hostName,
             "breadcrumbs",
-            breadcrumbs != null ? new SoyListData(breadcrumbs) : NullData.INSTANCE,
+            breadcrumbs != null ? breadcrumbs : NullData.INSTANCE,
             "prefix",
             prefix != null ? prefix + '/' : "",
             "repositories",
-            repos));
+            repos.build()));
   }
 
   @Override
diff --git a/java/com/google/gitiles/HtmlDiffFormatter.java b/java/com/google/gitiles/HtmlDiffFormatter.java
index 44d2c18..1467d42 100644
--- a/java/com/google/gitiles/HtmlDiffFormatter.java
+++ b/java/com/google/gitiles/HtmlDiffFormatter.java
@@ -111,7 +111,9 @@
             renderer
                 .newRenderer("gitiles.diffHeader")
                 .setData(ImmutableMap.of("firstParts", parts, "rest", rest, "fileIndex", fileIndex))
-                .render()
+                .renderHtml()
+                .get()
+                .toString()
                 .getBytes(UTF_8));
   }
 
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..dc782ab 100644
--- a/java/com/google/gitiles/LogSoyData.java
+++ b/java/com/google/gitiles/LogSoyData.java
@@ -22,7 +22,8 @@
 import com.google.common.collect.Maps;
 import com.google.common.collect.Sets;
 import com.google.gitiles.CommitData.Field;
-import com.google.template.soy.tofu.SoyTofu;
+import com.google.template.soy.data.LoggingAdvisingAppendable;
+import com.google.template.soy.jbcsrc.api.SoySauce;
 import java.io.IOException;
 import java.io.Writer;
 import java.util.Map;
@@ -74,35 +75,43 @@
     variant = firstNonNull(config.getString("logFormat", pretty, "variant"), pretty);
   }
 
+  private void renderHtml(SoySauce.Renderer renderer, LoggingAdvisingAppendable out)
+      throws IOException {
+    if (!renderer.renderHtml(out).result().isDone()) {
+      throw new IOException("failed to render HTML");
+    }
+  }
+
   public void renderStreaming(
       Paginator paginator,
       @Nullable String revision,
       Renderer renderer,
-      Writer out,
+      Writer writer,
       DateFormatter df,
       FooterBehavior footerBehavior)
       throws IOException {
-    renderer
-        .newRenderer("gitiles.logEntriesHeader")
-        .setData(toHeaderSoyData(paginator, revision))
-        .render(out);
-    out.flush();
+    LoggingAdvisingAppendable out = LoggingAdvisingAppendable.delegating(writer);
+    renderHtml(
+        renderer
+            .newRenderer("gitiles.logEntriesHeader")
+            .setData(toHeaderSoyData(paginator, revision)),
+        out);
 
-    SoyTofu.Renderer entryRenderer = renderer.newRenderer("gitiles.logEntryWrapper");
+    SoySauce.Renderer entryRenderer = renderer.newRenderer("gitiles.logEntryWrapper");
     boolean renderedEntries = false;
     for (RevCommit c : paginator) {
-      entryRenderer.setData(toEntrySoyData(paginator, c, df)).render(out);
-      out.flush();
+      renderHtml(entryRenderer.setData(toEntrySoyData(paginator, c, df)), out);
       renderedEntries = true;
     }
     if (!renderedEntries) {
-      renderer.newRenderer("gitiles.emptyLog").render(out);
+      renderHtml(renderer.newRenderer("gitiles.emptyLog"), out);
     }
 
-    renderer
-        .newRenderer("gitiles.logEntriesFooter")
-        .setData(toFooterSoyData(paginator, revision, footerBehavior))
-        .render(out);
+    renderHtml(
+        renderer
+            .newRenderer("gitiles.logEntriesFooter")
+            .setData(toFooterSoyData(paginator, revision, footerBehavior)),
+        out);
   }
 
   private Map<String, Object> toHeaderSoyData(Paginator paginator, @Nullable String revision) {
@@ -176,14 +185,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..dbd5590 100644
--- a/java/com/google/gitiles/Renderer.java
+++ b/java/com/google/gitiles/Renderer.java
@@ -29,7 +29,7 @@
 import com.google.common.html.types.LegacyConversions;
 import com.google.common.io.ByteStreams;
 import com.google.common.net.HttpHeaders;
-import com.google.template.soy.tofu.SoyTofu;
+import com.google.template.soy.jbcsrc.api.SoySauce;
 import java.io.File;
 import java.io.IOException;
 import java.io.InputStream;
@@ -60,6 +60,7 @@
           "Common.soy",
           "DiffDetail.soy",
           "Doc.soy",
+          "Error.soy",
           "HostIndex.soy",
           "LogDetail.soy",
           "ObjectDetail.soy",
@@ -146,16 +147,17 @@
     return h.hash();
   }
 
-  public String render(String templateName, Map<String, ?> soyData) {
-    return newRenderer(templateName).setData(soyData).render();
+  public String renderHtml(String templateName, Map<String, ?> soyData) {
+    return newRenderer(templateName).setData(soyData).renderHtml().get().toString();
   }
 
-  void render(
+  void renderHtml(
       HttpServletRequest req, HttpServletResponse res, String templateName, Map<String, ?> soyData)
       throws IOException {
     res.setContentType("text/html");
     res.setCharacterEncoding("UTF-8");
-    byte[] data = newRenderer(templateName).setData(soyData).render().getBytes(UTF_8);
+    byte[] data =
+        newRenderer(templateName).setData(soyData).renderHtml().get().toString().getBytes(UTF_8);
     if (BaseServlet.acceptsGzipEncoding(req)) {
       res.addHeader(HttpHeaders.VARY, HttpHeaders.ACCEPT_ENCODING);
       res.setHeader(HttpHeaders.CONTENT_ENCODING, "gzip");
@@ -165,15 +167,15 @@
     res.getOutputStream().write(data);
   }
 
-  OutputStream renderStreaming(HttpServletResponse res, String templateName, Map<String, ?> soyData)
-      throws IOException {
-    return renderStreaming(res, false, templateName, soyData);
+  OutputStream renderHtmlStreaming(
+      HttpServletResponse res, String templateName, Map<String, ?> soyData) throws IOException {
+    return renderHtmlStreaming(res, false, templateName, soyData);
   }
 
-  OutputStream renderStreaming(
+  OutputStream renderHtmlStreaming(
       HttpServletResponse res, boolean gzip, String templateName, Map<String, ?> soyData)
       throws IOException {
-    String html = newRenderer(templateName).setData(soyData).render();
+    String html = newRenderer(templateName).setData(soyData).renderHtml().get().toString();
     int id = html.indexOf(PLACEHOLDER);
     checkArgument(id >= 0, "Template must contain %s", PLACEHOLDER);
 
@@ -210,17 +212,17 @@
     };
   }
 
-  SoyTofu.Renderer newRenderer(String templateName) {
+  SoySauce.Renderer newRenderer(String templateName) {
     ImmutableMap.Builder<String, Object> staticUrls = ImmutableMap.builder();
     for (String key : STATIC_URL_GLOBALS.keySet()) {
       staticUrls.put(
           key.replaceFirst("^gitiles\\.", ""),
           LegacyConversions.riskilyAssumeTrustedResourceUrl(globals.get(key)));
     }
-    return getTofu()
-        .newRenderer(templateName)
-        .setIjData(ImmutableMap.of("staticUrls", staticUrls.build()));
+    return getSauce()
+        .renderTemplate(templateName)
+        .setIj(ImmutableMap.of("staticUrls", staticUrls.build()));
   }
 
-  protected abstract SoyTofu getTofu();
+  protected abstract SoySauce getSauce();
 }
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..b736f7f 100644
--- a/java/com/google/gitiles/RevisionParser.java
+++ b/java/com/google/gitiles/RevisionParser.java
@@ -212,7 +212,10 @@
   private static boolean isValidRevision(String revision) {
     // Disallow some uncommon but valid revision expressions that either we
     // don't support or we represent differently in our URLs.
-    return !revision.contains(":") && !revision.contains("^{") && !revision.contains("@");
+    return !revision.contains(":")
+        && !revision.contains("^{")
+        && !revision.contains("@{")
+        && !revision.equals("@");
   }
 
   private boolean isVisible(RevWalk walk, Result result) throws IOException {
@@ -225,7 +228,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..ccd5cf1
--- /dev/null
+++ b/java/com/google/gitiles/VisibilityChecker.java
@@ -0,0 +1,128 @@
+/*
+ * 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.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.
+    return !refDb.getTipsWithSha1(id).isEmpty();
+  }
+
+  /**
+   * 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/blame/BlameServlet.java b/java/com/google/gitiles/blame/BlameServlet.java
index 0bc061b..8614c6a 100644
--- a/java/com/google/gitiles/blame/BlameServlet.java
+++ b/java/com/google/gitiles/blame/BlameServlet.java
@@ -35,8 +35,6 @@
 import com.google.gitiles.blame.cache.Region;
 import com.google.gson.GsonBuilder;
 import com.google.gson.reflect.TypeToken;
-import com.google.template.soy.data.SoyListData;
-import com.google.template.soy.data.SoyMapData;
 import java.io.IOException;
 import java.util.List;
 import java.util.Map;
@@ -205,21 +203,21 @@
 
   private static final ImmutableList<String> CLASSES =
       ImmutableList.of("Blame-region--bg1", "Blame-region--bg2");
-  private static final ImmutableList<SoyMapData> NULLS;
+  private static final ImmutableList<ImmutableMap<String, Object>> NULLS;
 
   static {
-    ImmutableList.Builder<SoyMapData> nulls = ImmutableList.builder();
+    ImmutableList.Builder<ImmutableMap<String, Object>> nulls = ImmutableList.builder();
     for (String clazz : CLASSES) {
-      nulls.add(new SoyMapData("class", clazz));
+      nulls.add(ImmutableMap.of("class", clazz));
     }
     NULLS = nulls.build();
   }
 
-  private static SoyListData toSoyData(
+  private static List<ImmutableMap<String, Object>> toSoyData(
       GitilesView view, ObjectReader reader, List<Region> regions, DateFormatter df)
       throws IOException {
     Map<ObjectId, String> abbrevShas = Maps.newHashMap();
-    SoyListData result = new SoyListData();
+    ImmutableList.Builder<ImmutableMap<String, Object>> result = ImmutableList.builder();
 
     for (int i = 0; i < regions.size(); i++) {
       Region r = regions.get(i);
@@ -234,7 +232,7 @@
           abbrevSha = reader.abbreviate(r.getSourceCommit()).name();
           abbrevShas.put(r.getSourceCommit(), abbrevSha);
         }
-        Map<String, Object> e = Maps.newHashMapWithExpectedSize(6);
+        ImmutableMap.Builder<String, Object> e = ImmutableMap.builder();
         e.put("abbrevSha", abbrevSha);
         String blameParent = "";
         String blameText = "blame";
@@ -262,7 +260,7 @@
                 .toUrl());
         e.put("author", CommitSoyData.toSoyData(r.getSourceAuthor(), df));
         e.put("class", CLASSES.get(c));
-        result.add(e);
+        result.add(e.build());
       }
       // Pad the list with null regions so we can iterate in parallel in the
       // template. We can't do this by maintaining an index variable into the
@@ -272,6 +270,6 @@
         result.add(NULLS.get(c));
       }
     }
-    return result;
+    return result.build();
   }
 }
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/HostIndexServletTest.java b/javatests/com/google/gitiles/HostIndexServletTest.java
index d8332d1..a79ee8c 100644
--- a/javatests/com/google/gitiles/HostIndexServletTest.java
+++ b/javatests/com/google/gitiles/HostIndexServletTest.java
@@ -18,9 +18,8 @@
 import static com.google.gitiles.TestGitilesUrls.URLS;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gson.reflect.TypeToken;
-import com.google.template.soy.data.SoyListData;
-import com.google.template.soy.data.SoyMapData;
 import com.google.template.soy.data.restricted.NullData;
 import java.util.Map;
 import javax.servlet.http.HttpServletResponse;
@@ -48,15 +47,16 @@
 
   @Test
   public void rootHtml() throws Exception {
-    Map<String, ?> data = buildData("/");
+    Map<String, Object> data = buildData("/");
     assertThat(data).containsEntry("hostName", URLS.getHostName(null));
     assertThat(data).containsEntry("breadcrumbs", NullData.INSTANCE);
     assertThat(data).containsEntry("prefix", "");
 
-    SoyListData repos = (SoyListData) data.get("repositories");
+    ImmutableList<Map<String, Object>> repos =
+        (ImmutableList<Map<String, Object>>) data.get("repositories");
     assertThat(repos).hasSize(1);
 
-    SoyMapData ent = (SoyMapData) repos.get(0);
+    Map<String, Object> ent = repos.get(0);
     assertThat(ent.get("name").toString()).isEqualTo(NAME);
     assertThat(ent.get("url").toString()).isEqualTo("/b/" + NAME + "/");
   }
@@ -67,13 +67,15 @@
     assertThat(data).containsEntry("hostName", URLS.getHostName(null) + "/foo");
     assertThat(data).containsEntry("prefix", "foo/");
 
-    SoyListData breadcrumbs = (SoyListData) data.get("breadcrumbs");
-    assertThat(breadcrumbs.length()).isEqualTo(2);
+    ImmutableList<Map<String, Object>> breadcrumbs =
+        (ImmutableList<Map<String, Object>>) data.get("breadcrumbs");
+    assertThat(breadcrumbs.size()).isEqualTo(2);
 
-    SoyListData repos = (SoyListData) data.get("repositories");
-    assertThat(repos.length()).isEqualTo(1);
+    ImmutableList<Map<String, Object>> repos =
+        (ImmutableList<Map<String, Object>>) data.get("repositories");
+    assertThat(repos.size()).isEqualTo(1);
 
-    SoyMapData ent = (SoyMapData) repos.get(0);
+    Map<String, Object> ent = repos.get(0);
     assertThat(ent.get("name").toString()).isEqualTo("bar/repo");
     assertThat(ent.get("url").toString()).isEqualTo("/b/" + NAME + "/");
   }
@@ -84,13 +86,15 @@
     assertThat(data).containsEntry("hostName", URLS.getHostName(null) + "/foo/bar");
     assertThat(data).containsEntry("prefix", "foo/bar/");
 
-    SoyListData breadcrumbs = (SoyListData) data.get("breadcrumbs");
-    assertThat(breadcrumbs.length()).isEqualTo(3);
+    ImmutableList<Map<String, Object>> breadcrumbs =
+        (ImmutableList<Map<String, Object>>) data.get("breadcrumbs");
+    assertThat(breadcrumbs.size()).isEqualTo(3);
 
-    SoyListData repos = (SoyListData) data.get("repositories");
-    assertThat(repos.length()).isEqualTo(1);
+    ImmutableList<Map<String, Object>> repos =
+        (ImmutableList<Map<String, Object>>) data.get("repositories");
+    assertThat(repos.size()).isEqualTo(1);
 
-    SoyMapData ent = (SoyMapData) repos.get(0);
+    Map<String, Object> ent = repos.get(0);
     assertThat(ent.get("name").toString()).isEqualTo("repo");
     assertThat(ent.get("url").toString()).isEqualTo("/b/" + NAME + "/");
   }
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/PathServletTest.java b/javatests/com/google/gitiles/PathServletTest.java
index c0f98b1..c8ab575 100644
--- a/javatests/com/google/gitiles/PathServletTest.java
+++ b/javatests/com/google/gitiles/PathServletTest.java
@@ -102,6 +102,34 @@
   }
 
   @Test
+  public void fileWithMaxLines() throws Exception {
+    int MAX_LINE_COUNT = 50000;
+    StringBuilder contentBuilder = new StringBuilder();
+    for (int i = 1; i < MAX_LINE_COUNT; i++) {
+      contentBuilder.append("\n");
+    }
+    repo.branch("master").commit().add("bar", contentBuilder.toString()).create();
+
+    Map<String, ?> data = buildData("/repo/+/master/bar");
+    SoyListData lines = (SoyListData) getBlobData(data).get("lines");
+    assertThat(lines.length()).isEqualTo(MAX_LINE_COUNT - 1);
+  }
+
+  @Test
+  public void fileLargerThanSupportedLines() throws Exception {
+    int MAX_LINE_COUNT = 50000;
+    StringBuilder contentBuilder = new StringBuilder();
+    for (int i = 1; i <= MAX_LINE_COUNT; i++) {
+      contentBuilder.append("\n");
+    }
+    repo.branch("master").commit().add("largebar", contentBuilder.toString()).create();
+
+    Map<String, ?> data = buildData("/repo/+/master/largebar");
+    SoyListData lines = (SoyListData) getBlobData(data).get("lines");
+    assertThat(lines).isNull();
+  }
+
+  @Test
   public void symlinkHtml() throws Exception {
     final RevBlob link = repo.blob("foo");
     repo.branch("master")
diff --git a/javatests/com/google/gitiles/RevisionParserTest.java b/javatests/com/google/gitiles/RevisionParserTest.java
index a204b61..b2c88ea 100644
--- a/javatests/com/google/gitiles/RevisionParserTest.java
+++ b/javatests/com/google/gitiles/RevisionParserTest.java
@@ -256,6 +256,14 @@
   }
 
   @Test
+  public void parseEmailInRevision() throws Exception {
+    RevCommit c = repo.commit().create();
+    repo.update("refs/experimental/author@example.com/foo", c);
+    assertThat(parser.parse("refs/experimental/author@example.com/foo"))
+        .isEqualTo(new Result(Revision.peeled("refs/experimental/author@example.com/foo", c)));
+  }
+
+  @Test
   public void parseMissingSha() throws Exception {
     assertThat(parser.parse("deadbeef")).isNull();
     assertThat(parser.parse("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef")).isNull();
diff --git a/javatests/com/google/gitiles/ServletTest.java b/javatests/com/google/gitiles/ServletTest.java
index c642a79..df349f0 100644
--- a/javatests/com/google/gitiles/ServletTest.java
+++ b/javatests/com/google/gitiles/ServletTest.java
@@ -94,7 +94,7 @@
     return buildHtml(path, true);
   }
 
-  protected Map<String, ?> buildData(String path) throws Exception {
+  protected Map<String, Object> buildData(String path) throws Exception {
     // Render the page through Soy to ensure templates are valid, then return
     // the Soy data for introspection.
     FakeHttpServletRequest req = FakeHttpServletRequest.newRequest();
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/lib/BUILD b/lib/BUILD
index e1f4654..828ff38 100644
--- a/lib/BUILD
+++ b/lib/BUILD
@@ -23,4 +23,9 @@
     "guava",
     "guava-failureaccess",
     "prettify",
+    "ow2-asm",
+    "ow2-asm-analysis",
+    "ow2-asm-commons",
+    "ow2-asm-tree",
+    "ow2-asm-util",
 ]]
diff --git a/lib/soy/BUILD b/lib/soy/BUILD
index 9bf2a39..f6f0b8e 100644
--- a/lib/soy/BUILD
+++ b/lib/soy/BUILD
@@ -12,6 +12,11 @@
     runtime_deps = [
         "@html-types//jar",
         "@icu4j//jar",
+        "@ow2-asm-analysis//jar",
+        "@ow2-asm-commons//jar",
+        "@ow2-asm-tree//jar",
+        "@ow2-asm-util//jar",
+        "@ow2-asm//jar",
         "@protobuf//jar",
     ],
 )
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 6caa1da..ab2b9b3 100644
--- a/tools/BUILD
+++ b/tools/BUILD
@@ -30,11 +30,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",
@@ -45,7 +46,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",
@@ -69,7 +70,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 d361e3e..77853e2 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-11"
+GITILES_VERSION = "0.3-5"