Merge branch 'stable-0.2'

* stable-0.2:
  Bump version to 0.2-12
  Bump required Bazel version to 2.1.0
  Upgrade bazlets to latest master revision
  Set version to 0.2-7.1
  Fix formatting of Soy files
  Bump bazel version to 1.2.0
  Backport all build-related commits from master to stable-0.2
  Enable more error-prone checks
  Enable error-prone checks by default
  Update bazel options to align with gerrit's
  tools/eclipse/project.sh: Use bazel query to find project.py
  Harmonize build rule names to use hyphen instead of underscore
  Check BLOB content size before trying to render it
  BlameCacheImpl: Avoid NPE if path does not exist
  Navbar: Fix handling of [home] and [logo] metalinks
  Don't render navbar.md metadata in html
  Format with google-java-format 1.7
  Do not retain body in RevisionParser walk
  Remove unnecessary parseBody call from LogServlet
  Ensure RevTag is parsed before using its body
  Ensure RevCommit is parsed before using its content
  Require a RevWalk when building CommitData
  Convert /** @param */ to {@param} in Soy.
  RefServlet: Use full refname in link
  Automatically format all build files with buildifier lint mode

Change-Id: Ib9b31c72c276c6f95cd78802ffe818fb52338b68
diff --git a/Documentation/developer-guide.md b/Documentation/developer-guide.md
index 52c9192..398a2e7 100644
--- a/Documentation/developer-guide.md
+++ b/Documentation/developer-guide.md
@@ -26,7 +26,7 @@
 http://localhost:8080/ to view your local copy of gitiles, which
 will serve any repositories under `/path/to/repositories`.
 
-To run unit tests, run `bazel test ...`.
+To run unit tests, refer to the aforementioned bazel test command.
 
 
 ## Eclipse IDE
diff --git a/WORKSPACE b/WORKSPACE
index 4c16a90..b3f373c 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -28,7 +28,7 @@
 load("//tools:bazlets.bzl", "load_bazlets")
 
 load_bazlets(
-    commit = "fbe2b2fd07c95d752dced6b8624c9d5a08e8c6c6",
+    commit = "f30a992da9fc855dce819875afb59f9dd6f860cd",
     # local_path = "/home/<user>/projects/bazlets",
 )
 
@@ -58,8 +58,8 @@
 
 maven_jar(
     name = "guava",
-    artifact = "com.google.guava:guava:27.1-jre",
-    sha1 = "e47b59c893079b87743cdcfb6f17ca95c08c592c",
+    artifact = "com.google.guava:guava:28.2-jre",
+    sha1 = "8ec9ed76528425762174f0011ce8f74ad845b756",
 )
 
 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.1",
+    sha1 = "361459309085bd9441cb97b62f160e8b353a93c0",
 )
 
 # 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.5.1.201910021850-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 = "e0ba7a468e8c62da8521ca3a06a061d4dde95223",
 )
 
 maven_jar(
     name = "jgit-servlet",
     artifact = "org.eclipse.jgit:org.eclipse.jgit.http.server:" + JGIT_VERS,
     repository = JGIT_REPO,
-    sha1 = "3287341fca859340a00b51cb5dd3b78b8e532b39",
+    sha1 = "dde1857a91504fadda0af4bb8958d11cfb14dcfe",
 )
 
 maven_jar(
     name = "jgit-junit",
     artifact = "org.eclipse.jgit:org.eclipse.jgit.junit:" + JGIT_VERS,
     repository = JGIT_REPO,
-    sha1 = "3d9ba7e610d6ab5d08dcb1e4ba448b592a34de77",
+    sha1 = "894f85c1615d1b47def1018bd98ca65dcaf5a8d5",
 )
 
 maven_jar(
     name = "jgit-archive",
     artifact = "org.eclipse.jgit:org.eclipse.jgit.archive:" + JGIT_VERS,
     repository = JGIT_REPO,
-    sha1 = "3585027e83fb44a5de2c10ae9ddbf976593bf080",
+    sha1 = "1dd6de0d52ad3055cee6ba9b34764b08d85a5238",
 )
 
 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 c906b4a..2051d2a 100644
