Merge "Lists subdirectories at the top of the source tree"
diff --git a/.bazelversion b/.bazelversion
index fd2a018..fcdb2e1 100644
--- a/.bazelversion
+++ b/.bazelversion
@@ -1 +1 @@
-3.1.0
+4.0.0
diff --git a/Documentation/developer-guide.md b/Documentation/developer-guide.md
index 398a2e7..1716e80 100644
--- a/Documentation/developer-guide.md
+++ b/Documentation/developer-guide.md
@@ -115,7 +115,10 @@
 
 Gitiles artifacts are published to the [gerrit-maven
 bucket](http://gerrit-maven.storage.googleapis.com/). To release a new version,
-you must have write access to this bucket.
+you must have write access to this bucket. See
+[Deploy Gerrit
+Artifacts](https://gerrit-review.googlesource.com/Documentation/dev-release-deploy-config.html)
+for PGP key setup and Google Cloud Storage access setup.
 
 First, increment `GITILES_VERSION` in `version.bzl`. Technically, Gitiles uses
 the
@@ -129,7 +132,8 @@
 ./tools/maven/mvn.sh deploy
 ```
 
-Tag the release with an annotated tag matching the version number.
+Tag the release with a signed, annotated tag matching the version number, for
+example "v0.4-1".
 
 Once released, Maven projects can consume the new version as long as they point
 at the proper repository URL. Similarly, Bazel projects using the `maven_jar`
diff --git a/Documentation/markdown.md b/Documentation/markdown.md
index 23e3bae..e6b766c 100644
--- a/Documentation/markdown.md
+++ b/Documentation/markdown.md
@@ -443,8 +443,8 @@
 Relative and absolute links to image files within the Git repository
 (such as `../images/banner.png`) are resolved during rendering by
 inserting the base64 encoding of the image using a `data:` URI.  Only
-PNG (`*.png`), JPEG (`*.jpg` or `*.jpeg`) and GIF (`*.gif`) image
-formats are supported when referenced from the Git repository.
+PNG (`*.png`), JPEG (`*.jpg` or `*.jpeg`), GIF (`*.gif`) and WebP (`*.webp`)
+image formats are supported when referenced from the Git repository.
 
 Unsupported extensions or files larger than [image size](#Image-size)
 limit (default 256K) will display a broken image.
diff --git a/WORKSPACE b/WORKSPACE
index 1874de0..6359814 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -24,8 +24,8 @@
 
 maven_jar(
     name = "commons-lang3",
-    artifact = "org.apache.commons:commons-lang3:3.6",
-    sha1 = "9d28a6b23650e8a7e9063c04588ace6cf7012c17",
+    artifact = "org.apache.commons:commons-lang3:3.8.1",
+    sha1 = "6505a72a097d9270f7a9e7bf42c4238283247755",
 )
 
 maven_jar(
@@ -42,8 +42,8 @@
 
 maven_jar(
     name = "guava",
-    artifact = "com.google.guava:guava:29.0-jre",
-    sha1 = "801142b4c3d0f0770dd29abea50906cacfddd447",
+    artifact = "com.google.guava:guava:30.1-jre",
+    sha1 = "00d0c3ce2311c9e36e73228da25a6e99b2ab826f",
 )
 
 maven_jar(
@@ -54,9 +54,9 @@
 
 maven_jar(
     name = "jsr305",
-    artifact = "com.google.code.findbugs:jsr305:3.0.0",
+    artifact = "com.google.code.findbugs:jsr305:3.0.1",
     attach_source = False,
-    sha1 = "5871fb60dc68d67da54a663c3fd636a10a532948",
+    sha1 = "f7be08ec23c21485b9b5a1cf1654c2ec8c58168d",
 )
 
 # When upgrading prettify it should also be updated in plugins/gitiles
@@ -106,15 +106,15 @@
 )
 
 maven_jar(
-    name = "servlet-api_3_0",
-    artifact = "org.eclipse.jetty.orbit:javax.servlet:3.0.0.v201112011016",
-    sha1 = "0aaaa85845fb5c59da00193f06b8e5278d8bf3f8",
+    name = "servlet-api_3_1",
+    artifact = "javax.servlet:javax.servlet-api:3.1.0",
+    sha1 = "3cd63d075497751784b2fa84be59432f4905bf7c",
 )
 
 maven_jar(
     name = "truth",
-    artifact = "com.google.truth:truth:1.0.1",
-    sha1 = "361459309085bd9441cb97b62f160e8b353a93c0",
+    artifact = "com.google.truth:truth:1.1",
+    sha1 = "6a096a16646559c24397b03f797d0c9d75ee8720",
 )
 
 # Indirect dependency of truth
@@ -126,8 +126,8 @@
 
 maven_jar(
     name = "soy",
-    artifact = "com.google.template:soy:2019-10-08",
-    sha1 = "4518bf8bac2dbbed684849bc209c39c4cb546237",
+    artifact = "com.google.template:soy:2021-02-01",
+    sha1 = "8e833744832ba88059205a1e30e0898f925d8cb5",
 )
 
 maven_jar(
@@ -148,7 +148,7 @@
     sha1 = "198ea005f41219f038f4291f0b0e9f3259730e92",
 )
 
-JGIT_VERS = "5.7.0.202003110725-r"
+JGIT_VERS = "5.12.0.202106070339-r"
 
 JGIT_REPO = MAVEN_CENTRAL
 
@@ -156,34 +156,34 @@
     name = "jgit-lib",
     artifact = "org.eclipse.jgit:org.eclipse.jgit:" + JGIT_VERS,
     repository = JGIT_REPO,
-    sha1 = "8dfe333ee6850df171a3d6b696aca3f93e23abc3",
+    sha1 = "b7792da62103c956d3e58e29fb2e6e5c5f0e1317",
 )
 
 maven_jar(
     name = "jgit-servlet",
     artifact = "org.eclipse.jgit:org.eclipse.jgit.http.server:" + JGIT_VERS,
     repository = JGIT_REPO,
-    sha1 = "9c4ee5af7f0b42f4589acb255b7bb3543d7d70d6",
+    sha1 = "c50ee52951bdcd119af0181926c25e09ae913aab",
 )
 
 maven_jar(
     name = "jgit-junit",
     artifact = "org.eclipse.jgit:org.eclipse.jgit.junit:" + JGIT_VERS,
     repository = JGIT_REPO,
-    sha1 = "a90513295c1cba0b289430512f0aa9d984d7cba2",
+    sha1 = "1bb81c9104f318f16748dbaa43f95509a53e7aa0",
 )
 
 maven_jar(
     name = "jgit-archive",
     artifact = "org.eclipse.jgit:org.eclipse.jgit.archive:" + JGIT_VERS,
     repository = JGIT_REPO,
-    sha1 = "4456eb727ed1289121962d74a79c6add9ab3e0c2",
+    sha1 = "93f59b510a923bd757ea6b2a6e359d222daf2e1d",
 )
 
 maven_jar(
     name = "ewah",
-    artifact = "com.googlecode.javaewah:JavaEWAH:1.1.6",
-    sha1 = "94ad16d728b374d65bd897625f3fbb3da223a2b6",
+    artifact = "com.googlecode.javaewah:JavaEWAH:1.1.7",
+    sha1 = "570dde3cd706ae10c62fe19b150928cfdb415e87",
 )
 
 # When upgrading commons_compress, upgrade tukaani_xz to the
@@ -215,32 +215,32 @@
     sha1 = "42a25dc3219429f0e5d060061f71acb49bf010a0",
 )
 
-SL_VERS = "1.7.7"
+SL_VERS = "1.7.26"
 
 maven_jar(
     name = "slf4j-api",
     artifact = "org.slf4j:slf4j-api:" + SL_VERS,
-    sha1 = "2b8019b6249bb05d81d3a3094e468753e2b21311",
+    sha1 = "77100a62c2e6f04b53977b9f541044d7d722693d",
 )
 
 maven_jar(
     name = "slf4j-simple",
     artifact = "org.slf4j:slf4j-simple:" + SL_VERS,
-    sha1 = "8095d0b9f7e0a9cd79a663c740e0f8fb31d0e2c8",
+    sha1 = "dfb0de47f433c2a37dd44449c88d84b698cd5cf7",
 )
 
-GUICE_VERSION = "4.2.3"
+GUICE_VERSION = "5.0.1"
 
 maven_jar(
     name = "guice-library",
     artifact = "com.google.inject:guice:" + GUICE_VERSION,
-    sha1 = "2ea992d6d7bdcac7a43111a95d182a4c42eb5ff7",
+    sha1 = "0dae7556b441cada2b4f0a2314eb68e1ff423429",
 )
 
 maven_jar(
     name = "guice-assistedinject",
     artifact = "com.google.inject.extensions:guice-assistedinject:" + GUICE_VERSION,
-    sha1 = "acbfddc556ee9496293ed1df250cc378f331d854",
+    sha1 = "62e02f2aceb7d90ba354584dacc018c1e94ff01c",
 )
 
 maven_jar(
@@ -255,78 +255,78 @@
     sha1 = "6975da39a7040257bd51d21a231b76c915872d38",
 )
 
-JETTY_VERSION = "9.4.18.v20190429"
+JETTY_VERSION = "9.4.36.v20210114"
 
 maven_jar(
     name = "servlet",
     artifact = "org.eclipse.jetty:jetty-servlet:" + JETTY_VERSION,
-    sha1 = "290f7a88f351950d51ebc9fb4a794752c62d7de5",
+    sha1 = "b189e52a5ee55ae172e4e99e29c5c314f5daf4b9",
 )
 
 maven_jar(
     name = "security",
     artifact = "org.eclipse.jetty:jetty-security:" + JETTY_VERSION,
-    sha1 = "01aceff3608ca1b223bfd275a497797cfe675ef4",
+    sha1 = "42030d6ed7dfc0f75818cde0adcf738efc477574",
 )
 
 maven_jar(
     name = "server",
     artifact = "org.eclipse.jetty:jetty-server:" + JETTY_VERSION,
-    sha1 = "b76ef50e04635f11d4d43bc6ccb7c4482a8384f0",
+    sha1 = "88a7d342974aadca658e7386e8d0fcc5c0788f41",
 )
 
 maven_jar(
     name = "continuation",
     artifact = "org.eclipse.jetty:jetty-continuation:" + JETTY_VERSION,
-    sha1 = "3c421a3be5be5805e32b1a7f9c6046526524181d",
+    sha1 = "84dcd3bc44258d6e2e552f59c77966c4ed252373",
 )
 
 maven_jar(
     name = "http",
     artifact = "org.eclipse.jetty:jetty-http:" + JETTY_VERSION,
-    sha1 = "c2e73db2db5c369326b717da71b6587b3da11e0e",
+    sha1 = "1eee89a55e04ff94df0f85d95200fc48acb43d86",
 )
 
 maven_jar(
     name = "io",
     artifact = "org.eclipse.jetty:jetty-io:" + JETTY_VERSION,
-    sha1 = "844af5efe58ab23fd0166a796efef123f4cb06b0",
+    sha1 = "84a8faf9031eb45a5a2ddb7681e22c483d81ab3a",
 )
 
 maven_jar(
     name = "util",
     artifact = "org.eclipse.jetty:jetty-util:" + JETTY_VERSION,
-    sha1 = "13e6148bfda7ae511f69ae7e5e3ea898bc9b0e33",
+    sha1 = "925257fbcca6b501a25252c7447dbedb021f7404",
 )
 
-OW2_VERS = "7.0"
+OW2_VERS = "9.0"
 
 maven_jar(
     name = "ow2-asm",
     artifact = "org.ow2.asm:asm:" + OW2_VERS,
-    sha1 = "d74d4ba0dee443f68fb2dcb7fcdb945a2cd89912",
+    sha1 = "af582ff60bc567c42d931500c3fdc20e0141ddf9",
 )
 
 maven_jar(
     name = "ow2-asm-analysis",
     artifact = "org.ow2.asm:asm-analysis:" + OW2_VERS,
-    sha1 = "4b310d20d6f1c6b7197a75f1b5d69f169bc8ac1f",
+    sha1 = "4630afefbb43939c739445dde0af1a5729a0fb4e",
 )
 
 maven_jar(
     name = "ow2-asm-commons",
     artifact = "org.ow2.asm:asm-commons:" + OW2_VERS,
-    sha1 = "478006d07b7c561ae3a92ddc1829bca81ae0cdd1",
+    sha1 = "5a34a3a9ac44f362f35d1b27932380b0031a3334",
 )
 
 maven_jar(
     name = "ow2-asm-tree",
     artifact = "org.ow2.asm:asm-tree:" + OW2_VERS,
-    sha1 = "29bc62dcb85573af6e62e5b2d735ef65966c4180",
+    sha1 = "9df939f25c556b0c7efe00701d47e77a49837f24",
 )
 
 maven_jar(
     name = "ow2-asm-util",
     artifact = "org.ow2.asm:asm-util:" + OW2_VERS,
-    sha1 = "18d4d07010c24405129a6dbb0e92057f8779fb9d",
+    sha1 = "7c059a94ab5eed3347bf954e27fab58e52968848",
 )
diff --git a/java/com/google/gitiles/BranchRedirectFilter.java b/java/com/google/gitiles/BranchRedirectFilter.java
new file mode 100644
index 0000000..12dc2b4
--- /dev/null
+++ b/java/com/google/gitiles/BranchRedirectFilter.java
@@ -0,0 +1,121 @@
+// Copyright 2021 Google LLC. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gitiles;
+
+import static com.google.common.net.HttpHeaders.LOCATION;
+import static com.google.gitiles.FormatType.HTML;
+import static javax.servlet.http.HttpServletResponse.SC_MOVED_PERMANENTLY;
+import static org.eclipse.jgit.http.server.ServletUtils.ATTRIBUTE_REPOSITORY;
+
+import java.io.IOException;
+import java.util.Optional;
+import javax.servlet.FilterChain;
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import org.eclipse.jgit.http.server.ServletUtils;
+import org.eclipse.jgit.lib.Repository;
+
+/**
+ * Filter to replace the URL string that contains a branch name to a new branch name. The updated
+ * branch mapping is provided by {@code BranchRedirectFilter#getRedirectBranch} method If it updates
+ * the branch it then returns the new URL with updated branch name as redirect.
+ *
+ * <p>This implementation does not provide a branch redirect mapping. Hence including this filter
+ * as-is would be a no-op. To make this effective {@code BranchRedirectFilter#getRedirectBranch}
+ * needs to be overridden that provides a mapping to the requested repo/branch.
+ */
+public class BranchRedirectFilter extends AbstractHttpFilter {
+  /**
+   * Provides an extendable interface that can be used to provide implementation for determining
+   * redirect branch
+   *
+   * @param repo Repository
+   * @param sourceBranch full branch name eg. refs/heads/master
+   * @return Returns the branch that should be redirected to on a given repo. {@code
+   *     Optional.empty()} means no redirect.
+   */
+  protected Optional<String> getRedirectBranch(Repository repo, String sourceBranch) {
+    return Optional.empty();
+  }
+
+  private Optional<String> rewriteRevision(Repository repo, Revision rev) {
+    if (Revision.isNull(rev)) {
+      return Optional.empty();
+    }
+    return getRedirectBranch(repo, rev.getName());
+  }
+
+  private static Revision rewriteRevision(Revision revision, Optional<String> targetBranch) {
+    if (!targetBranch.isPresent()) {
+      return revision;
+    }
+
+    return new Revision(
+        targetBranch.get(),
+        revision.getId(),
+        revision.getType(),
+        revision.getPeeledId(),
+        revision.getPeeledType());
+  }
+
+  @Override
+  public void doFilter(HttpServletRequest req, HttpServletResponse res, FilterChain chain)
+      throws IOException, ServletException {
+    if (!hasRepository(req) || isForAutomation(req)) {
+      chain.doFilter(req, res);
+      return;
+    }
+
+    GitilesView view = ViewFilter.getView(req);
+    Repository repo = ServletUtils.getRepository(req);
+
+    Optional<String> rewrittenRevision = rewriteRevision(repo, view.getRevision());
+    Optional<String> rewrittenOldRevision = rewriteRevision(repo, view.getOldRevision());
+
+    if (!rewrittenRevision.isPresent() && !rewrittenOldRevision.isPresent()) {
+      chain.doFilter(req, res);
+      return;
+    }
+
+    Revision rev = rewriteRevision(view.getRevision(), rewrittenRevision);
+    Revision oldRev = rewriteRevision(view.getOldRevision(), rewrittenOldRevision);
+    if (rev.equals(view.getRevision()) && oldRev.equals(view.getOldRevision())) {
+      chain.doFilter(req, res);
+      return;
+    }
+
+    String url = view.toBuilder().setRevision(rev).setOldRevision(oldRev).toUrl();
+    res.setStatus(SC_MOVED_PERMANENTLY);
+    res.setHeader(LOCATION, url);
+  }
+
+  private static boolean hasRepository(HttpServletRequest req) {
+    return req.getAttribute(ATTRIBUTE_REPOSITORY) != null;
+  }
+
+  private static boolean isForAutomation(HttpServletRequest req) {
+    FormatType formatType = FormatType.getFormatType(req).orElse(HTML);
+    switch (formatType) {
+      case HTML:
+      case DEFAULT:
+        return false;
+      case JSON:
+      case TEXT:
+      default:
+        return true;
+    }
+  }
+}
diff --git a/java/com/google/gitiles/CommitData.java b/java/com/google/gitiles/CommitData.java
index 705240e..23f03e8 100644
--- a/java/com/google/gitiles/CommitData.java
+++ b/java/com/google/gitiles/CommitData.java
@@ -41,6 +41,7 @@
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.notes.NoteMap;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.treewalk.AbstractTreeIterator;
@@ -60,6 +61,7 @@
     DIFF_TREE,
     LOG_URL,
     MESSAGE,
+    NOTES,
     PARENTS,
     PARENT_BLAME_URL,
     SHA,
@@ -83,6 +85,7 @@
   static class Builder {
     private ArchiveFormat archiveFormat;
     private Map<AnyObjectId, Set<Ref>> refsById;
+    private static final int MAX_NOTE_SIZE = 524288;
 
     Builder setArchiveFormat(@Nullable ArchiveFormat archiveFormat) {
       this.archiveFormat = archiveFormat;
@@ -145,6 +148,19 @@
       if (fs.contains(Field.TAGS)) {
         result.tags = getRefsById(repo, c, Constants.R_TAGS);
       }
+      if (fs.contains(Field.NOTES)) {
+        Ref notesRef = repo.getRefDatabase().exactRef(Constants.R_NOTES_COMMITS);
+        if (notesRef != null) {
+          try {
+            byte[] data =
+                NoteMap.read(walk.getObjectReader(), walk.parseCommit(notesRef.getObjectId()))
+                    .getCachedBytes(c, MAX_NOTE_SIZE);
+            result.notes = new String(data, "utf-8");
+          } catch (Exception e) {
+            result.notes = "";
+          }
+        }
+      }
       if (fs.contains(Field.MESSAGE)) {
         walk.parseBody(c);
         result.message = c.getFullMessage();
@@ -167,7 +183,6 @@
       if (fs.contains(Field.DIFF_TREE)) {
         result.diffEntries = computeDiffEntries(repo, view, walk, c);
       }
-
       return result;
     }
 
@@ -251,6 +266,7 @@
   List<RevCommit> parents;
   String shortMessage;
   String message;
+  String notes;
 
   List<Ref> branches;
   List<Ref> tags;
diff --git a/java/com/google/gitiles/CommitJsonData.java b/java/com/google/gitiles/CommitJsonData.java
index 953c19c..5f94a70 100644
--- a/java/com/google/gitiles/CommitJsonData.java
+++ b/java/com/google/gitiles/CommitJsonData.java
@@ -32,7 +32,13 @@
 public class CommitJsonData {
   static final ImmutableSet<Field> DEFAULT_FIELDS =
       Sets.immutableEnumSet(
-          Field.SHA, Field.TREE, Field.PARENTS, Field.AUTHOR, Field.COMMITTER, Field.MESSAGE);
+          Field.SHA,
+          Field.TREE,
+          Field.PARENTS,
+          Field.AUTHOR,
+          Field.COMMITTER,
+          Field.MESSAGE,
+          Field.NOTES);
 
   public static class Log {
     public List<Commit> log;
@@ -53,6 +59,7 @@
     Ident author;
     Ident committer;
     String message;
+    String notes;
 
     List<Diff> treeDiff;
   }
@@ -101,6 +108,9 @@
     if (cd.message != null) {
       result.message = cd.message;
     }
+    if (cd.notes != null && !cd.notes.isEmpty()){
+      result.notes = cd.notes;
+    }
     if (cd.diffEntries != null) {
       result.treeDiff = toJsonData(cd.diffEntries);
     }
diff --git a/java/com/google/gitiles/CommitSoyData.java b/java/com/google/gitiles/CommitSoyData.java
index c4087a6..ac81f80 100644
--- a/java/com/google/gitiles/CommitSoyData.java
+++ b/java/com/google/gitiles/CommitSoyData.java
@@ -53,6 +53,7 @@
           Field.TREE_URL,
           Field.PARENTS,
           Field.MESSAGE,
+          Field.NOTES, // Optional Field
           Field.LOG_URL,
           Field.ARCHIVE_URL,
           Field.ARCHIVE_TYPE);
@@ -137,11 +138,20 @@
     if (cd.diffEntries != null) {
       data.put("diffTree", toSoyData(view, cd.diffEntries));
     }
-    checkState(
-        Sets.difference(fs, NESTED_FIELDS).size() == data.size(),
-        "bad commit data fields: %s != %s",
-        fs,
-        data.keySet());
+
+    int diffSetSize = Sets.difference(fs, NESTED_FIELDS).size();
+
+    if (fs.contains(
+        Field.NOTES)) { // Since NOTES is optional this is required for checkState to pass
+      diffSetSize -= 1;
+    }
+
+    checkState(diffSetSize == data.size(), "bad commit data fields: %s != %s", fs, data.keySet());
+
+    if (cd.notes != null && !cd.notes.isEmpty()) {
+      data.put("notes", cd.notes);
+    }
+
     return data;
   }
 
diff --git a/java/com/google/gitiles/DebugRenderer.java b/java/com/google/gitiles/DebugRenderer.java
index 5e35e57..5e4c466 100644
--- a/java/com/google/gitiles/DebugRenderer.java
+++ b/java/com/google/gitiles/DebugRenderer.java
@@ -48,14 +48,14 @@
 
   @Override
   protected SoySauce getSauce() {
-    SoyFileSet.Builder builder = SoyFileSet.builder().setCompileTimeGlobals(globals);
+    SoyFileSet.Builder builder = SoyFileSet.builder();
     for (URL template : templates.values()) {
       try {
         checkState(new File(template.toURI()).exists(), "Missing Soy template %s", template);
       } catch (URISyntaxException e) {
         throw new IllegalStateException(e);
       }
-      builder.add(template);
+      builder.add(template, toSoySrcPath(template));
     }
     return builder.build().compileTemplates();
   }
diff --git a/java/com/google/gitiles/DefaultRenderer.java b/java/com/google/gitiles/DefaultRenderer.java
index 0307862..48d8554 100644
--- a/java/com/google/gitiles/DefaultRenderer.java
+++ b/java/com/google/gitiles/DefaultRenderer.java
@@ -45,9 +45,9 @@
         staticPrefix,
         customTemplates,
         siteTitle);
-    SoyFileSet.Builder builder = SoyFileSet.builder().setCompileTimeGlobals(this.globals);
+    SoyFileSet.Builder builder = SoyFileSet.builder();
     for (URL template : templates.values()) {
-      builder.add(template);
+      builder.add(template, toSoySrcPath(template));
     }
     sauce = builder.build().compileTemplates();
   }
diff --git a/java/com/google/gitiles/GitilesFilter.java b/java/com/google/gitiles/GitilesFilter.java
index 3013096..b347e08 100644
--- a/java/com/google/gitiles/GitilesFilter.java
+++ b/java/com/google/gitiles/GitilesFilter.java
@@ -1,4 +1,4 @@
-// Copyright 2012 Google Inc. All Rights Reserved.
+// Copyright 2021 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.
@@ -176,6 +176,7 @@
   private BlameCache blameCache;
   private GitwebRedirectFilter gitwebRedirect;
   private Filter errorHandler;
+  private BranchRedirectFilter branchRedirect;
   private boolean initialized;
 
   GitilesFilter() {}
@@ -190,6 +191,7 @@
       @Nullable TimeCache timeCache,
       @Nullable BlameCache blameCache,
       @Nullable GitwebRedirectFilter gitwebRedirect,
+      BranchRedirectFilter branchRedirect,
       @Nullable Filter errorHandler) {
     this.config = checkNotNull(config, "config");
     this.renderer = renderer;
@@ -203,6 +205,7 @@
       this.resolver = resolver;
     }
     this.errorHandler = errorHandler;
+    this.branchRedirect = branchRedirect;
   }
 
   @Override
@@ -224,13 +227,21 @@
     if (gitwebRedirect != null) {
       root.through(gitwebRedirect);
     }
+    if (branchRedirect != null) {
+      root.through(branchRedirect);
+    }
     root.through(dispatchFilter);
 
-    serveRegex(REPO_REGEX).through(repositoryFilter).through(viewFilter).through(dispatchFilter);
+    serveRegex(REPO_REGEX)
+        .through(repositoryFilter)
+        .through(viewFilter)
+        .through(branchRedirect)
+        .through(dispatchFilter);
 
     serveRegex(REPO_PATH_REGEX)
         .through(repositoryFilter)
         .through(viewFilter)
+        .through(branchRedirect)
         .through(dispatchFilter);
 
     initialized = true;
diff --git a/java/com/google/gitiles/GitilesServlet.java b/java/com/google/gitiles/GitilesServlet.java
index 6d10c0c..4467cfa 100644
--- a/java/com/google/gitiles/GitilesServlet.java
+++ b/java/com/google/gitiles/GitilesServlet.java
@@ -51,7 +51,8 @@
       @Nullable VisibilityCache visibilityCache,
       @Nullable TimeCache timeCache,
       @Nullable BlameCache blameCache,
-      @Nullable GitwebRedirectFilter gitwebRedirect) {
+      @Nullable GitwebRedirectFilter gitwebRedirect,
+      BranchRedirectFilter branchRedirect) {
     this(
         config,
         renderer,
@@ -62,6 +63,7 @@
         timeCache,
         blameCache,
         gitwebRedirect,
+        branchRedirect,
         null);
   }
 
@@ -75,6 +77,7 @@
       @Nullable TimeCache timeCache,
       @Nullable BlameCache blameCache,
       @Nullable GitwebRedirectFilter gitwebRedirect,
+      BranchRedirectFilter branchRedirect,
       @Nullable Filter errorHandler) {
     super(
         new GitilesFilter(
@@ -87,6 +90,7 @@
             timeCache,
             blameCache,
             gitwebRedirect,
+            branchRedirect,
             errorHandler));
   }
 
diff --git a/java/com/google/gitiles/PathServlet.java b/java/com/google/gitiles/PathServlet.java
index df24062..26de757 100644
--- a/java/com/google/gitiles/PathServlet.java
+++ b/java/com/google/gitiles/PathServlet.java
@@ -578,7 +578,7 @@
     }
     int lastSlash = path.lastIndexOf('/');
     if (lastSlash > 0) {
-      return path.substring(0, lastSlash - 1);
+      return path.substring(0, lastSlash);
     } else if (lastSlash == 0) {
       return "/";
     } else {
diff --git a/java/com/google/gitiles/Renderer.java b/java/com/google/gitiles/Renderer.java
index dbd5590..957e50d 100644
--- a/java/com/google/gitiles/Renderer.java
+++ b/java/com/google/gitiles/Renderer.java
@@ -95,6 +95,7 @@
 
   protected ImmutableMap<String, URL> templates;
   protected ImmutableMap<String, String> globals;
+  protected final String siteTitle;
   private final ConcurrentMap<String, HashCode> hashes =
       new ConcurrentHashMap<>(SOY_FILENAMES.size());
 
@@ -119,9 +120,9 @@
     for (Map.Entry<String, String> e : STATIC_URL_GLOBALS.entrySet()) {
       allGlobals.put(e.getKey(), staticPrefix + e.getValue());
     }
-    allGlobals.put("gitiles.SITE_TITLE", siteTitle);
     allGlobals.putAll(globals);
     this.globals = ImmutableMap.copyOf(allGlobals);
+    this.siteTitle = siteTitle;
   }
 
   public HashCode getTemplateHash(String soyFile) {
@@ -221,8 +222,18 @@
     }
     return getSauce()
         .renderTemplate(templateName)
-        .setIj(ImmutableMap.of("staticUrls", staticUrls.build()));
+        .setIj(ImmutableMap.of("staticUrls", staticUrls.build(), "SITE_TITLE", siteTitle));
   }
 
   protected abstract SoySauce getSauce();
+
+  /**
+   * Give a resource URL of a soy template file, returns the import path for use in a Soy import
+   * statement.
+   */
+  protected String toSoySrcPath(URL templateUrl) {
+    String filePath = templateUrl.getPath();
+    String fileName = filePath.substring(filePath.lastIndexOf('/') + 1);
+    return "com/google/gitiles/templates/" + fileName;
+  }
 }
diff --git a/java/com/google/gitiles/dev/BUILD b/java/com/google/gitiles/dev/BUILD
index 47db87d..9550cf0 100644
--- a/java/com/google/gitiles/dev/BUILD
+++ b/java/com/google/gitiles/dev/BUILD
@@ -9,7 +9,7 @@
         "//lib:guava",
         "//lib:guava-failureaccess",
         "//lib:html-types",
-        "//lib:servlet-api_3_0",
+        "//lib:servlet-api_3_1",
         "//lib/jetty:server",
         "//lib/jetty:servlet",
         "//lib/jgit",
diff --git a/java/com/google/gitiles/dev/DevServer.java b/java/com/google/gitiles/dev/DevServer.java
index 29414c4..5a8fed5 100644
--- a/java/com/google/gitiles/dev/DevServer.java
+++ b/java/com/google/gitiles/dev/DevServer.java
@@ -19,6 +19,7 @@
 
 import com.google.common.base.Strings;
 import com.google.common.html.types.UncheckedConversions;
+import com.google.gitiles.BranchRedirectFilter;
 import com.google.gitiles.DebugRenderer;
 import com.google.gitiles.GitilesAccess;
 import com.google.gitiles.GitilesServlet;
@@ -137,7 +138,9 @@
     if (!Strings.isNullOrEmpty(docRoot)) {
       servlet = createRootedDocServlet(renderer, docRoot);
     } else {
-      servlet = new GitilesServlet(cfg, renderer, null, null, null, null, null, null, null);
+      servlet =
+          new GitilesServlet(
+              cfg, renderer, null, null, null, null, null, null, null, new BranchRedirectFilter());
     }
 
     ServletContextHandler handler = new ServletContextHandler();
diff --git a/java/com/google/gitiles/doc/ImageLoader.java b/java/com/google/gitiles/doc/ImageLoader.java
index 3e536d7..81724b6 100644
--- a/java/com/google/gitiles/doc/ImageLoader.java
+++ b/java/com/google/gitiles/doc/ImageLoader.java
@@ -34,7 +34,7 @@
 class ImageLoader {
   private static final Logger log = LoggerFactory.getLogger(ImageLoader.class);
   private static final ImmutableSet<String> ALLOWED_TYPES =
-      ImmutableSet.of("image/gif", "image/jpeg", "image/png");
+      ImmutableSet.of("image/gif", "image/jpeg", "image/png", "image/webp");
 
   private final ObjectReader reader;
   private final GitilesView view;
diff --git a/javatests/com/google/gitiles/BUILD b/javatests/com/google/gitiles/BUILD
index c67e565..4021114 100644
--- a/javatests/com/google/gitiles/BUILD
+++ b/javatests/com/google/gitiles/BUILD
@@ -34,6 +34,7 @@
         ],
         exclude = ["**/ServletTest.java"],
     ),
+    size = "small",
     visibility = ["//visibility:public"],
     runtime_deps = ["//lib/junit:hamcrest-core"],
     deps = DEPS + [
diff --git a/javatests/com/google/gitiles/BranchRedirectFilterTest.java b/javatests/com/google/gitiles/BranchRedirectFilterTest.java
new file mode 100644
index 0000000..76c6198
--- /dev/null
+++ b/javatests/com/google/gitiles/BranchRedirectFilterTest.java
@@ -0,0 +1,284 @@
+// Copyright 2021 Google LLC. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gitiles;
+
+import static com.google.common.truth.Truth.assertThat;
+import static javax.servlet.http.HttpServletResponse.SC_MOVED_PERMANENTLY;
+import static javax.servlet.http.HttpServletResponse.SC_MOVED_TEMPORARILY;
+import static javax.servlet.http.HttpServletResponse.SC_OK;
+
+import com.google.common.base.Strings;
+import com.google.common.net.HttpHeaders;
+import java.util.Optional;
+import javax.annotation.Nullable;
+import org.eclipse.jgit.internal.storage.dfs.DfsRepository;
+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.Constants;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.Before;
+import org.junit.Ignore;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/** Tests for BranchRedirect. */
+@RunWith(JUnit4.class)
+public class BranchRedirectFilterTest {
+  private static final String MASTER = "refs/heads/master";
+  private static final String MAIN = "refs/heads/main";
+  private static final String DEVELOP = "refs/heads/develop";
+  private static final String FOO = "refs/heads/foo";
+  private static final String BAR = "refs/heads/bar";
+  private static final String ORIGIN = "http://localhost";
+  private static final String QUERY_STRING_HTML = "format=html";
+  private static final String QUERY_STRING_JSON = "format=json";
+
+  private TestRepository<DfsRepository> repo;
+  private GitilesServlet servlet;
+
+  @Before
+  public void setUp() throws Exception {
+    repo = new TestRepository<>(new InMemoryRepository(new DfsRepositoryDescription("repo")));
+    BranchRedirectFilter branchRedirectFilter =
+        new BranchRedirectFilter() {
+          @Override
+          protected Optional<String> getRedirectBranch(Repository repo, String sourceBranch) {
+            if (MASTER.equals(toFullBranchName(sourceBranch))) {
+              return Optional.of(MAIN);
+            }
+            if (FOO.equals(toFullBranchName(sourceBranch))) {
+              return Optional.of(BAR);
+            }
+            return Optional.empty();
+          }
+        };
+    servlet = TestGitilesServlet.create(repo, new GitwebRedirectFilter(), branchRedirectFilter);
+  }
+
+  @Test
+  public void show_withoutRedirect() throws Exception {
+    repo.branch("develop").commit().add("foo", "contents").create();
+
+    String path = "/repo/+/refs/heads/develop/foo";
+    FakeHttpServletRequest req = newHttpRequest(path, ORIGIN, QUERY_STRING_HTML);
+    FakeHttpServletResponse res = new FakeHttpServletResponse();
+
+    servlet.service(req, res);
+    assertThat(res.getStatus()).isEqualTo(SC_OK);
+  }
+
+  @Test
+  public void show_withRedirect() throws Exception {
+    repo.branch(MASTER).commit().add("foo", "contents").create();
+
+    String path = "/repo/+/refs/heads/master/foo";
+    FakeHttpServletRequest req = newHttpRequest(path, ORIGIN, QUERY_STRING_HTML);
+    FakeHttpServletResponse res = new FakeHttpServletResponse();
+
+    servlet.service(req, res);
+    assertThat(res.getStatus()).isEqualTo(SC_MOVED_PERMANENTLY);
+    assertThat(res.getHeader(HttpHeaders.LOCATION))
+        .isEqualTo("/b/repo/+/refs/heads/main/foo?format=html");
+  }
+
+  @Test
+  public void show_withRedirect_onDefaultFormatType() throws Exception {
+    repo.branch(MASTER).commit().add("foo", "contents").create();
+
+    String path = "/repo/+/refs/heads/master/foo";
+    FakeHttpServletRequest req = newHttpRequest(path, ORIGIN, null);
+    FakeHttpServletResponse res = new FakeHttpServletResponse();
+
+    servlet.service(req, res);
+    assertThat(res.getStatus()).isEqualTo(SC_MOVED_PERMANENTLY);
+    assertThat(res.getHeader(HttpHeaders.LOCATION)).isEqualTo("/b/repo/+/refs/heads/main/foo");
+  }
+
+  @Test
+  public void show_withRedirect_usingShortRefInUrl() throws Exception {
+    repo.branch(MASTER).commit().add("foo", "contents").create();
+
+    String path = "/repo/+/master/foo";
+    FakeHttpServletRequest req = newHttpRequest(path, ORIGIN, QUERY_STRING_HTML);
+    FakeHttpServletResponse res = new FakeHttpServletResponse();
+
+    servlet.service(req, res);
+    assertThat(res.getStatus()).isEqualTo(SC_MOVED_PERMANENTLY);
+    assertThat(res.getHeader(HttpHeaders.LOCATION))
+        .isEqualTo("/b/repo/+/refs/heads/main/foo?format=html");
+  }
+
+  @Test
+  public void show_onAutomationRequest() throws Exception {
+    repo.branch(MASTER).commit().add("foo", "contents").create();
+
+    String path = "/repo/+/refs/heads/master/foo";
+    FakeHttpServletRequest req = newHttpRequest(path, ORIGIN, QUERY_STRING_JSON);
+    FakeHttpServletResponse res = new FakeHttpServletResponse();
+
+    servlet.service(req, res);
+    assertThat(res.getStatus()).isEqualTo(SC_OK);
+  }
+
+  @Test
+  public void showParent_withRedirect() throws Exception {
+    RevCommit parent = repo.branch(MASTER).commit().add("foo", "contents").create();
+    repo.branch(MASTER).commit().add("bar", "contents").parent(parent).create();
+
+    String path = "/repo/+/refs/heads/master^";
+    FakeHttpServletRequest req = newHttpRequest(path, ORIGIN, QUERY_STRING_HTML);
+    FakeHttpServletResponse res = new FakeHttpServletResponse();
+
+    servlet.service(req, res);
+    // It is resolved to the object id by ViewFilter.
+    assertThat(res.getStatus()).isEqualTo(SC_MOVED_TEMPORARILY);
+    assertThat(res.getHeader(HttpHeaders.LOCATION))
+        .isEqualTo("/b/repo/+/" + parent.toObjectId().name() + "?format=html");
+  }
+
+  @Test
+  public void diff_withRedirect_onSingleBranch() throws Exception {
+    repo.branch(MASTER).commit().add("foo", "contents").create();
+    repo.branch(DEVELOP).commit().add("foo", "contents").create();
+
+    String path = "/repo/+/refs/heads/master..refs/heads/develop";
+    FakeHttpServletRequest req = newHttpRequest(path, ORIGIN, QUERY_STRING_HTML);
+    FakeHttpServletResponse res = new FakeHttpServletResponse();
+
+    servlet.service(req, res);
+    assertThat(res.getStatus()).isEqualTo(SC_MOVED_PERMANENTLY);
+    assertThat(res.getHeader(HttpHeaders.LOCATION))
+        .isEqualTo("/b/repo/+/refs/heads/main..refs/heads/develop/?format=html");
+  }
+
+  @Test
+  // @Ignore
+  public void diff_withRedirect_onBothBranch() throws Exception {
+    repo.branch(MASTER).commit().add("foo", "contents").create();
+    repo.branch(FOO).commit().add("foo", "contents").create();
+
+    String path = "/repo/+/refs/heads/foo..refs/heads/master";
+    FakeHttpServletRequest req = newHttpRequest(path, ORIGIN, QUERY_STRING_HTML);
+    FakeHttpServletResponse res = new FakeHttpServletResponse();
+
+    servlet.service(req, res);
+    assertThat(res.getStatus()).isEqualTo(SC_MOVED_PERMANENTLY);
+    assertThat(res.getHeader(HttpHeaders.LOCATION))
+        .isEqualTo("/b/repo/+/refs/heads/bar..refs/heads/main/?format=html");
+  }
+
+  @Test
+  public void diff_withRedirect() throws Exception {
+    repo.branch(MASTER).commit().add("foo", "contents").create();
+
+    String path = "/repo/+diff/refs/heads/master^!";
+    FakeHttpServletRequest req = newHttpRequest(path, ORIGIN, QUERY_STRING_HTML);
+    FakeHttpServletResponse res = new FakeHttpServletResponse();
+
+    servlet.service(req, res);
+    assertThat(res.getStatus()).isEqualTo(SC_MOVED_PERMANENTLY);
+    assertThat(res.getHeader(HttpHeaders.LOCATION))
+        .isEqualTo("/b/repo/+/refs/heads/main%5E%21/?format=html");
+  }
+
+  @Test
+  public void log_withRedirect() throws Exception {
+    repo.branch(MASTER).commit().add("foo", "contents").create();
+
+    String path = "/repo/+log/refs/heads/master";
+    FakeHttpServletRequest req = newHttpRequest(path, ORIGIN, QUERY_STRING_HTML);
+    FakeHttpServletResponse res = new FakeHttpServletResponse();
+
+    servlet.service(req, res);
+    assertThat(res.getStatus()).isEqualTo(SC_MOVED_PERMANENTLY);
+    assertThat(res.getHeader(HttpHeaders.LOCATION))
+        .isEqualTo("/b/repo/+log/refs/heads/main/?format=html");
+  }
+
+  @Test
+  @Ignore
+  public void diff_withGrandParent_redirect() throws Exception {
+    RevCommit parent1 = repo.branch(MASTER).commit().add("foo", "contents").create();
+    RevCommit parent2 =
+        repo.branch(MASTER).commit().add("bar", "contents").parent(parent1).create();
+    repo.branch(MASTER).commit().add("bar", "contents").parent(parent2).create();
+
+    String path = "/repo/+diff/refs/heads/master^^..refs/heads/master";
+    FakeHttpServletRequest req = newHttpRequest(path, ORIGIN, QUERY_STRING_HTML);
+    FakeHttpServletResponse res = new FakeHttpServletResponse();
+
+    servlet.service(req, res);
+    assertThat(res.getStatus()).isEqualTo(SC_MOVED_PERMANENTLY);
+    assertThat(res.getHeader(HttpHeaders.LOCATION))
+        .isEqualTo("/b/repo/+/refs/heads/main%5E%5E..refs/heads/main/?format=html");
+  }
+
+  @Test
+  @Ignore
+  public void diff_withRelativeParent_redirect() throws Exception {
+    RevCommit parent1 = repo.branch(MASTER).commit().add("foo", "contents").create();
+    RevCommit parent2 =
+        repo.branch(MASTER).commit().add("bar", "contents").parent(parent1).create();
+    repo.branch(MASTER).commit().add("bar", "contents").parent(parent2).create();
+
+    String path = "/repo/+diff/refs/heads/master~1..refs/heads/master";
+    FakeHttpServletRequest req = newHttpRequest(path, ORIGIN, QUERY_STRING_HTML);
+    FakeHttpServletResponse res = new FakeHttpServletResponse();
+
+    servlet.service(req, res);
+    assertThat(res.getStatus()).isEqualTo(SC_MOVED_PERMANENTLY);
+    assertThat(res.getHeader(HttpHeaders.LOCATION))
+        .isEqualTo("/b/repo/+/refs/heads/main%5E%21/?format=html");
+  }
+
+  @Test
+  @Ignore
+  public void diff_withRelativeGrandParent_redirect() throws Exception {
+    RevCommit parent1 = repo.branch(MASTER).commit().add("foo", "contents").create();
+    RevCommit parent2 =
+        repo.branch(MASTER).commit().add("bar", "contents").parent(parent1).create();
+    repo.branch(MASTER).commit().add("bar", "contents").parent(parent2).create();
+
+    String path = "/repo/+diff/refs/heads/master~2..refs/heads/master";
+    FakeHttpServletRequest req = newHttpRequest(path, ORIGIN, QUERY_STRING_HTML);
+    FakeHttpServletResponse res = new FakeHttpServletResponse();
+
+    servlet.service(req, res);
+    assertThat(res.getStatus()).isEqualTo(SC_MOVED_PERMANENTLY);
+    assertThat(res.getHeader(HttpHeaders.LOCATION))
+        .isEqualTo("/b/repo/+/refs/heads/main%7E2..refs/heads/main/?format=html");
+  }
+
+  private static String toFullBranchName(String sourceBranch) {
+    if (sourceBranch.startsWith(Constants.R_REFS)) {
+      return sourceBranch;
+    }
+    return Constants.R_HEADS + sourceBranch;
+  }
+
+  private static FakeHttpServletRequest newHttpRequest(
+      String path, String origin, @Nullable String queryString) {
+    FakeHttpServletRequest req = FakeHttpServletRequest.newRequest();
+    req.setHeader(HttpHeaders.ORIGIN, origin);
+    req.setPathInfo(path);
+    if (!Strings.isNullOrEmpty(queryString)) {
+      req.setQueryString(queryString);
+    }
+    return req;
+  }
+}
diff --git a/javatests/com/google/gitiles/ConfigUtilTest.java b/javatests/com/google/gitiles/ConfigUtilTest.java
index d2e3ac7..0f5d6e9 100644
--- a/javatests/com/google/gitiles/ConfigUtilTest.java
+++ b/javatests/com/google/gitiles/ConfigUtilTest.java
@@ -35,7 +35,7 @@
 
     config.setString("core", "dht", "timeout", "500 ms");
     t = getDuration(config, "core", "dht", "timeout", def);
-    assertThat(t.toMillis()).isEqualTo(500);
+    assertThat(t).isEqualTo(Duration.ofMillis(500));
 
     config.setString("core", "dht", "timeout", "5.2 sec");
     try {
@@ -47,7 +47,7 @@
 
     config.setString("core", "dht", "timeout", "1 min");
     t = getDuration(config, "core", "dht", "timeout", def);
-    assertThat(t.toMillis()).isEqualTo(60000);
+    assertThat(t).isEqualTo(Duration.ofMinutes(1));
   }
 
   @Test
@@ -57,15 +57,15 @@
     Duration t;
 
     t = getDuration(config, "core", null, "blank", def);
-    assertThat(t.toMillis()).isEqualTo(1000);
+    assertThat(t).isEqualTo(Duration.ofSeconds(1));
 
     config.setString("core", null, "blank", "");
     t = getDuration(config, "core", null, "blank", def);
-    assertThat(t.toMillis()).isEqualTo(1000);
+    assertThat(t).isEqualTo(Duration.ofSeconds(1));
 
     config.setString("core", null, "blank", " ");
     t = getDuration(config, "core", null, "blank", def);
-    assertThat(t.toMillis()).isEqualTo(1000);
+    assertThat(t).isEqualTo(Duration.ofSeconds(1));
   }
 
   @Test
diff --git a/javatests/com/google/gitiles/PathServletTest.java b/javatests/com/google/gitiles/PathServletTest.java
index bebd98c..68b1e3e 100644
--- a/javatests/com/google/gitiles/PathServletTest.java
+++ b/javatests/com/google/gitiles/PathServletTest.java
@@ -149,23 +149,12 @@
 
   @Test
   public void symlinkHtml() throws Exception {
-    final RevBlob link = repo.blob("foo");
-    repo.branch("master")
-        .commit()
-        .add("foo", "contents")
-        .edit(
-            new PathEdit("bar") {
-              @Override
-              public void apply(DirCacheEntry ent) {
-                ent.setFileMode(FileMode.SYMLINK);
-                ent.setObjectId(link);
-              }
-            })
-        .create();
+    testSymlink("foo", "bar", "foo");
+  }
 
-    Map<String, ?> data = buildData("/repo/+/master/bar");
-    assertThat(data).containsEntry("type", "SYMLINK");
-    assertThat(getBlobData(data)).containsEntry("target", "foo");
+  @Test
+  public void relativeSymlinkHtml() throws Exception {
+    testSymlink("foo/bar", "foo/baz", "./bar");
   }
 
   @Test
@@ -410,6 +399,28 @@
     assertThat(res.getHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN)).isEqualTo(null);
   }
 
+  private void testSymlink(String linkTarget, String linkName, String linkContent)
+      throws Exception {
+    final RevBlob linkBlob = repo.blob(linkContent);
+    repo.branch("master")
+        .commit()
+        .add(linkTarget, "contents")
+        .edit(
+            new PathEdit(linkName) {
+              @Override
+              public void apply(DirCacheEntry ent) {
+                ent.setFileMode(FileMode.SYMLINK);
+                ent.setObjectId(linkBlob);
+              }
+            })
+        .create();
+
+    Map<String, ?> data = buildData("/repo/+/master/" + linkName);
+    assertThat(data).containsEntry("type", "SYMLINK");
+    assertThat(getBlobData(data)).containsEntry("target", linkContent);
+    assertThat(getBlobData(data)).containsEntry("targetUrl", "/b/repo/+/master/" + linkTarget);
+  }
+
   private Map<String, ?> getBlobData(Map<String, ?> data) {
     return ((Map<String, Map<String, ?>>) data).get("data");
   }
diff --git a/javatests/com/google/gitiles/TestGitilesServlet.java b/javatests/com/google/gitiles/TestGitilesServlet.java
index 0a7beb1..a35e76f 100644
--- a/javatests/com/google/gitiles/TestGitilesServlet.java
+++ b/javatests/com/google/gitiles/TestGitilesServlet.java
@@ -31,10 +31,17 @@
 
 /** Static utility methods for creating {@link GitilesServlet}s for testing. */
 public class TestGitilesServlet {
-  /** @see #create(TestRepository) */
+  /** @see #create(TestRepository,GitwebRedirectFilter,BranchRedirectFilter) */
   public static GitilesServlet create(final TestRepository<DfsRepository> repo)
       throws ServletException {
-    return create(repo, new GitwebRedirectFilter());
+    return create(repo, new GitwebRedirectFilter(), new BranchRedirectFilter());
+  }
+
+  /** @see #create(TestRepository,GitwebRedirectFilter,BranchRedirectFilter) */
+  public static GitilesServlet create(
+      final TestRepository<DfsRepository> repo, GitwebRedirectFilter gitwebRedirect)
+      throws ServletException {
+    return create(repo, gitwebRedirect, new BranchRedirectFilter());
   }
 
   /**
@@ -48,10 +55,13 @@
    *
    * @param repo the test repo backing the servlet.
    * @param gitwebRedirect optional redirect filter for gitweb URLs.
+   * @param branchRedirect branch redirect filter
    * @return a servlet.
    */
   public static GitilesServlet create(
-      final TestRepository<DfsRepository> repo, GitwebRedirectFilter gitwebRedirect)
+      final TestRepository<DfsRepository> repo,
+      GitwebRedirectFilter gitwebRedirect,
+      BranchRedirectFilter branchRedirect)
       throws ServletException {
     final String repoName = repo.getRepository().getDescription().getRepositoryName();
     GitilesServlet servlet =
@@ -74,7 +84,8 @@
             null,
             null,
             null,
-            gitwebRedirect);
+            gitwebRedirect,
+            branchRedirect);
 
     servlet.init(
         new ServletConfig() {
diff --git a/lib/BUILD b/lib/BUILD
index 828ff38..a6f026f 100644
--- a/lib/BUILD
+++ b/lib/BUILD
@@ -18,7 +18,7 @@
     "html-types",
     "jsr305",
     "servlet-api_2_5",
-    "servlet-api_3_0",
+    "servlet-api_3_1",
     "gson",
     "guava",
     "guava-failureaccess",
diff --git a/lib/jetty/BUILD b/lib/jetty/BUILD
index 5517d65..4f58b3d 100644
--- a/lib/jetty/BUILD
+++ b/lib/jetty/BUILD
@@ -8,7 +8,7 @@
     name = "servlet",
     exports = [
         ":security",
-        "//lib:servlet-api_3_0",  # Different from the rest of gitiles-server.
+        "//lib:servlet-api_3_1",  # Different from the rest of gitiles-server.
         "@servlet//jar",
     ],
 )
diff --git a/resources/com/google/gitiles/mime-types.properties b/resources/com/google/gitiles/mime-types.properties
index 0b4bd26..8cc19b4 100644
--- a/resources/com/google/gitiles/mime-types.properties
+++ b/resources/com/google/gitiles/mime-types.properties
@@ -13,4 +13,5 @@
 svg = image/svg+xml
 tiff = image/tiff
 txt = text/plain
+webp = image/webp
 xml = text/xml
diff --git a/resources/com/google/gitiles/static/base.css b/resources/com/google/gitiles/static/base.css
index 37a98c4..a1d5a36 100644
--- a/resources/com/google/gitiles/static/base.css
+++ b/resources/com/google/gitiles/static/base.css
@@ -55,7 +55,6 @@
 }
 .u-pre {
   font-size: 10pt;
-  font-weight: 500;
   white-space: pre;
 }
 .u-lineNum {
@@ -297,7 +296,9 @@
 .RefList-title {
   margin: 0;
 }
-.RefList-items {}
+.RefList-items {
+  word-wrap: break-word;
+}
 .RefList-item {
   padding: 2px 0;
 }
diff --git a/resources/com/google/gitiles/templates/BlameDetail.soy b/resources/com/google/gitiles/templates/BlameDetail.soy
index fa68ac7..1589056 100644
--- a/resources/com/google/gitiles/templates/BlameDetail.soy
+++ b/resources/com/google/gitiles/templates/BlameDetail.soy
@@ -13,10 +13,13 @@
 // limitations under the License.
 {namespace gitiles}
 
+import * as common from 'com/google/gitiles/templates/Common.soy';
+import * as objDetail from 'com/google/gitiles/templates/ObjectDetail.soy';
+
 /**
  * Detail page showing blame info for a file.
  */
-{template .blameDetail stricthtml="false"}
+{template blameDetail stricthtml="false"}
   {@param title: ?}  /** human-readable revision name. */
   {@param repositoryName: ?}  /** name of this repository. */
   {@param? menuEntries: ?}  /** menu entries. */
@@ -36,19 +39,18 @@
       */
   {@inject staticUrls: ?}
 {if $regions}
-  {call .header data="all"}
+  {call common.header data="all"}
     {param css: [$staticUrls.PRETTIFY_CSS_URL] /}
     {param containerClass: 'Container--fullWidth' /}
   {/call}
 
-  {call .blobHeader data="$data" /}
+  {call objDetail.blobHeader data="$data" /}
 
   <table class="Blame">
-    {for $line in $data.lines}
-      {let $i: index($line) /}
+    {for $line, $i in $data.lines}
       {let $region: $regions[$i] /}
       <tr class="Blame-region {$region.class}">
-        {if isNonnull($region.abbrevSha)}
+        {if $region.abbrevSha != null}
           <td class="Blame-author">{$region.author.name}</td>
           <td class="Blame-sha1"><a class="u-sha1 u-monospace Blame-sha1" href="{$region.commitUrl}">{$region.abbrevSha}</a></td>
           <td class="Blame-time">{$region.author.time}</td>
@@ -72,8 +74,8 @@
     {/for}
   </table>
 {else}
-  {call .header data="all" /}
-  {call .blobDetail data="$data" /}
+  {call common.header data="all" /}
+  {call objDetail.blobDetail data="$data" /}
   <div class="FileContents-binary">
     {msg desc="blame not available for binary file"}
       No blame information available
@@ -81,7 +83,7 @@
   </div>
 {/if}
 
-{call .footer}
+{call common.footer}
   {param customVariant: $customVariant /}
 {/call}
 {/template}
diff --git a/resources/com/google/gitiles/templates/Common.soy b/resources/com/google/gitiles/templates/Common.soy
index 82c77ba..3a33299 100644
--- a/resources/com/google/gitiles/templates/Common.soy
+++ b/resources/com/google/gitiles/templates/Common.soy
@@ -16,7 +16,7 @@
 /**
  * Common header for Gitiles.
  */
-{template .header stricthtml="false"}
+{template header stricthtml="false"}
   {@param title: ?}  /** title for this page. Always suffixed with repository name and a sitewide
       title. */
   {@param? repositoryName: ?}  /** repository name for this page, if applicable. */
@@ -27,6 +27,7 @@
   {@param? css: list<?>}  /** optional list of CSS URLs to include. */
   {@param? containerClass: ?}  /** optional class to append to the main container. */
   {@inject staticUrls: ?}
+  {@inject SITE_TITLE: string}
 <!DOCTYPE html>
 <html lang="en">
 <head>
@@ -36,7 +37,9 @@
     {if $repositoryName}
       {sp}- {$repositoryName}
     {/if}
-    {sp}- {msg desc="name of the application"}{gitiles.SITE_TITLE}{/msg}
+    {sp}- {msg desc="name of the application"}
+      {$SITE_TITLE}
+    {/msg}
   </title>
 
   <link rel="stylesheet" type="text/css" href="{$staticUrls.BASE_CSS_URL}">
@@ -73,9 +76,9 @@
     <div class="Container {if $containerClass}{$containerClass}{/if}">
       {if $breadcrumbs and length($breadcrumbs)}
         <div class="Breadcrumbs">
-          {for $entry in $breadcrumbs}
-            {if not isFirst($entry)}{sp}/{sp}{/if}
-            {if not isLast($entry)}
+          {for $entry, $index in $breadcrumbs}
+            {if $index > 0}{sp}/{sp}{/if}
+            {if $index  < length($breadcrumbs) - 1}
               <a class="Breadcrumbs-crumb" href="{$entry.url}">{$entry.text}</a>
             {else}
               <span class="Breadcrumbs-crumb">{$entry.text}</span>
@@ -99,9 +102,12 @@
  * Default custom header implementation for Gitiles.
  */
 {deltemplate gitiles.customHeader}
+  {@inject SITE_TITLE: string}
 <!-- default customHeader -->
 <div class="Header-title">
-  {msg desc="short name of the application"}{gitiles.SITE_TITLE}{/msg}
+  {msg desc="short name of the application"}
+    {$SITE_TITLE}
+  {/msg}
 </div>
 {/deltemplate}
 
@@ -148,7 +154,7 @@
  * The footer tag part can be customized by creating a customFooter
  * variant template.
  */
-{template .footer stricthtml="false"}
+{template footer stricthtml="false"}
   {@param? customVariant: ?}  /** variant name for custom styling. */
     </div> <!-- Container -->
   </div> <!-- Site-content -->
@@ -163,6 +169,6 @@
  * Insert this in a template to use with
  * Renderer#renderStreaming(HttpServletResponse, String).
  */
-{template .streamingPlaceholder}
+{template streamingPlaceholder}
 <br id="STREAMED_OUTPUT_BLOCK">
 {/template}
diff --git a/resources/com/google/gitiles/templates/DiffDetail.soy b/resources/com/google/gitiles/templates/DiffDetail.soy
index 38e483b..519ed77 100644
--- a/resources/com/google/gitiles/templates/DiffDetail.soy
+++ b/resources/com/google/gitiles/templates/DiffDetail.soy
@@ -13,10 +13,13 @@
 // limitations under the License.
 {namespace gitiles}
 
+import * as common from 'com/google/gitiles/templates/Common.soy';
+import * as objDetail from 'com/google/gitiles/templates/ObjectDetail.soy';
+
 /**
  * Detail page showing diffs for a single commit.
  */
-{template .diffDetail stricthtml="false"}
+{template diffDetail stricthtml="false"}
   {@param title: ?}  /** human-readable revision name. */
   {@param repositoryName: ?}  /** name of this repository. */
   {@param? menuEntries: ?}  /** menu entries. */
@@ -24,14 +27,14 @@
   {@param breadcrumbs: ?}  /** breadcrumbs for this page. */
   {@param? commit: ?}  /** optional commit for which diffs are displayed, with keys corresponding to
       the gitiles.commitDetail template (minus "diffTree"). */
-{call .header data="all" /}
+  {call common.header data="all" /}
 
 {if $commit}
-  {call .commitDetail data="$commit" /}
+  {call objDetail.commitDetail data="$commit" /}
 {/if}
-{call .streamingPlaceholder /}
+  {call common.streamingPlaceholder /}
 
-{call .footer}
+{call common.footer}
   {param customVariant: $customVariant /}
 {/call}
 {/template}
@@ -39,15 +42,15 @@
 /**
  * File header for a single unified diff patch.
  */
-{template .diffHeader}
+{template diffHeader}
   {@param firstParts: ?}  /** parts of the first line of the header, with "text" and optional "url"
       fields. */
   {@param rest: ?}  /** remaining lines of the header, if any. */
   {@param fileIndex: ?}  /** position of the file within the difference. */
 <pre class="u-pre u-monospace Diff">
   <a name="F{$fileIndex}" class="Diff-fileIndex"></a>
-  {for $part in $firstParts}
-    {if not isFirst($part)}{sp}{/if}
+  {for $part, $index in $firstParts}
+    {if $index > 0}{sp}{/if}
     {if $part.url}
       <a href="{$part.url}">{$part.text}</a>
     {else}
diff --git a/resources/com/google/gitiles/templates/Doc.soy b/resources/com/google/gitiles/templates/Doc.soy
index db1bfc6..c541033 100644
--- a/resources/com/google/gitiles/templates/Doc.soy
+++ b/resources/com/google/gitiles/templates/Doc.soy
@@ -13,6 +13,8 @@
 // limitations under the License.
 {namespace gitiles}
 
+import * as common from 'com/google/gitiles/templates/Common.soy';
+
 /**
  * Default Doc Footer
  */
@@ -36,7 +38,7 @@
 /**
  * Documentation page rendered from markdown.
  */
-{template .markdownDoc}
+{template markdownDoc}
   {@param? siteTitle: ?}  /** h1 title from navbar.md. */
   {@param pageTitle: ?}  /** h1 title from specific page. */
   {@param? logoUrl: ?}  /** url of image logo. */
@@ -77,7 +79,7 @@
   <div class="Site-content Site-Content--markdown">
     <div class="Container">
       <div class="doc">
-        {call .streamingPlaceholder /}
+        {call common.streamingPlaceholder /}
       </div>
     </div>
   </div>
@@ -85,14 +87,12 @@
   {if $analyticsId}
     /* From https://developers.google.com/analytics/devguides/collection/analyticsjs/ */
     <script>
-    (function(i,s,o,g,r,a,m){lb}i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){lb}
-    (i[r].q=i[r].q||[]).push(arguments){rb},i[r].l=1*new Date();a=s.createElement(o),
-    m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m)
-    {rb})(window,document,'script','//www.google-analytics.com/analytics.js','ga');
+    window.ga=window.ga||function(){lb}(ga.q=ga.q||[]).push(arguments){rb};ga.l=+new Date;
 
     ga('create', '{$analyticsId}', 'auto');
     ga('send', 'pageview', {lb}title: '{$pageTitle}'{rb});
     </script>
+    <script async src="https://www.google-analytics.com/analytics.js"></script>
   {/if}
 </body>
 </html>
diff --git a/resources/com/google/gitiles/templates/Error.soy b/resources/com/google/gitiles/templates/Error.soy
index 39fcef3..7ca5024 100644
--- a/resources/com/google/gitiles/templates/Error.soy
+++ b/resources/com/google/gitiles/templates/Error.soy
@@ -13,15 +13,17 @@
 // limitations under the License.
 {namespace gitiles}
 
+import * as common from 'com/google/gitiles/templates/Common.soy';
+
 /**
  * HTML page for error.
  */
-{template .error stricthtml="false"}
+{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}
+{call common.header}
   {param title: $title /}
   {param menuEntries: $menuEntries /}
   {param breadcrumbs: $breadcrumbs /}
@@ -33,7 +35,7 @@
   {/msg}
 </h1>
 
-{call .footer}
+{call common.footer}
   {param customVariant: $customVariant /}
 {/call}
 {/template}
diff --git a/resources/com/google/gitiles/templates/HostIndex.soy b/resources/com/google/gitiles/templates/HostIndex.soy
index 5a05304..a8d84f8 100644
--- a/resources/com/google/gitiles/templates/HostIndex.soy
+++ b/resources/com/google/gitiles/templates/HostIndex.soy
@@ -13,10 +13,12 @@
 // limitations under the License.
 {namespace gitiles}
 
+import * as common from 'com/google/gitiles/templates/Common.soy';
+
 /**
  * HTML page for /.
  */
-{template .hostIndex stricthtml="false"}
+{template hostIndex stricthtml="false"}
   {@param hostName: ?}  /** host name. */
   {@param? menuEntries: ?}  /** menu entries. */
   {@param? customVariant: ?}  /** variant name for custom styling. */
@@ -24,7 +26,7 @@
   {@param? breadcrumbs: ?}  /** map of breadcrumbs for header. */
   {@param repositories: ?}  /** list of repository description maps with name, cloneUrl, and
       optional description values. */
-{call .header}
+{call common.header}
   {param title: $prefix ? $prefix : $hostName ? $hostName + ' Git repositories' : 'Git repositories' /}
   {param menuEntries: $menuEntries /}
   {param breadcrumbs: $breadcrumbs /}
@@ -62,7 +64,7 @@
   </div>
 {/if}
 
-{call .footer}
+{call common.footer}
   {param customVariant: $customVariant /}
 {/call}
 {/template}
diff --git a/resources/com/google/gitiles/templates/LogDetail.soy b/resources/com/google/gitiles/templates/LogDetail.soy
index 6b9a573..d79f25a 100644
--- a/resources/com/google/gitiles/templates/LogDetail.soy
+++ b/resources/com/google/gitiles/templates/LogDetail.soy
@@ -13,10 +13,13 @@
 // limitations under the License.
 {namespace gitiles}
 
+import * as common from 'com/google/gitiles/templates/Common.soy';
+import * as objDetail from 'com/google/gitiles/templates/ObjectDetail.soy';
+
 /**
  * Detail page showing a shortlog for a commit.
  */
-{template .logDetail stricthtml="false"}
+{template logDetail stricthtml="false"}
   {@param title: ?}  /** human-readable revision name. */
   {@param repositoryName: ?}  /** name of this repository. */
   {@param? menuEntries: ?}  /** menu entries. */
@@ -24,17 +27,17 @@
   {@param breadcrumbs: ?}  /** breadcrumbs for this page. */
   {@param? tags: ?}  /** optional list of tags encountered when peeling this object, with keys
       corresponding to gitiles.tagDetail. */
-{call .header data="all" /}
+  {call common.header data="all" /}
 
 {if $tags}
   {for $tag in $tags}
-    {call gitiles.tagDetail data="$tag" /}
+    {call objDetail.tagDetail data="$tag" /}
   {/for}
 {/if}
 
-{call .streamingPlaceholder /}
+{call common.streamingPlaceholder /}
 
-{call .footer}
+{call common.footer}
   {param customVariant: $customVariant /}
 {/call}
 {/template}
@@ -43,7 +46,7 @@
 /**
  * Header for list of log entries.
  */
-{template .logEntriesHeader stricthtml="false"}
+{template logEntriesHeader stricthtml="false"}
   {@param? previousUrl: ?}  /** URL for the previous page of results. */
 {if $previousUrl}
   <nav class="LogNav">
@@ -58,7 +61,7 @@
 /**
  * Wrapper for a single log entry with pretty format and variant.
  */
-{template .logEntryWrapper}
+{template logEntryWrapper}
   {@param variant: ?}  /** variant name for log entry template. */
   {@param entry: ?}  /** log entry; see .logEntry. */
 <li class="CommitLog-item CommitLog-item--{$variant}">
@@ -70,7 +73,7 @@
 /**
  * Footer for the list of log entries.
  */
-{template .logEntriesFooter stricthtml="false"}
+{template logEntriesFooter stricthtml="false"}
   {@param? nextUrl: ?}  /** URL for the next page of results. */
   {@param? nextText: ?}  /** text for next page link. */
 </ol>
@@ -85,7 +88,7 @@
 /**
  * Single log entry indicating the full log is empty.
  */
-{template .emptyLog}
+{template emptyLog}
 <li class="CommitLog-item CommitLog-item--empty">{msg desc="informational text for when the log is empty"}No commits.{/msg}</li>
 {/template}
 
@@ -221,12 +224,16 @@
   </tr>
   <tr>
     <th class="Metadata-title">{msg desc="Header for commit author"}author{/msg}</th>
-    <td>{call .person_ data="$author" /}</td>
+    <td>
+      {call objDetail.person_ data="$author" /}
+    </td>
     <td>{$author.time}</td>
   </tr>
   <tr>
     <th class="Metadata-title">{msg desc="Header for committer"}committer{/msg}</th>
-    <td>{call .person_ data="$committer" /}</td>
+    <td>
+      {call objDetail.person_ data="$committer" /}
+    </td>
     <td>{$committer.time}</td>
   </tr>
 
diff --git a/resources/com/google/gitiles/templates/ObjectDetail.soy b/resources/com/google/gitiles/templates/ObjectDetail.soy
index f755720..4fc4b99 100644
--- a/resources/com/google/gitiles/templates/ObjectDetail.soy
+++ b/resources/com/google/gitiles/templates/ObjectDetail.soy
@@ -16,7 +16,7 @@
 /**
  * Detailed listing of a commit.
  */
-{template .commitDetail}
+{template commitDetail}
   {@param author: ?}  /** map with "name", "email", and "time" keys for the commit author. */
   {@param committer: ?}  /** map with "name", "email", and "time" keys for the committer. */
   {@param sha: ?}  /** commit SHA-1. */
@@ -32,6 +32,7 @@
       text: raw text of the part.
       url: optional URL that should be linked to from the part.
       */
+  {@param notes: ?} /** the notes for the corresponding commit */
   {@param diffTree: ?}  /** list of changed tree entries with the following keys:
       changeType: string matching an org.eclipse.jgit.diff.DiffEntry.ChangeType constant.
       path: (new) path of the tree entry.
@@ -58,12 +59,16 @@
     </tr>
     <tr>
       <th class="Metadata-title">{msg desc="Header for commit author"}author{/msg}</th>
-      <td>{call .person_ data="$author" /}</td>
+      <td>
+        {call person_ data="$author" /}
+      </td>
       <td>{$author.time}</td>
     </tr>
     <tr>
       <th class="Metadata-title">{msg desc="Header for committer"}committer{/msg}</th>
-      <td>{call .person_ data="$committer" /}</td>
+      <td>
+        {call person_ data="$committer" /}
+      </td>
       <td>{$committer.time}</td>
     </tr>
     <tr>
@@ -77,7 +82,7 @@
           <a href="{$parent.url}">{$parent.sha}</a>
           {sp}<span>
             [<a href="{$parent.diffUrl}">{msg desc="text for the parent diff link"}diff{/msg}</a>]
-            {if isNonnull($parent.blameUrl)}
+            {if $parent.blameUrl != null}
               {sp}[<a href="{$parent.blameUrl}">{msg desc="text for the parent blame link"}blame{/msg}</a>]
             {/if}
           </span>
@@ -86,11 +91,20 @@
     {/for}
   </table>
 </div>
-{call .message_}
+{call message_}
   {param className: 'u-pre u-monospace MetadataMessage' /}
   {param message: $message /}
 {/call}
 
+{if $notes}
+  <h4>Notes:</h4>
+  <div class="MetadataMessage">
+      <span>
+        {msg desc="Text for the git notes"}{$notes}{/msg}
+      </span>
+  </div>
+{/if}
+
 {if $diffTree and length($diffTree)}
   <ul class="DiffTree">
     {for $entry in $diffTree}
@@ -146,7 +160,7 @@
 /**
  * Detailed listing of a tree.
  */
-{template .treeDetail}
+{template treeDetail}
   {@param sha: ?}  /** SHA of this path's tree. */
   {@param? logUrl: ?}  /** optional URL to a log for this path. */
   {@param? archiveUrl: ?}  /** optional URL to a download link of this tree as an archive. */
@@ -222,7 +236,7 @@
 /**
  * Common header for a blob shared between detail, blame, etc. views.
  */
-{template .blobHeader}
+{template blobHeader}
   {@param sha: ?}  /** SHA of this file's blob. */
   {@param? fileUrl: ?}  /** optional URL to a detail view of this file. */
   {@param? logUrl: ?}  /** optional URL to a log for this file. */
@@ -240,20 +254,20 @@
 /**
  * Detailed listing of a blob.
  */
-{template .blobDetail}
+{template blobDetail}
   {@param sha: ?}  /** SHA of this file's blob. */
   {@param? logUrl: ?}  /** optional URL to a log for this file. */
   {@param? blameUrl: ?}  /** optional URL to a blame for this file. */
   {@param lines: ?}  /** lines (may be empty), or null for a binary file. Each line is a list of
       entries with "classes" and "text" fields for pretty-printed spans. */
   {@param? size: ?}  /** for binary files only, size in bytes. */
-  {call .blobHeader data="all" /}
+  {call blobHeader data="all" /}
 
   {if $lines != null}
     {if $lines}
       <table class="FileContents">
-        {for $line in $lines}
-          {let $n: index($line) + 1 /}
+        {for $line, $index in $lines}
+          {let $n: $index + 1 /}
           <tr class="u-pre u-monospace FileContents-line">
             <td class="u-lineNum u-noSelect FileContents-lineNum"
                 data-line-number="{$n}" onclick="window.location.hash='#{$n}'"></td>
@@ -278,7 +292,7 @@
 /**
  * Detailed listing of an annotated tag.
  */
-{template .tagDetail}
+{template tagDetail}
   {@param sha: ?}  /** SHA of this tag. */
   {@param? tagger: ?}  /** optional map with "name", "email", and "time" keys for the tagger. */
   {@param object: ?}  /** SHA of the object this tag points to. */
@@ -293,7 +307,9 @@
     {if $tagger}
       <tr>
         <th class="Metadata-title">{msg desc="Header for tagger"}tagger{/msg}</th>
-        <td>{call .person_ data="$tagger" /}</td>
+        <td>
+          {call person_ data="$tagger" /}
+        </td>
         <td>{$tagger.time}</td>
       </tr>
     {/if}
@@ -305,7 +321,7 @@
   </table>
 </div>
 {if $message and length($message)}
-  {call .message_}
+  {call message_}
     {param className: 'u-pre u-monospace MetadataMessage' /}
     {param message: $message /}
   {/call}
@@ -315,7 +331,7 @@
 /**
  * Line about a git person identity.
  */
-{template .person_}
+{template person_}
   {@param name: ?}  /** name. */
   {@param email: ?}  /** email. */
 {$name}{if $email} &lt;{$email}&gt;{/if}
@@ -324,7 +340,7 @@
 /**
  * Preformatted message, possibly containing hyperlinks.
  */
-{template .message_ visibility="private"}
+{template message_ visibility="private"}
   {@param className: ?}  /** CSS class name for <pre> block. */
   {@param message: ?}  /** list of message parts, where each part contains:
       text: raw text of the part.
diff --git a/resources/com/google/gitiles/templates/PathDetail.soy b/resources/com/google/gitiles/templates/PathDetail.soy
index 7dd283b..fb0936b 100644
--- a/resources/com/google/gitiles/templates/PathDetail.soy
+++ b/resources/com/google/gitiles/templates/PathDetail.soy
@@ -13,10 +13,13 @@
 // limitations under the License.
 {namespace gitiles}
 
+import * as common from 'com/google/gitiles/templates/Common.soy';
+import * as objDetail from 'com/google/gitiles/templates/ObjectDetail.soy';
+
 /**
  * Detail page for a path within a tree.
  */
-{template .pathDetail stricthtml="false"}
+{template pathDetail stricthtml="false"}
   {@param title: ?}  /** human-readable name of this path. */
   {@param repositoryName: ?}  /** name of this repository. */
   {@param? menuEntries: ?}  /** menu entries. */
@@ -28,30 +31,35 @@
       .symlinkDetail, or .gitlinkDetail as appropriate. */
   {@inject staticUrls: ?}
 {if $type == 'REGULAR_FILE' or $type == 'EXECUTABLE_FILE'}
-  {call .header data="all"}
+  {call common.header data="all"}
     {param css: [$staticUrls.PRETTIFY_CSS_URL] /}
   {/call}
 {elseif $data.readmeHtml}
-  {call .header data="all"}
+  {call common.header data="all"}
     {param css: [$staticUrls.DOC_CSS_URL, $staticUrls.PRETTIFY_CSS_URL] /}
   {/call}
 {else}
-  {call .header data="all" /}
+  {call common.header data="all" /}
 {/if}
 
 {switch $type}
-  {case 'TREE'}{call .treeDetail data="$data" /}
-  {case 'SYMLINK'}{call .symlinkDetail data="$data" /}
-  {case 'REGULAR_FILE'}{call .blobDetail data="$data" /}
-  {case 'EXECUTABLE_FILE'}{call .blobDetail data="$data" /}
-  {case 'GITLINK'}{call .gitlinkDetail data="$data" /}
+  {case 'TREE'}
+    {call objDetail.treeDetail data="$data" /}
+  {case 'SYMLINK'}
+    {call symlinkDetail data="$data" /}
+  {case 'REGULAR_FILE'}
+    {call objDetail.blobDetail data="$data" /}
+  {case 'EXECUTABLE_FILE'}
+    {call objDetail.blobDetail data="$data" /}
+  {case 'GITLINK'}
+    {call gitlinkDetail data="$data" /}
   {default}
     <div class="error">
       {msg desc="Error message for an unknown object type"}Object has unknown type.{/msg}
     </div>
 {/switch}
 
-{call .footer}
+{call common.footer}
   {param customVariant: $customVariant /}
 {/call}
 {/template}
@@ -59,7 +67,7 @@
 /**
  * Detail for a symbolic link.
  */
-{template .symlinkDetail}
+{template symlinkDetail}
   {@param target: ?}  /** target of this symlink. */
   {@param? targetUrl: ?}  /** optional URL for the target, if it is within this repo. */
 <div class="symlink-detail">
@@ -71,7 +79,7 @@
 /**
  * Detail for a git submodule link.
  */
-{template .gitlinkDetail}
+{template gitlinkDetail}
   {@param sha: ?}  /** submodule commit SHA. */
   {@param remoteUrl: ?}  /** URL of the remote repository. */
   {@param? httpUrl: ?}  /** optional HTTP URL pointing to a web-browser-compatible URL of the remote
diff --git a/resources/com/google/gitiles/templates/RefList.soy b/resources/com/google/gitiles/templates/RefList.soy
index 0815d41..112573b 100644
--- a/resources/com/google/gitiles/templates/RefList.soy
+++ b/resources/com/google/gitiles/templates/RefList.soy
@@ -13,18 +13,19 @@
 // limitations under the License.
 {namespace gitiles}
 
+import * as common from 'com/google/gitiles/templates/Common.soy';
 
 /**
  * List of all refs in a repository.
  */
-{template .refsDetail stricthtml="false"}
+{template refsDetail stricthtml="false"}
   {@param repositoryName: ?}  /** name of this repository. */
   {@param? menuEntries: ?}  /** menu entries. */
   {@param? customVariant: ?}  /** variant name for custom styling. */
   {@param breadcrumbs: ?}  /** breadcrumbs for this page. */
   {@param branches: ?}  /** list of branch objects with url, name, and isHead keys. */
   {@param tags: ?}  /** list of tag objects with url and name keys. */
-{call .header}
+{call common.header}
   {param title: 'Refs' /}
   {param repositoryName: $repositoryName /}
   {param menuEntries: $menuEntries /}
@@ -34,21 +35,21 @@
 
 <div class="Refs">
   {if length($branches)}
-    {call .refList}
+    {call refList}
       {param type: 'Branches' /}
       {param refs: $branches /}
     {/call}
   {/if}
 
   {if length($tags)}
-    {call .refList}
+    {call refList}
       {param type: 'Tags' /}
       {param refs: $tags /}
     {/call}
   {/if}
 </div>
 
-{call .footer}
+{call common.footer}
   {param customVariant: $customVariant /}
 {/call}
 {/template}
@@ -56,7 +57,7 @@
 /**
  * List of a single type of refs
  */
-{template .refList}
+{template refList}
   {@param type: ?}  /** name of this type of refs, e.g. "Branches" */
   {@param refs: ?}  /** list of branch objects with url, name, and optional isHead keys. */
   <div class="RefList">
diff --git a/resources/com/google/gitiles/templates/RepositoryIndex.soy b/resources/com/google/gitiles/templates/RepositoryIndex.soy
index 0a27d47..ca83cab 100644
--- a/resources/com/google/gitiles/templates/RepositoryIndex.soy
+++ b/resources/com/google/gitiles/templates/RepositoryIndex.soy
@@ -13,10 +13,13 @@
 // limitations under the License.
 {namespace gitiles}
 
+import * as common from 'com/google/gitiles/templates/Common.soy';
+import * as refList from 'com/google/gitiles/templates/RefList.soy';
+
 /**
  * Index page for a repository.
  */
-{template .repositoryIndex stricthtml="false"}
+{template repositoryIndex stricthtml="false"}
   {@param repositoryName: ?}  /** name of this repository. */
   {@param? menuEntries: ?}  /** menu entries. */
   {@param? customVariant: ?}  /** variant name for custom styling. */
@@ -32,16 +35,16 @@
   {@param? readmeHtml: ?}  /** optional rendered README.md contents. */
   {@inject staticUrls: ?}
 {if $readmeHtml}
-  {call .header data="all"}
+  {call common.header data="all"}
     {param title: $repositoryName /}
     {param repositoryName: null /}
     {param menuEntries: $menuEntries /}
     {param customVariant: $customVariant /}
     {param breadcrumbs: $breadcrumbs /}
-    {param css: [$staticUrls.DOC_CSS_URL] /}
+    {param css: [$staticUrls['DOC_CSS_URL']] /}
   {/call}
 {else}
-  {call .header}
+  {call common.header}
     {param title: $repositoryName /}
     {param repositoryName: null /}
     {param menuEntries: $menuEntries /}
@@ -72,11 +75,11 @@
 {if $hasLog and (length($branches) or length($tags))}
   <div class="RepoShortlog">
     <div class="RepoShortlog-refs">
-      {call .branches_ data="all" /}
-      {call .tags_ data="all" /}
+      {call branches_ data="all" /}
+      {call tags_ data="all" /}
     </div>
     <div class="RepoShortlog-log">
-      {call .streamingPlaceholder /}
+      {call common.streamingPlaceholder /}
       {if $readmeHtml}
         <div class="doc RepoIndexDoc">{$readmeHtml}</div>
       {/if}
@@ -84,16 +87,16 @@
   </div>
 
 {elseif $hasLog}
-  {call .streamingPlaceholder /}
+  {call common.streamingPlaceholder /}
 {elseif length($branches) or length($tags)}
-  {call .branches_ data="all" /}
-  {call .tags_ data="all" /}
+  {call branches_ data="all" /}
+  {call tags_ data="all" /}
 {else}
   <h1 class="EmptyRepo-header">Empty Repository</h1>
   <p class="EmptyRepo-description">This repository is empty. Push to it to show branches and history.</p>
 {/if}
 
-{call .footer}
+{call common.footer}
   {param customVariant: $customVariant /}
 {/call}
 {/template}
@@ -101,11 +104,11 @@
 /**
  * List of branches.
  */
-{template .branches_ visibility="private"}
+{template branches_ visibility="private"}
   {@param? branches: ?}  /** list of branch objects with url and name keys. */
   {@param? moreBranchesUrl: ?}  /** URL to show more branches, if necessary. */
   {if length($branches)}
-    {call .refList}
+    {call refList.refList}
       {param type: 'Branches' /}
       {param refs: $branches /}
     {/call}
@@ -118,11 +121,11 @@
 /**
  * List of tags.
  */
-{template .tags_ visibility="private"}
+{template tags_ visibility="private"}
   {@param? tags: ?}  /** list of branch objects with url and name keys. */
   {@param? moreTagsUrl: ?}  /** URL to show more tags, if necessary. */
   {if length($tags)}
-    {call .refList}
+    {call refList.refList}
       {param type: 'Tags' /}
       {param refs: $tags /}
     {/call}
diff --git a/resources/com/google/gitiles/templates/RevisionDetail.soy b/resources/com/google/gitiles/templates/RevisionDetail.soy
index 06fa996..e520990 100644
--- a/resources/com/google/gitiles/templates/RevisionDetail.soy
+++ b/resources/com/google/gitiles/templates/RevisionDetail.soy
@@ -13,10 +13,13 @@
 // limitations under the License.
 {namespace gitiles}
 
+import * as common from 'com/google/gitiles/templates/Common.soy';
+import * as objDetail from 'com/google/gitiles/templates/ObjectDetail.soy';
+
 /**
  * Detail page about a single revision.
  */
-{template .revisionDetail stricthtml="false"}
+{template revisionDetail stricthtml="false"}
   {@param title: ?}  /** human-readable revision name. */
   {@param repositoryName: ?}  /** name of this repository. */
   {@param? menuEntries: ?}  /** menu entries. */
@@ -30,27 +33,27 @@
       ObjectDetail.soy. */
   {@inject staticUrls: ?}
 {if $hasBlob}
-  {call .header data="all"}
+  {call common.header data="all"}
     {param css: [$staticUrls.PRETTIFY_CSS_URL] /}
   {/call}
 {elseif $hasReadme}
-  {call .header data="all"}
+  {call common.header data="all"}
     {param css: [$staticUrls.DOC_CSS_URL, $staticUrls.PRETTIFY_CSS_URL] /}
   {/call}
 {else}
-  {call .header data="all" /}
+  {call common.header data="all" /}
 {/if}
 
 {for $object in $objects}
   {switch $object.type}
     {case 'commit'}
-      {call .commitDetail data="$object.data" /}
+      {call objDetail.commitDetail data="$object.data" /}
     {case 'tree'}
-      {call .treeDetail data="$object.data" /}
+      {call objDetail.treeDetail data="$object.data" /}
     {case 'blob'}
-      {call .blobDetail data="$object.data" /}
+      {call objDetail.blobDetail data="$object.data" /}
     {case 'tag'}
-      {call .tagDetail data="$object.data" /}
+      {call objDetail.tagDetail data="$object.data" /}
     {default}
       <div class="error">
         {msg desc="Error message for an unknown object type"}Object has unknown type.{/msg}
@@ -58,7 +61,7 @@
   {/switch}
 {/for}
 
-{call .footer}
+{call common.footer}
   {param customVariant: $customVariant /}
 {/call}
 {/template}
diff --git a/version.bzl b/version.bzl
index c5200a7..3c1285c 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.4"
+GITILES_VERSION = "0.4-1"