--- a/java/com/google/gitiles/BlobSoyData.java
+++ b/java/com/google/gitiles/BlobSoyData.java
@@ -50,6 +50,13 @@
    */
   @VisibleForTesting 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;
 
@@ -73,6 +80,9 @@
       byte[] raw = loader.getCachedBytes(MAX_FILE_SIZE);
       content =
           (raw.length < MAX_FILE_SIZE && !RawText.isBinary(raw)) ? RawParseUtils.decode(raw) : null;
+      if (isContentTooLargeForDisplay(content)) {
+        content = null;
+      }
     } catch (LargeObjectException.OutOfMemory e) {
       throw e;
     } catch (LargeObjectException e) {
@@ -190,4 +200,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/DescribeServlet.java b/java/com/google/gitiles/DescribeServlet.java
index ae1d4c8..545f3aa 100644
--- a/java/com/google/gitiles/DescribeServlet.java
+++ b/java/com/google/gitiles/DescribeServlet.java
@@ -80,7 +80,7 @@
     try {
       return repo.resolve(rev);
     } catch (RevisionSyntaxException e) {
-      throw new GitilesRequestFailureException(FailureReason.INCORECT_PARAMETER)
+      throw new GitilesRequestFailureException(FailureReason.INCORECT_PARAMETER, e)
           .withPublicErrorMessage(
               "Invalid revision syntax: %s", RefServlet.sanitizeRefForText(rev));
     } catch (AmbiguousObjectException e) {
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..3013096 100644
--- a/java/com/google/gitiles/GitilesFilter.java
+++ b/java/com/google/gitiles/GitilesFilter.java
@@ -382,10 +382,9 @@
   private void setDefaultVisibilityCache() {
     if (visibilityCache == null) {
       if (config.getSubsections("cache").contains("visibility")) {
-        visibilityCache =
-            new VisibilityCache(false, ConfigUtil.getCacheBuilder(config, "visibility"));
+        visibilityCache = new VisibilityCache(ConfigUtil.getCacheBuilder(config, "visibility"));
       } else {
-        visibilityCache = new VisibilityCache(false);
+        visibilityCache = new VisibilityCache();
       }
     }
   }
@@ -420,7 +419,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/GitwebRedirectFilter.java b/java/com/google/gitiles/GitwebRedirectFilter.java
index 0d0c74f..cc8bca0 100644
--- a/java/com/google/gitiles/GitwebRedirectFilter.java
+++ b/java/com/google/gitiles/GitwebRedirectFilter.java
@@ -131,7 +131,7 @@
               .setServletPath(gitwebView.getServletPath())
               .toUrl();
     } catch (InvalidViewException e) {
-      throw new GitilesRequestFailureException(FailureReason.UNSUPPORTED_GITWEB_URL);
+      throw new GitilesRequestFailureException(FailureReason.UNSUPPORTED_GITWEB_URL, e);
     }
     res.setStatus(SC_MOVED_PERMANENTLY);
     res.setHeader(LOCATION, url);
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..c738f0e 100644
--- a/java/com/google/gitiles/LogServlet.java
+++ b/java/com/google/gitiles/LogServlet.java
@@ -71,6 +71,9 @@
   private static final String FOLLOW_PARAM = "follow";
   private static final String NAME_STATUS_PARAM = "name-status";
   private static final String PRETTY_PARAM = "pretty";
+  private static final String TOPO_ORDER_PARAM = "topo-order";
+  private static final String REVERSE_PARAM = "reverse";
+  private static final String FIRST_PARENT_PARAM = "first-parent";
 
   private static final int DEFAULT_LIMIT = 100;
   private static final int MAX_LIMIT = 10000;
@@ -113,7 +116,7 @@
       }
 
       String title = "Log - ";
-      if (view.getOldRevision() != Revision.NULL) {
+      if (!Revision.isNull(view.getOldRevision())) {
         title += view.getRevisionRange();
       } else {
         title += view.getRevision().getName();
@@ -175,7 +178,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);
@@ -223,9 +226,18 @@
   private static RevWalk newWalk(Repository repo, GitilesView view, GitilesAccess access)
       throws MissingObjectException, IOException {
     RevWalk walk = new RevWalk(repo);
+    if (isTrue(view, FIRST_PARENT_PARAM)) {
+      walk.setFirstParent(true);
+    }
+    if (isTrue(view, TOPO_ORDER_PARAM)) {
+      walk.sort(RevSort.TOPO, true);
+    }
+    if (isTrue(view, REVERSE_PARAM)) {
+      walk.sort(RevSort.REVERSE, true);
+    }
     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) {
@@ -233,12 +245,6 @@
     }
     setTreeFilter(walk, view, access);
     setRevFilter(walk, view);
-    if (isTrue(view, "topo-order")) {
-      walk.sort(RevSort.TOPO, true);
-    }
-    if (isTrue(view, "reverse")) {
-      walk.sort(RevSort.REVERSE, true);
-    }
     return walk;
   }
 
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..4305d1d 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,25 +83,48 @@
   }
 
   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);
   }
 
-  public VisibilityCache(boolean topoSort) {
-    this(topoSort, defaultBuilder());
+  public VisibilityCache() {
+    this(new VisibilityChecker(), defaultBuilder());
   }
 
-  public VisibilityCache(boolean topoSort, CacheBuilder<Object, Object> builder) {
+  public VisibilityCache(CacheBuilder<Object, Object> builder) {
+    this(new VisibilityChecker(), builder);
+  }
+
+  /**
+   * Use the constructors with a boolean parameter (e.g. {@link #VisibilityCache()}). 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()}). 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..fd81396
--- /dev/null
+++ b/java/com/google/gitiles/VisibilityChecker.java
@@ -0,0 +1,91 @@
+// Copyright 2019 Google LLC
+//
+// 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
+//
+//     https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gitiles;
+
+import com.google.common.collect.ImmutableList;
+import 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.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 {
+
+  /**
+   * 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 referring to other kinds 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;
+    }
+
+    ImmutableList<RevCommit> startCommits = objectIdsToCommits(walk, starters);
+    if (startCommits.isEmpty()) {
+      return false;
+    }
+
+    return !walk.createReachabilityChecker()
+        .areAllReachable(ImmutableList.of(commit), startCommits)
+        .isPresent();
+  }
+
+  private static ImmutableList<RevCommit> objectIdsToCommits(RevWalk walk, Collection<ObjectId> ids)
+      throws IOException {
+    ImmutableList.Builder<RevCommit> commits = ImmutableList.builder();
+    for (ObjectId id : ids) {
+      try {
+        commits.add(walk.parseCommit(id));
+      } catch (MissingObjectException e) {
+        // TODO(ifrade): ResolveParser has already checked that the object exists in the repo.
+        // Report as AssertionError.
+      } catch (IncorrectObjectTypeException e) {
+        // Ignore, doesn't affect commit reachability
+      }
+    }
+    return commits.build();
+  }
+}
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/DocServlet.java b/java/com/google/gitiles/doc/DocServlet.java
index ca83ae0..0cbea50 100644
--- a/java/com/google/gitiles/doc/DocServlet.java
+++ b/java/com/google/gitiles/doc/DocServlet.java
@@ -95,7 +95,7 @@
       try {
         root = rw.parseTree(view.getRevision().getId());
       } catch (IncorrectObjectTypeException e) {
-        throw new GitilesRequestFailureException(FailureReason.INCORRECT_OBJECT_TYPE);
+        throw new GitilesRequestFailureException(FailureReason.INCORRECT_OBJECT_TYPE, e);
       }
 
       MarkdownFile srcmd = findFile(rw, root, path);
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/LogServletTest.java b/javatests/com/google/gitiles/LogServletTest.java
index 06ddaf4..7232439 100644
--- a/javatests/com/google/gitiles/LogServletTest.java
+++ b/javatests/com/google/gitiles/LogServletTest.java
@@ -67,6 +67,19 @@
   }
 
   @Test
+  public void firstParentLog() throws Exception {
+    RevCommit p1 = repo.update("master", repo.commit().add("foo", "foo\n"));
+    RevCommit p2 = repo.update("master", repo.commit().add("foo", "foo2\n"));
+    RevCommit c = repo.update("master", repo.commit().parent(p1).parent(p2).add("foo", "foo3\n"));
+
+    Log response = buildJson(LOG, "/repo/+log/master", "first-parent");
+    assertThat(response.log).hasSize(2);
+
+    verifyJsonCommit(response.log.get(0), c);
+    verifyJsonCommit(response.log.get(1), p1);
+  }
+
+  @Test
   public void follow() throws Exception {
     String contents = "contents";
     RevCommit c1 = repo.branch("master").commit().add("foo", contents).create();
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 998ede1..4311833 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 largeFileHtml() throws Exception {
     int largeContentSize = BlobSoyData.MAX_FILE_SIZE + 1;
     repo.branch("master").commit().add("foo", generateContent(largeContentSize)).create();
diff --git a/javatests/com/google/gitiles/RevisionParserTest.java b/javatests/com/google/gitiles/RevisionParserTest.java
index a204b61..16e76a7 100644
--- a/javatests/com/google/gitiles/RevisionParserTest.java
+++ b/javatests/com/google/gitiles/RevisionParserTest.java
@@ -46,7 +46,7 @@
         new RevisionParser(
             repo.getRepository(),
             new TestGitilesAccess(repo.getRepository()).forRequest(null),
-            new VisibilityCache(false, CacheBuilder.newBuilder().maximumSize(0)));
+            new VisibilityCache(CacheBuilder.newBuilder().maximumSize(0)));
   }
 
   @Test
@@ -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/TestViewFilter.java b/javatests/com/google/gitiles/TestViewFilter.java
index 68acffb..4f67efc 100644
--- a/javatests/com/google/gitiles/TestViewFilter.java
+++ b/javatests/com/google/gitiles/TestViewFilter.java
@@ -65,7 +65,7 @@
         new ViewFilter(
             new TestGitilesAccess(repo.getRepository()),
             TestGitilesUrls.URLS,
-            new VisibilityCache(false));
+            new VisibilityCache());
     MetaFilter mf = new MetaFilter();
 
     for (Pattern p : ImmutableList.of(ROOT_REGEX, REPO_REGEX, REPO_PATH_REGEX)) {
diff --git a/javatests/com/google/gitiles/VisibilityCacheTest.java b/javatests/com/google/gitiles/VisibilityCacheTest.java
index f22cf02..e271e71 100644
--- a/javatests/com/google/gitiles/VisibilityCacheTest.java
+++ b/javatests/com/google/gitiles/VisibilityCacheTest.java
@@ -79,7 +79,7 @@
       git.update("refs/tags/v0.1", commitA);
     }
 
-    visibilityCache = new VisibilityCache(true);
+    visibilityCache = new VisibilityCache();
     walk = new RevWalk(repo);
     walk.setRetainBody(false);
   }
diff --git a/javatests/com/google/gitiles/VisibilityCheckerTest.java b/javatests/com/google/gitiles/VisibilityCheckerTest.java
new file mode 100644
index 0000000..bf416bb
--- /dev/null
+++ b/javatests/com/google/gitiles/VisibilityCheckerTest.java
@@ -0,0 +1,92 @@
+// Copyright 2019 Google LLC
+//
+// 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
+//
+//     https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gitiles;
+
+import static org.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();
+    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..5cfe48c 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",
@@ -81,6 +82,7 @@
         "-Xep:TypeParameterUnusedInFormals:ERROR",
         "-Xep:URLEqualsHashCode:ERROR",
         "-Xep:UnsynchronizedOverridesSynchronized:ERROR",
+        "-Xep:UnusedException:ERROR",
         "-Xep:WaitNotInLoop:ERROR",
         "-Xep:WildcardImport:ERROR",
     ],
diff --git a/version.bzl b/version.bzl
index 6d3ce4d..7ac9881 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-12"
+GITILES_VERSION = "0.3-7"