Merge "Adapt CSS for small screens"
diff --git a/.bazelrc b/.bazelrc
index dde54fe..b481c64 100644
--- a/.bazelrc
+++ b/.bazelrc
@@ -1,9 +1,9 @@
-build --workspace_status_command=./tools/workspace-status.sh
+build --workspace_status_command="python3 ./tools/workspace_status.py"
 build --repository_cache=~/.gerritcodereview/bazel-cache/repository
 build --experimental_strict_action_env
 build --action_env=PATH
 build --disk_cache=~/.gerritcodereview/bazel-cache/cas
-build --java_toolchain //tools:error_prone_warnings_toolchain
+build --java_toolchain //tools:error_prone_warnings_toolchain_java11
 
 test --build_tests_only
 test --test_output=errors
diff --git a/.gitmodules b/.gitmodules
new file mode 100644
index 0000000..b339fa4
--- /dev/null
+++ b/.gitmodules
@@ -0,0 +1,3 @@
+[submodule "modules/jgit"]
+	path = modules/jgit
+	url = ../jgit
diff --git a/BUILD b/BUILD
index d65fe65..3d84a08 100644
--- a/BUILD
+++ b/BUILD
@@ -6,7 +6,7 @@
     libs = [
         "//lib/jetty:server",
         "//lib/jetty:servlet",
-        "//lib/slf4j:slf4j-simple",
+        "//lib:slf4j-simple",
         "//java/com/google/gitiles:servlet",
     ],
     web_xml = "//resources:web_xml",
diff --git a/Documentation/markdown.md b/Documentation/markdown.md
index e6b766c..4c76388 100644
--- a/Documentation/markdown.md
+++ b/Documentation/markdown.md
@@ -446,7 +446,7 @@
 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)
+Unsupported extensions or files larger than [image size](config.md#Image-size)
 limit (default 256K) will display a broken image.
 
 *** note
diff --git a/WORKSPACE b/WORKSPACE
index 6359814..1fd87c1 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -18,10 +18,15 @@
 
 load(
     "@com_googlesource_gerrit_bazlets//tools:maven_jar.bzl",
-    "MAVEN_CENTRAL",
     "maven_jar",
 )
 
+# JGit external repository consumed from git submodule
+local_repository(
+    name = "jgit",
+    path = "modules/jgit",
+)
+
 maven_jar(
     name = "commons-lang3",
     artifact = "org.apache.commons:commons-lang3:3.8.1",
@@ -106,7 +111,7 @@
 )
 
 maven_jar(
-    name = "servlet-api_3_1",
+    name = "servlet-api",
     artifact = "javax.servlet:javax.servlet-api:3.1.0",
     sha1 = "3cd63d075497751784b2fa84be59432f4905bf7c",
 )
@@ -148,40 +153,8 @@
     sha1 = "198ea005f41219f038f4291f0b0e9f3259730e92",
 )
 
-JGIT_VERS = "5.12.0.202106070339-r"
-
-JGIT_REPO = MAVEN_CENTRAL
-
 maven_jar(
-    name = "jgit-lib",
-    artifact = "org.eclipse.jgit:org.eclipse.jgit:" + JGIT_VERS,
-    repository = JGIT_REPO,
-    sha1 = "b7792da62103c956d3e58e29fb2e6e5c5f0e1317",
-)
-
-maven_jar(
-    name = "jgit-servlet",
-    artifact = "org.eclipse.jgit:org.eclipse.jgit.http.server:" + JGIT_VERS,
-    repository = JGIT_REPO,
-    sha1 = "c50ee52951bdcd119af0181926c25e09ae913aab",
-)
-
-maven_jar(
-    name = "jgit-junit",
-    artifact = "org.eclipse.jgit:org.eclipse.jgit.junit:" + JGIT_VERS,
-    repository = JGIT_REPO,
-    sha1 = "1bb81c9104f318f16748dbaa43f95509a53e7aa0",
-)
-
-maven_jar(
-    name = "jgit-archive",
-    artifact = "org.eclipse.jgit:org.eclipse.jgit.archive:" + JGIT_VERS,
-    repository = JGIT_REPO,
-    sha1 = "93f59b510a923bd757ea6b2a6e359d222daf2e1d",
-)
-
-maven_jar(
-    name = "ewah",
+    name = "javaewah",
     artifact = "com.googlecode.javaewah:JavaEWAH:1.1.7",
     sha1 = "570dde3cd706ae10c62fe19b150928cfdb415e87",
 )
@@ -210,15 +183,47 @@
 )
 
 maven_jar(
+    name = "hamcrest-library",
+    artifact = "org.hamcrest:hamcrest-library:1.3",
+    sha1 = "4785a3c21320980282f9f33d0d1264a69040538f",
+)
+
+maven_jar(
     name = "hamcrest-core",
     artifact = "org.hamcrest:hamcrest-core:1.3",
     sha1 = "42a25dc3219429f0e5d060061f71acb49bf010a0",
 )
 
+maven_jar(
+    name = "mockito",
+    artifact = "org.mockito:mockito-core:2.23.0",
+    sha1 = "497ddb32fd5d01f9dbe99a2ec790aeb931dff1b1",
+)
+
+BYTE_BUDDY_VERSION = "1.9.0"
+
+maven_jar(
+    name = "bytebuddy",
+    artifact = "net.bytebuddy:byte-buddy:" + BYTE_BUDDY_VERSION,
+    sha1 = "8cb0d5baae526c9df46ae17693bbba302640538b",
+)
+
+maven_jar(
+    name = "bytebuddy-agent",
+    artifact = "net.bytebuddy:byte-buddy-agent:" + BYTE_BUDDY_VERSION,
+    sha1 = "37b5703b4a6290be3fffc63ae9c6bcaaee0ff856",
+)
+
+maven_jar(
+    name = "objenesis",
+    artifact = "org.objenesis:objenesis:2.6",
+    sha1 = "639033469776fd37c08358c6b92a4761feb2af4b",
+)
+
 SL_VERS = "1.7.26"
 
 maven_jar(
-    name = "slf4j-api",
+    name = "log-api",
     artifact = "org.slf4j:slf4j-api:" + SL_VERS,
     sha1 = "77100a62c2e6f04b53977b9f541044d7d722693d",
 )
diff --git a/java/com/google/gitiles/BUILD b/java/com/google/gitiles/BUILD
index a105f38..8e79933 100644
--- a/java/com/google/gitiles/BUILD
+++ b/java/com/google/gitiles/BUILD
@@ -15,15 +15,15 @@
     "//lib:gfm-tables",
     "//lib:gfm-strikethrough",
     "//lib:prettify",
-    "//lib/jgit:jgit",
-    "//lib/jgit:jgit-servlet",
-    "//lib/slf4j:slf4j-api",
+    "//lib:jgit",
+    "//lib:jgit-servlet",
+    "//lib:slf4j-api",
     "//lib/soy:soy",
     "//java/com/google/gitiles/blame/cache",
 ]
 
 DEPS_ALL = DEPS + [
-    "//lib/jgit:jgit-archive",
+    "//lib:jgit-archive",
     "//lib/guice:guice",
 ]
 
diff --git a/java/com/google/gitiles/BranchRedirect.java b/java/com/google/gitiles/BranchRedirect.java
new file mode 100644
index 0000000..3a34f65
--- /dev/null
+++ b/java/com/google/gitiles/BranchRedirect.java
@@ -0,0 +1,62 @@
+// 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.gitiles.FormatType.HTML;
+
+import java.util.Optional;
+import javax.servlet.http.HttpServletRequest;
+import org.eclipse.jgit.lib.Repository;
+
+/**
+ * Utility that provides information to replace the URL string that contains a branch name to a new
+ * branch name. The updated branch mapping is provided by {@code BranchRedirect#getRedirectBranch}
+ * method. If it should update the branch then it is the caller's responsibility to update the URL
+ * with updated branch name as redirect.
+ *
+ * <p>This implementation does not provide a branch redirect mapping. Hence, including this as-is
+ * would be a no-op. To make this effective {@code BranchRedirect#getRedirectBranch} needs to be
+ * overridden that provides a mapping to the requested repo/branch.
+ */
+public class BranchRedirect {
+
+  static final BranchRedirect EMPTY = new BranchRedirect();
+
+  /**
+   * 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();
+  }
+
+  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/BranchRedirectFilter.java b/java/com/google/gitiles/BranchRedirectFilter.java
deleted file mode 100644
index 12dc2b4..0000000
--- a/java/com/google/gitiles/BranchRedirectFilter.java
+++ /dev/null
@@ -1,121 +0,0 @@
-// 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/GitilesFilter.java b/java/com/google/gitiles/GitilesFilter.java
index b347e08..da6044b 100644
--- a/java/com/google/gitiles/GitilesFilter.java
+++ b/java/com/google/gitiles/GitilesFilter.java
@@ -176,7 +176,7 @@
   private BlameCache blameCache;
   private GitwebRedirectFilter gitwebRedirect;
   private Filter errorHandler;
-  private BranchRedirectFilter branchRedirect;
+  private BranchRedirect branchRedirect;
   private boolean initialized;
 
   GitilesFilter() {}
@@ -191,7 +191,7 @@
       @Nullable TimeCache timeCache,
       @Nullable BlameCache blameCache,
       @Nullable GitwebRedirectFilter gitwebRedirect,
-      BranchRedirectFilter branchRedirect,
+      BranchRedirect branchRedirect,
       @Nullable Filter errorHandler) {
     this.config = checkNotNull(config, "config");
     this.renderer = renderer;
@@ -220,28 +220,23 @@
     }
 
     Filter repositoryFilter = new RepositoryFilter(resolver);
-    Filter viewFilter = new ViewFilter(accessFactory, urls, visibilityCache);
+    Filter viewFilter = new ViewFilter(accessFactory, urls, visibilityCache, branchRedirect);
     Filter dispatchFilter = new DispatchFilter(filters, servlets);
 
     ServletBinder root = serveRegex(ROOT_REGEX).through(viewFilter);
     if (gitwebRedirect != null) {
       root.through(gitwebRedirect);
     }
-    if (branchRedirect != null) {
-      root.through(branchRedirect);
-    }
     root.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 4467cfa..ee66753 100644
--- a/java/com/google/gitiles/GitilesServlet.java
+++ b/java/com/google/gitiles/GitilesServlet.java
@@ -52,7 +52,7 @@
       @Nullable TimeCache timeCache,
       @Nullable BlameCache blameCache,
       @Nullable GitwebRedirectFilter gitwebRedirect,
-      BranchRedirectFilter branchRedirect) {
+      BranchRedirect branchRedirect) {
     this(
         config,
         renderer,
@@ -77,7 +77,7 @@
       @Nullable TimeCache timeCache,
       @Nullable BlameCache blameCache,
       @Nullable GitwebRedirectFilter gitwebRedirect,
-      BranchRedirectFilter branchRedirect,
+      BranchRedirect branchRedirect,
       @Nullable Filter errorHandler) {
     super(
         new GitilesFilter(
diff --git a/java/com/google/gitiles/LogServlet.java b/java/com/google/gitiles/LogServlet.java
index c738f0e..645264b 100644
--- a/java/com/google/gitiles/LogServlet.java
+++ b/java/com/google/gitiles/LogServlet.java
@@ -230,7 +230,7 @@
       walk.setFirstParent(true);
     }
     if (isTrue(view, TOPO_ORDER_PARAM)) {
-      walk.sort(RevSort.TOPO, true);
+      walk.sort(RevSort.TOPO_KEEP_BRANCH_TOGETHER, true);
     }
     if (isTrue(view, REVERSE_PARAM)) {
       walk.sort(RevSort.REVERSE, true);
diff --git a/java/com/google/gitiles/RevisionParser.java b/java/com/google/gitiles/RevisionParser.java
index b736f7f..8196267 100644
--- a/java/com/google/gitiles/RevisionParser.java
+++ b/java/com/google/gitiles/RevisionParser.java
@@ -21,8 +21,12 @@
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.CharMatcher;
 import com.google.common.base.Splitter;
+import com.google.common.base.Strings;
 import java.io.IOException;
 import java.util.Objects;
+import java.util.Optional;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
 import org.eclipse.jgit.errors.AmbiguousObjectException;
 import org.eclipse.jgit.errors.MissingObjectException;
 import org.eclipse.jgit.errors.RevisionSyntaxException;
@@ -37,6 +41,10 @@
 class RevisionParser {
   private static final Splitter OPERATOR_SPLITTER = Splitter.on(CharMatcher.anyOf("^~"));
 
+  // The ref name part of a revision expression ends at the first
+  // appearance of ^, ~, :, or @{ (see git-check-ref-format(1)).
+  private static final Pattern END_OF_REF = Pattern.compile("[\\^~:]|@\\{");
+
   static class Result {
     private final Revision revision;
     private final Revision oldRevision;
@@ -96,21 +104,28 @@
   private final Repository repo;
   private final GitilesAccess access;
   private final VisibilityCache cache;
+  private final BranchRedirect branchRedirect;
 
-  RevisionParser(Repository repo, GitilesAccess access, VisibilityCache cache) {
+  RevisionParser(
+      Repository repo, GitilesAccess access, VisibilityCache cache, BranchRedirect branchRedirect) {
     this.repo = checkNotNull(repo, "repo");
     this.access = checkNotNull(access, "access");
     this.cache = checkNotNull(cache, "cache");
+    this.branchRedirect = checkNotNull(branchRedirect, "branchRedirect");
   }
 
   Result parse(String path) throws IOException {
     if (path.startsWith("/")) {
       path = path.substring(1);
     }
+    if (Strings.isNullOrEmpty(path)) {
+      return null;
+    }
     try (RevWalk walk = new RevWalk(repo)) {
       walk.setRetainBody(false);
 
       Revision oldRevision = null;
+      Revision oldRevisionRedirected = null;
 
       StringBuilder b = new StringBuilder();
       boolean first = true;
@@ -130,14 +145,24 @@
           } else if (dots > 0) {
             b.append(part, 0, dots);
             String oldName = b.toString();
-            if (!isValidRevision(oldName)) {
+            String oldNameRedirect = getRedirectFor(oldName);
+
+            if (!isValidRevision(oldNameRedirect)) {
               return null;
             }
-            RevObject old = resolve(oldName, walk);
+            RevObject old = resolve(oldNameRedirect, walk);
             if (old == null) {
               return null;
             }
+            /*
+             * Retain oldRevision with the old name (non-redirected-path) since it is used in
+             * determining the Revision path (start index of the path from the name).
+             * For example: For a master -> main redirect,
+             * original path: /master/index.c is updated to /main/index.c
+             * To parse the ref/path to build Revision object we look at the original path.
+             */
             oldRevision = Revision.peel(oldName, old, walk);
+            oldRevisionRedirected = Revision.peel(oldNameRedirect, old, walk);
             part = part.substring(dots + 2);
             b = new StringBuilder();
           } else if (firstParent > 0) {
@@ -149,7 +174,9 @@
             if (!isValidRevision(name)) {
               return null;
             }
-            RevObject obj = resolve(name, walk);
+
+            String nameRedirected = getRedirectFor(name);
+            RevObject obj = resolve(nameRedirected, walk);
             if (obj == null) {
               return null;
             }
@@ -162,13 +189,15 @@
             }
             RevCommit c = (RevCommit) obj;
             if (c.getParentCount() > 0) {
-              oldRevision = Revision.peeled(name + "^", c.getParent(0));
+              oldRevisionRedirected = Revision.peeled(nameRedirected + "^", c.getParent(0));
             } else {
-              oldRevision = Revision.NULL;
+              oldRevisionRedirected = Revision.NULL;
             }
             Result result =
                 new Result(
-                    Revision.peeled(name, c), oldRevision, path.substring(name.length() + 2));
+                    Revision.peeled(nameRedirected, c),
+                    oldRevisionRedirected,
+                    path.substring(name.length() + 2));
             return isVisible(walk, result) ? result : null;
           }
         }
@@ -178,7 +207,9 @@
         if (!isValidRevision(name)) {
           return null;
         }
-        RevObject obj = resolve(name, walk);
+        String nameRedirected = getRedirectFor(name);
+
+        RevObject obj = resolve(nameRedirected, walk);
         if (obj != null) {
           int pathStart;
           if (oldRevision == null) {
@@ -188,7 +219,10 @@
             pathStart = oldRevision.getName().length() + 2 + name.length();
           }
           Result result =
-              new Result(Revision.peel(name, obj, walk), oldRevision, path.substring(pathStart));
+              new Result(
+                  Revision.peel(nameRedirected, obj, walk),
+                  oldRevisionRedirected,
+                  path.substring(pathStart));
           return isVisible(walk, result) ? result : null;
         }
         first = false;
@@ -233,4 +267,29 @@
     }
     return true;
   }
+
+  /**
+   * It replaces the ref in the revision expression to the redirected refName, without changing the
+   * behavior of the expression.
+   *
+   * <p>For eg: branch redirect {master -> main} would yield {master -> main}, {refs/heads/master^
+   * -> refs/heads/main^}, {refs/heads/master^ -> refs/heads/main^}. It does expand to a full
+   * refName even for shorter refNames.
+   */
+  private String getRedirectFor(String revisionExpression) {
+    String refName = refPart(revisionExpression);
+    Optional<String> redirect = branchRedirect.getRedirectBranch(repo, refName);
+    if (redirect.isPresent()) {
+      return redirect.get() + revisionExpression.substring(refName.length());
+    }
+    return revisionExpression;
+  }
+
+  private static String refPart(String revisionExpression) {
+    Matcher m = END_OF_REF.matcher(revisionExpression);
+    if (!m.find()) { // no terminator -> the whole string is a ref name.
+      return revisionExpression;
+    }
+    return revisionExpression.substring(0, m.start());
+  }
 }
diff --git a/java/com/google/gitiles/TreeSoyData.java b/java/com/google/gitiles/TreeSoyData.java
index 1e4e4b7..985edfa 100644
--- a/java/com/google/gitiles/TreeSoyData.java
+++ b/java/com/google/gitiles/TreeSoyData.java
@@ -41,6 +41,14 @@
    */
   private static final int MAX_SYMLINK_TARGET_LENGTH = 72;
 
+  private static final Map<String, Integer> TYPE_WEIGHT =
+      Map.of(
+          "TREE", 0,
+          "GITLINK", 1,
+          "SYMLINK", 2,
+          "REGULAR_FILE", 3,
+          "EXECUTABLE_FILE", 3);
+
   /**
    * Maximum number of bytes to load from a blob that claims to be a symlink. If the blob is larger
    * than this byte limit it will be displayed as a binary file instead of as a symlink.
@@ -65,6 +73,24 @@
     return lastSlash >= 0 ? "..." + target.substring(lastSlash) : target;
   }
 
+  static String stripEndingSolidus(String p) {
+    return p.endsWith("/") ? p.substring(0, p.length() - 1) : p;
+  }
+
+  static int sortByTypeAlpha(Map<String, String> m1, Map<String, String> m2) {
+    int weightDiff = TYPE_WEIGHT.get(m1.get("type")).compareTo(TYPE_WEIGHT.get(m2.get("type")));
+    if (weightDiff == 0) {
+      String s1 = m1.get("name");
+      String s2 = m2.get("name");
+      if (m1.get("type").equals("TREE")) {
+        s1 = stripEndingSolidus(s1);
+        s2 = stripEndingSolidus(s2);
+      }
+      return s1.compareToIgnoreCase(s2);
+    }
+    return weightDiff;
+  }
+
   private final ObjectReader reader;
   private final GitilesView view;
   private final Config cfg;
@@ -90,7 +116,7 @@
       throws MissingObjectException, IOException {
     ReadmeHelper readme =
         new ReadmeHelper(reader, view, MarkdownConfig.get(cfg), rootTree, requestUri);
-    List<Object> entries = Lists.newArrayList();
+    List<Map<String, String>> entries = Lists.newArrayList();
     GitilesView.Builder urlBuilder = GitilesView.path().copyFrom(view);
     while (tw.next()) {
       FileType type = FileType.forEntry(tw);
@@ -129,6 +155,8 @@
       entries.add(entry);
     }
 
+    entries.sort(TreeSoyData::sortByTypeAlpha);
+
     Map<String, Object> data = Maps.newHashMapWithExpectedSize(3);
     data.put("sha", treeId.name());
     data.put("entries", entries);
diff --git a/java/com/google/gitiles/ViewFilter.java b/java/com/google/gitiles/ViewFilter.java
index 25d6d7e..dd3d54a 100644
--- a/java/com/google/gitiles/ViewFilter.java
+++ b/java/com/google/gitiles/ViewFilter.java
@@ -85,12 +85,17 @@
   private final GitilesUrls urls;
   private final GitilesAccess.Factory accessFactory;
   private final VisibilityCache visibilityCache;
+  private final BranchRedirect branchRedirect;
 
   public ViewFilter(
-      GitilesAccess.Factory accessFactory, GitilesUrls urls, VisibilityCache visibilityCache) {
+      GitilesAccess.Factory accessFactory,
+      GitilesUrls urls,
+      VisibilityCache visibilityCache,
+      BranchRedirect branchRedirect) {
     this.urls = checkNotNull(urls, "urls");
     this.accessFactory = checkNotNull(accessFactory, "accessFactory");
     this.visibilityCache = checkNotNull(visibilityCache, "visibilityCache");
+    this.branchRedirect = checkNotNull(branchRedirect, "branchRedirect");
   }
 
   @Override
@@ -101,7 +106,6 @@
       throw new GitilesRequestFailureException(FailureReason.CANNOT_PARSE_GITILES_VIEW);
     }
 
-    @SuppressWarnings("unchecked")
     Map<String, String[]> params = req.getParameterMap();
     view.setHostName(urls.getHostName(req))
         .setServletPath(req.getContextPath() + req.getServletPath())
@@ -320,7 +324,14 @@
       throws IOException {
     RevisionParser revParser =
         new RevisionParser(
-            ServletUtils.getRepository(req), accessFactory.forRequest(req), visibilityCache);
+            ServletUtils.getRepository(req),
+            accessFactory.forRequest(req),
+            visibilityCache,
+            getBranchRedirect(req));
     return revParser.parse(checkLeadingSlash(path));
   }
+
+  private BranchRedirect getBranchRedirect(HttpServletRequest req) {
+    return BranchRedirect.isForAutomation(req) ? BranchRedirect.EMPTY : branchRedirect;
+  }
 }
diff --git a/java/com/google/gitiles/blame/cache/BUILD b/java/com/google/gitiles/blame/cache/BUILD
index e1491fc..e256ea3 100644
--- a/java/com/google/gitiles/blame/cache/BUILD
+++ b/java/com/google/gitiles/blame/cache/BUILD
@@ -8,7 +8,7 @@
     visibility = ["//visibility:public"],
     deps = [
         "//lib:guava",
-        "//lib/jgit",
+        "//lib:jgit",
     ],
 )
 
@@ -17,7 +17,7 @@
     libs = [
         ":cache",
         "//lib:guava",
-        "//lib/jgit:jgit",
+        "//lib:jgit",
     ],
     pkgs = ["com.google.gitiles.blame.cache"],
     title = "Blame Cache API Documentation",
diff --git a/java/com/google/gitiles/dev/BUILD b/java/com/google/gitiles/dev/BUILD
index 9550cf0..72caf40 100644
--- a/java/com/google/gitiles/dev/BUILD
+++ b/java/com/google/gitiles/dev/BUILD
@@ -9,13 +9,13 @@
         "//lib:guava",
         "//lib:guava-failureaccess",
         "//lib:html-types",
-        "//lib:servlet-api_3_1",
+        "//lib:jgit",
+        "//lib:jgit-servlet",
+        "//lib:servlet-api",
+        "//lib:slf4j-api",
+        "//lib:slf4j-simple",
         "//lib/jetty:server",
         "//lib/jetty:servlet",
-        "//lib/jgit",
-        "//lib/jgit:jgit-servlet",
-        "//lib/slf4j:slf4j-api",
-        "//lib/slf4j:slf4j-simple",
         "//lib/soy",
     ],
 )
diff --git a/java/com/google/gitiles/dev/DevServer.java b/java/com/google/gitiles/dev/DevServer.java
index 5a8fed5..028edd6 100644
--- a/java/com/google/gitiles/dev/DevServer.java
+++ b/java/com/google/gitiles/dev/DevServer.java
@@ -19,7 +19,7 @@
 
 import com.google.common.base.Strings;
 import com.google.common.html.types.UncheckedConversions;
-import com.google.gitiles.BranchRedirectFilter;
+import com.google.gitiles.BranchRedirect;
 import com.google.gitiles.DebugRenderer;
 import com.google.gitiles.GitilesAccess;
 import com.google.gitiles.GitilesServlet;
@@ -140,7 +140,7 @@
     } else {
       servlet =
           new GitilesServlet(
-              cfg, renderer, null, null, null, null, null, null, null, new BranchRedirectFilter());
+              cfg, renderer, null, null, null, null, null, null, null, new BranchRedirect());
     }
 
     ServletContextHandler handler = new ServletContextHandler();
diff --git a/javatests/com/google/gitiles/BUILD b/javatests/com/google/gitiles/BUILD
index 4021114..5469beb 100644
--- a/javatests/com/google/gitiles/BUILD
+++ b/javatests/com/google/gitiles/BUILD
@@ -5,8 +5,8 @@
     "//lib:gson",
     "//lib:guava",
     "//lib:guava-failureaccess",
-    "//lib/jgit:jgit",
-    "//lib/jgit:jgit-servlet",
+    "//lib:jgit",
+    "//lib:jgit-servlet",
     "//lib/soy:soy",
 ]
 
@@ -21,20 +21,20 @@
         "//lib:jsr305",
         "//lib:servlet-api_2_5",
         "//lib/truth",
-        "//lib/jgit:junit",
+        "//lib:jgit-junit",
         "//lib/junit",
     ],
 )
 
 junit_tests(
     name = "servlet_tests",
+    size = "small",
     srcs = glob(
         [
             "**/*Test.java",
         ],
         exclude = ["**/ServletTest.java"],
     ),
-    size = "small",
     visibility = ["//visibility:public"],
     runtime_deps = ["//lib/junit:hamcrest-core"],
     deps = DEPS + [
@@ -42,7 +42,7 @@
         ":testutil",
         "//lib:servlet-api_2_5",
         "//lib/truth",
-        "//lib/jgit:junit",
+        "//lib:jgit-junit",
         "//lib/junit",
     ],
 )
diff --git a/javatests/com/google/gitiles/BranchRedirectFilterTest.java b/javatests/com/google/gitiles/BranchRedirectTest.java
similarity index 69%
rename from javatests/com/google/gitiles/BranchRedirectFilterTest.java
rename to javatests/com/google/gitiles/BranchRedirectTest.java
index 76c6198..b9e4890 100644
--- a/javatests/com/google/gitiles/BranchRedirectFilterTest.java
+++ b/javatests/com/google/gitiles/BranchRedirectTest.java
@@ -15,7 +15,6 @@
 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;
 
@@ -31,14 +30,13 @@
 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 {
+public class BranchRedirectTest {
   private static final String MASTER = "refs/heads/master";
   private static final String MAIN = "refs/heads/main";
   private static final String DEVELOP = "refs/heads/develop";
@@ -54,8 +52,8 @@
   @Before
   public void setUp() throws Exception {
     repo = new TestRepository<>(new InMemoryRepository(new DfsRepositoryDescription("repo")));
-    BranchRedirectFilter branchRedirectFilter =
-        new BranchRedirectFilter() {
+    BranchRedirect branchRedirect =
+        new BranchRedirect() {
           @Override
           protected Optional<String> getRedirectBranch(Repository repo, String sourceBranch) {
             if (MASTER.equals(toFullBranchName(sourceBranch))) {
@@ -67,7 +65,7 @@
             return Optional.empty();
           }
         };
-    servlet = TestGitilesServlet.create(repo, new GitwebRedirectFilter(), branchRedirectFilter);
+    servlet = TestGitilesServlet.create(repo, new GitwebRedirectFilter(), branchRedirect);
   }
 
   @Test
@@ -84,48 +82,52 @@
 
   @Test
   public void show_withRedirect() throws Exception {
-    repo.branch(MASTER).commit().add("foo", "contents").create();
+    RevCommit master = repo.branch(MASTER).commit().add("foo", "contents").create();
+    repo.branch(MAIN).commit().parent(master).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");
+    assertThat(res.getActualBodyString()).contains("repo/+/refs/heads/main/foo");
+    assertThat(res.getActualBodyString()).doesNotContain("repo/+/refs/heads/master/foo");
   }
 
   @Test
   public void show_withRedirect_onDefaultFormatType() throws Exception {
-    repo.branch(MASTER).commit().add("foo", "contents").create();
+    RevCommit master = repo.branch(MASTER).commit().add("foo", "contents").create();
+    repo.branch(MAIN).commit().parent(master).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");
+    assertThat(res.getStatus()).isEqualTo(SC_OK);
+    assertThat(res.getActualBodyString()).contains("repo/+/refs/heads/main/foo");
+    assertThat(res.getActualBodyString()).doesNotContain("repo/+/refs/heads/master/foo");
   }
 
   @Test
   public void show_withRedirect_usingShortRefInUrl() throws Exception {
-    repo.branch(MASTER).commit().add("foo", "contents").create();
+    RevCommit master = repo.branch(MASTER).commit().add("foo", "contents").create();
+    repo.branch(MAIN).commit().parent(master).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");
+    assertThat(res.getStatus()).isEqualTo(SC_OK);
+    assertThat(res.getActualBodyString()).contains("repo/+/refs/heads/main/foo");
+    assertThat(res.getActualBodyString()).doesNotContain("repo/+/master/foo");
   }
 
   @Test
   public void show_onAutomationRequest() throws Exception {
-    repo.branch(MASTER).commit().add("foo", "contents").create();
+    RevCommit master = repo.branch(MASTER).commit().add("foo", "contents").create();
+    repo.branch(MAIN).commit().parent(master).create();
 
     String path = "/repo/+/refs/heads/master/foo";
     FakeHttpServletRequest req = newHttpRequest(path, ORIGIN, QUERY_STRING_JSON);
@@ -133,12 +135,15 @@
 
     servlet.service(req, res);
     assertThat(res.getStatus()).isEqualTo(SC_OK);
+    assertThat(res.getActualBodyString()).contains("\"revision\": \"refs/heads/master\"");
+    assertThat(res.getActualBodyString()).contains("\"path\": \"foo\"");
   }
 
   @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();
+    repo.branch(MAIN).commit().parent(parent).create();
 
     String path = "/repo/+/refs/heads/master^";
     FakeHttpServletRequest req = newHttpRequest(path, ORIGIN, QUERY_STRING_HTML);
@@ -153,7 +158,8 @@
 
   @Test
   public void diff_withRedirect_onSingleBranch() throws Exception {
-    repo.branch(MASTER).commit().add("foo", "contents").create();
+    RevCommit master = repo.branch(MASTER).commit().add("foo", "contents").create();
+    repo.branch(MAIN).commit().parent(master).create();
     repo.branch(DEVELOP).commit().add("foo", "contents").create();
 
     String path = "/repo/+/refs/heads/master..refs/heads/develop";
@@ -161,107 +167,108 @@
     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");
+    assertThat(res.getStatus()).isEqualTo(SC_OK);
+    assertThat(res.getActualBodyString())
+        .contains("/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();
+    RevCommit master = repo.branch(MASTER).commit().add("foo", "contents").create();
+    repo.branch(MAIN).commit().parent(master).create();
+    RevCommit foo = repo.branch(FOO).commit().add("foo", "contents").create();
+    repo.branch(BAR).commit().parent(foo).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");
+    assertThat(res.getStatus()).isEqualTo(SC_OK);
+    assertThat(res.getActualBodyString())
+        .contains("/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();
+    RevCommit master = repo.branch(MASTER).commit().add("foo", "contents").create();
+    repo.branch(MAIN).commit().parent(master).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");
+    assertThat(res.getStatus()).isEqualTo(SC_OK);
+    assertThat(res.getActualBodyString()).contains("/b/repo/+/refs/heads/main%5E%21/?format=html");
   }
 
   @Test
   public void log_withRedirect() throws Exception {
     repo.branch(MASTER).commit().add("foo", "contents").create();
+    RevCommit main = repo.branch(MAIN).commit().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");
+    assertThat(res.getStatus()).isEqualTo(SC_OK);
+    assertThat(res.getActualBodyString()).contains("Log - refs/heads/main");
+    assertThat(res.getActualBodyString()).contains("/b/repo/+/" + main.toObjectId().getName());
   }
 
   @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();
+    RevCommit master = repo.branch(MASTER).commit().add("bar", "contents").parent(parent2).create();
+    repo.branch(MAIN).commit().parent(master).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");
+    assertThat(res.getStatus()).isEqualTo(SC_OK);
+    assertThat(res.getActualBodyString())
+        .contains("/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();
+    RevCommit master = repo.branch(MASTER).commit().add("bar", "contents").parent(parent2).create();
+    repo.branch(MAIN).commit().parent(master).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");
+    assertThat(res.getStatus()).isEqualTo(SC_OK);
+    assertThat(res.getActualBodyString()).contains("/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();
+    RevCommit master = repo.branch(MASTER).commit().add("bar", "contents").parent(parent2).create();
+    repo.branch(MAIN).commit().parent(master).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");
+    assertThat(res.getStatus()).isEqualTo(SC_OK);
+    assertThat(res.getActualBodyString())
+        .contains("/b/repo/+/refs/heads/main%7E2..refs/heads/main/?format=html");
   }
 
   private static String toFullBranchName(String sourceBranch) {
diff --git a/javatests/com/google/gitiles/LogServletTest.java b/javatests/com/google/gitiles/LogServletTest.java
index 7232439..4ef7a4d 100644
--- a/javatests/com/google/gitiles/LogServletTest.java
+++ b/javatests/com/google/gitiles/LogServletTest.java
@@ -80,6 +80,24 @@
   }
 
   @Test
+  public void topoKeepBranchTogetherLog() throws Exception {
+    RevCommit a = repo.update("master", repo.commit().add("foo", "foo\n"));
+    RevCommit b1 = repo.update("master", repo.commit().parent(a).add("foo", "foo3\n"));
+    RevCommit c = repo.update("master", repo.commit().parent(a).add("foo", "foo2\n"));
+    RevCommit b2 = repo.update("master", repo.commit().parent(b1).add("foo", "foo4\n"));
+    RevCommit d = repo.update("master", repo.commit().parent(c).parent(b2).add("foo", "foo5\n"));
+
+    Log response = buildJson(LOG, "/repo/+log/master", "topo-order");
+    assertThat(response.log).hasSize(5);
+
+    verifyJsonCommit(response.log.get(0), d);
+    verifyJsonCommit(response.log.get(1), b2);
+    verifyJsonCommit(response.log.get(2), b1);
+    verifyJsonCommit(response.log.get(3), c);
+    verifyJsonCommit(response.log.get(4), a);
+  }
+
+  @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/PathServletTest.java b/javatests/com/google/gitiles/PathServletTest.java
index a32de9f..68b1e3e 100644
--- a/javatests/com/google/gitiles/PathServletTest.java
+++ b/javatests/com/google/gitiles/PathServletTest.java
@@ -64,8 +64,8 @@
     assertThat(data).containsEntry("type", "TREE");
     List<Map<String, ?>> entries = getTreeEntries(data);
     assertThat(entries).hasSize(2);
-    assertThat(entries.get(0).get("name")).isEqualTo("baz");
-    assertThat(entries.get(1).get("name")).isEqualTo("foo/");
+    assertThat(entries.get(0).get("name")).isEqualTo("foo/");
+    assertThat(entries.get(1).get("name")).isEqualTo("baz");
 
     data = buildData("/repo/+/master/foo");
     assertThat(data).containsEntry("type", "TREE");
diff --git a/javatests/com/google/gitiles/RevisionParserTest.java b/javatests/com/google/gitiles/RevisionParserTest.java
index 16e76a7..b8ed94d 100644
--- a/javatests/com/google/gitiles/RevisionParserTest.java
+++ b/javatests/com/google/gitiles/RevisionParserTest.java
@@ -46,7 +46,8 @@
         new RevisionParser(
             repo.getRepository(),
             new TestGitilesAccess(repo.getRepository()).forRequest(null),
-            new VisibilityCache(CacheBuilder.newBuilder().maximumSize(0)));
+            new VisibilityCache(CacheBuilder.newBuilder().maximumSize(0)),
+            new BranchRedirect());
   }
 
   @Test
diff --git a/javatests/com/google/gitiles/TestGitilesServlet.java b/javatests/com/google/gitiles/TestGitilesServlet.java
index a35e76f..036707c 100644
--- a/javatests/com/google/gitiles/TestGitilesServlet.java
+++ b/javatests/com/google/gitiles/TestGitilesServlet.java
@@ -31,17 +31,17 @@
 
 /** Static utility methods for creating {@link GitilesServlet}s for testing. */
 public class TestGitilesServlet {
-  /** @see #create(TestRepository,GitwebRedirectFilter,BranchRedirectFilter) */
+  /** @see #create(TestRepository,GitwebRedirectFilter, BranchRedirect) */
   public static GitilesServlet create(final TestRepository<DfsRepository> repo)
       throws ServletException {
-    return create(repo, new GitwebRedirectFilter(), new BranchRedirectFilter());
+    return create(repo, new GitwebRedirectFilter(), new BranchRedirect());
   }
 
-  /** @see #create(TestRepository,GitwebRedirectFilter,BranchRedirectFilter) */
+  /** @see #create(TestRepository,GitwebRedirectFilter, BranchRedirect) */
   public static GitilesServlet create(
       final TestRepository<DfsRepository> repo, GitwebRedirectFilter gitwebRedirect)
       throws ServletException {
-    return create(repo, gitwebRedirect, new BranchRedirectFilter());
+    return create(repo, gitwebRedirect, new BranchRedirect());
   }
 
   /**
@@ -61,7 +61,7 @@
   public static GitilesServlet create(
       final TestRepository<DfsRepository> repo,
       GitwebRedirectFilter gitwebRedirect,
-      BranchRedirectFilter branchRedirect)
+      BranchRedirect branchRedirect)
       throws ServletException {
     final String repoName = repo.getRepository().getDescription().getRepositoryName();
     GitilesServlet servlet =
diff --git a/javatests/com/google/gitiles/TestViewFilter.java b/javatests/com/google/gitiles/TestViewFilter.java
index 4f67efc..6b6a006 100644
--- a/javatests/com/google/gitiles/TestViewFilter.java
+++ b/javatests/com/google/gitiles/TestViewFilter.java
@@ -58,14 +58,18 @@
     }
   }
 
-  public static Result service(TestRepository<? extends DfsRepository> repo, String pathAndQuery)
+  public static Result service(
+      TestRepository<? extends DfsRepository> repo,
+      String pathAndQuery,
+      BranchRedirect branchRedirect)
       throws IOException, ServletException {
     TestServlet servlet = new TestServlet();
     ViewFilter vf =
         new ViewFilter(
             new TestGitilesAccess(repo.getRepository()),
             TestGitilesUrls.URLS,
-            new VisibilityCache());
+            new VisibilityCache(),
+            branchRedirect);
     MetaFilter mf = new MetaFilter();
 
     for (Pattern p : ImmutableList.of(ROOT_REGEX, REPO_REGEX, REPO_PATH_REGEX)) {
diff --git a/javatests/com/google/gitiles/TreeSoyDataTest.java b/javatests/com/google/gitiles/TreeSoyDataTest.java
index 98d416a..75ae771 100644
--- a/javatests/com/google/gitiles/TreeSoyDataTest.java
+++ b/javatests/com/google/gitiles/TreeSoyDataTest.java
@@ -17,8 +17,11 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gitiles.TreeSoyData.getTargetDisplayName;
 import static com.google.gitiles.TreeSoyData.resolveTargetUrl;
+import static com.google.gitiles.TreeSoyData.sortByTypeAlpha;
 
 import com.google.common.base.Strings;
+import java.util.HashMap;
+import java.util.Map;
 import org.eclipse.jgit.lib.ObjectId;
 import org.junit.Test;
 import org.junit.runner.RunWith;
@@ -64,4 +67,47 @@
     assertThat(resolveTargetUrl(view, "../../../../")).isNull();
     assertThat(resolveTargetUrl(view, "../../a/../../..")).isNull();
   }
+
+  @Test
+  public void sortByTypeSortsCorrect() throws Exception {
+    Map<String, String> m1 = new HashMap<>();
+    Map<String, String> m2 = new HashMap<>();
+    Map<String, String> m3 = new HashMap<>();
+    Map<String, String> m4 = new HashMap<>();
+    Map<String, String> m5 = new HashMap<>();
+    Map<String, String> m6 = new HashMap<>();
+    m1.put("type", "TREE");
+    m1.put("name", "aa");
+    m2.put("type", "TREE");
+    m2.put("name", "BB");
+    m3.put("type", "SYMLINK");
+    m4.put("type", "REGULAR_FILE");
+    m5.put("type", "GITLINK");
+    m6.put("type", "TREE");
+    m6.put("name", "AA");
+    assertThat(sortByTypeAlpha(m1, m2)).isEqualTo(-1);
+    assertThat(sortByTypeAlpha(m2, m3)).isEqualTo(-1);
+    assertThat(sortByTypeAlpha(m3, m4)).isEqualTo(-1);
+    assertThat(sortByTypeAlpha(m4, m1)).isEqualTo(1);
+    assertThat(sortByTypeAlpha(m1, m4)).isEqualTo(-1);
+    assertThat(sortByTypeAlpha(m5, m2)).isEqualTo(1);
+    assertThat(sortByTypeAlpha(m2, m5)).isEqualTo(-1);
+    assertThat(sortByTypeAlpha(m1, m6)).isEqualTo(0);
+    assertThat(sortByTypeAlpha(m2, m1)).isEqualTo(1);
+  }
+
+  @Test
+  public void sortByShortestPathFirst() throws Exception {
+    Map<String, String> p1 = new HashMap<>();
+    Map<String, String> p2 = new HashMap<>();
+    Map<String, String> p3 = new HashMap<>();
+    p1.put("type", "TREE");
+    p1.put("name", "short/");
+    p2.put("type", "TREE");
+    p2.put("name", "shortpath/");
+    p3.put("type", "TREE");
+    p3.put("name", "short.path/");
+    assertThat(sortByTypeAlpha(p1, p2)).isLessThan(0);
+    assertThat(sortByTypeAlpha(p1, p3)).isLessThan(0);
+  }
 }
diff --git a/javatests/com/google/gitiles/ViewFilterTest.java b/javatests/com/google/gitiles/ViewFilterTest.java
index 66cd5d8..fb001cd 100644
--- a/javatests/com/google/gitiles/ViewFilterTest.java
+++ b/javatests/com/google/gitiles/ViewFilterTest.java
@@ -21,11 +21,14 @@
 import com.google.common.net.HttpHeaders;
 import com.google.gitiles.GitilesView.Type;
 import java.io.IOException;
+import java.util.Optional;
 import javax.servlet.ServletException;
 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.Test;
@@ -53,7 +56,7 @@
   @Test
   public void autoCommand() throws Exception {
     RevCommit parent = repo.commit().create();
-    RevCommit master = repo.branch("refs/heads/master").commit().parent(parent).create();
+    RevCommit master = repo.branch(MASTER).commit().parent(parent).create();
     String hex = master.name();
     String hexBranch = hex.substring(0, 10);
     repo.branch(hexBranch).commit().create();
@@ -157,7 +160,7 @@
 
   @Test
   public void showBranches() throws Exception {
-    RevCommit master = repo.branch("refs/heads/master").commit().create();
+    RevCommit master = repo.branch(MASTER).commit().create();
     RevCommit stable = repo.branch("refs/heads/stable").commit().create();
     GitilesView view;
 
@@ -175,7 +178,7 @@
 
     view = getView("/repo/+show/refs/heads/master");
     assertThat(view.getType()).isEqualTo(Type.REVISION);
-    assertThat(view.getRevision().getName()).isEqualTo("refs/heads/master");
+    assertThat(view.getRevision().getName()).isEqualTo(MASTER);
     assertThat(view.getRevision().getId()).isEqualTo(master);
     assertThat(view.getPathPart()).isNull();
 
@@ -227,7 +230,7 @@
 
   @Test
   public void path() throws Exception {
-    RevCommit master = repo.branch("refs/heads/master").commit().create();
+    RevCommit master = repo.branch(MASTER).commit().create();
     repo.branch("refs/heads/stable").commit().create();
     GitilesView view;
 
@@ -257,7 +260,7 @@
 
   @Test
   public void doc() throws Exception {
-    RevCommit master = repo.branch("refs/heads/master").commit().create();
+    RevCommit master = repo.branch(MASTER).commit().create();
     repo.branch("refs/heads/stable").commit().create();
     GitilesView view;
 
@@ -287,7 +290,7 @@
 
   @Test
   public void multipleSlashes() throws Exception {
-    repo.branch("refs/heads/master").commit().create();
+    repo.branch(MASTER).commit().create();
     assertThat(getView("//").getType()).isEqualTo(Type.HOST_INDEX);
     assertThat(getView("//repo").getType()).isEqualTo(Type.REPOSITORY_INDEX);
     assertThat(getView("//repo//").getType()).isEqualTo(Type.REPOSITORY_INDEX);
@@ -301,7 +304,7 @@
   @Test
   public void diff() throws Exception {
     RevCommit parent = repo.commit().create();
-    RevCommit master = repo.branch("refs/heads/master").commit().parent(parent).create();
+    RevCommit master = repo.branch(MASTER).commit().parent(parent).create();
     GitilesView view;
 
     view = getView("/repo/+diff/master^..master");
@@ -330,7 +333,7 @@
 
     view = getView("/repo/+diff/refs/heads/master^..refs/heads/master");
     assertThat(view.getType()).isEqualTo(Type.DIFF);
-    assertThat(view.getRevision().getName()).isEqualTo("refs/heads/master");
+    assertThat(view.getRevision().getName()).isEqualTo(MASTER);
     assertThat(view.getRevision().getId()).isEqualTo(master);
     assertThat(view.getOldRevision().getName()).isEqualTo("refs/heads/master^");
     assertThat(view.getOldRevision().getId()).isEqualTo(parent);
@@ -339,7 +342,7 @@
 
   @Test
   public void diffAgainstEmptyCommit() throws Exception {
-    RevCommit master = repo.branch("refs/heads/master").commit().create();
+    RevCommit master = repo.branch(MASTER).commit().create();
     GitilesView view = getView("/repo/+diff/master^!");
     assertThat(view.getType()).isEqualTo(Type.DIFF);
     assertThat(view.getRevision().getName()).isEqualTo("master");
@@ -351,7 +354,7 @@
   @Test
   public void log() throws Exception {
     RevCommit parent = repo.commit().create();
-    RevCommit master = repo.branch("refs/heads/master").commit().parent(parent).create();
+    RevCommit master = repo.branch(MASTER).commit().parent(parent).create();
     GitilesView view;
 
     view = getView("/repo/+log");
@@ -411,7 +414,7 @@
 
     view = getView("/repo/+log/refs/heads/master^..refs/heads/master");
     assertThat(view.getType()).isEqualTo(Type.LOG);
-    assertThat(view.getRevision().getName()).isEqualTo("refs/heads/master");
+    assertThat(view.getRevision().getName()).isEqualTo(MASTER);
     assertThat(view.getRevision().getId()).isEqualTo(master);
     assertThat(view.getOldRevision().getName()).isEqualTo("refs/heads/master^");
     assertThat(view.getOldRevision().getId()).isEqualTo(parent);
@@ -420,7 +423,7 @@
 
   @Test
   public void archive() throws Exception {
-    RevCommit master = repo.branch("refs/heads/master").commit().create();
+    RevCommit master = repo.branch(MASTER).commit().create();
     repo.branch("refs/heads/branch").commit().create();
     GitilesView view;
 
@@ -465,7 +468,7 @@
 
   @Test
   public void blame() throws Exception {
-    RevCommit master = repo.branch("refs/heads/master").commit().create();
+    RevCommit master = repo.branch(MASTER).commit().create();
     repo.branch("refs/heads/branch").commit().create();
     GitilesView view;
 
@@ -487,7 +490,7 @@
   @Test
   public void testNormalizeParents() throws Exception {
     RevCommit parent = repo.commit().create();
-    RevCommit master = repo.branch("refs/heads/master").commit().parent(parent).create();
+    RevCommit master = repo.branch(MASTER).commit().parent(parent).create();
     GitilesView view;
 
     assertThat(getView("/repo/+/master").toUrl()).isEqualTo("/b/repo/+/master");
@@ -504,18 +507,208 @@
     assertThat(view.getOldRevision().getName()).isEqualTo("master^");
   }
 
+  private static final String MASTER = "refs/heads/master";
+  private static final String MAIN = "refs/heads/main";
+
+  @Test
+  public void autoCommand_branchRedirect() throws Exception {
+    RevCommit parent = repo.commit().create();
+    RevCommit master = repo.branch(MASTER).commit().parent(parent).create();
+    RevCommit main = repo.branch(MAIN).commit().parent(parent).create();
+    RevCommit someBranch =
+        repo.branch("refs/heads/some@branch")
+            .commit()
+            .parent(main)
+            .add("README", "This is a test README")
+            .create();
+    repo.branch("refs/heads/another@level").commit().parent(someBranch).create();
+
+    String hex = master.name();
+    String hexBranch = hex.substring(0, 10);
+    repo.branch(hexBranch).commit().create();
+
+    BranchRedirect branchRedirect =
+        new BranchRedirect() {
+          @Override
+          protected Optional<String> getRedirectBranch(Repository repo, String sourceBranch) {
+            if (MASTER.equals(toFullBranchName(sourceBranch))) {
+              return Optional.of(MAIN);
+            }
+            if ("refs/heads/some@branch".equals(toFullBranchName(sourceBranch))) {
+              return Optional.of(MAIN);
+            }
+            return Optional.empty();
+          }
+        };
+
+    GitilesView view = getView("/repo/+/master", branchRedirect);
+    assertThat(view.getType()).isEqualTo(Type.REVISION);
+    assertThat(view.getRevision().getName()).isEqualTo(MAIN);
+    assertThat(view.getRevision().getId()).isEqualTo(main);
+
+    view = getView("/repo/+/master/index.c", branchRedirect);
+    assertThat(view.getType()).isEqualTo(Type.PATH);
+    assertThat(view.getPathPart()).isEqualTo("index.c");
+    assertThat(view.getRevision().getName()).isEqualTo(MAIN);
+    assertThat(view.getRevision().getId()).isEqualTo(main);
+
+    view = getView("/repo/+/some@branch", branchRedirect);
+    assertThat(view.getType()).isEqualTo(Type.REVISION);
+    assertThat(view.getRevision().getName()).isEqualTo(MAIN);
+    assertThat(view.getRevision().getId()).isEqualTo(main);
+
+    view = getView("/repo/+/master/master", branchRedirect);
+    assertThat(view.getType()).isEqualTo(Type.PATH);
+    assertThat(view.getPathPart()).isEqualTo("master");
+    assertThat(view.getRevision().getName()).isEqualTo(MAIN);
+    assertThat(view.getRevision().getId()).isEqualTo(main);
+
+    FakeHttpServletResponse response = getResponse("/repo/+/master^1", branchRedirect);
+    assertThat(response.getHeader(HttpHeaders.LOCATION))
+        .contains("/b/repo/+/" + parent.toObjectId().name());
+
+    response = getResponse("/repo/+/master~1", branchRedirect);
+    assertThat(response.getHeader(HttpHeaders.LOCATION))
+        .contains("/b/repo/+/" + parent.toObjectId().name());
+
+    response = getResponse("/repo/+/another@level^1~2", branchRedirect);
+    assertThat(response.getHeader(HttpHeaders.LOCATION))
+        .contains("/b/repo/+/" + parent.toObjectId().name());
+
+    assertThrows(
+        GitilesRequestFailureException.class,
+        () -> getView("/repo/+/some@branch:README", branchRedirect));
+
+    assertThrows(
+        GitilesRequestFailureException.class, () -> getView("/repo/+/master@{1}", branchRedirect));
+  }
+
+  @Test
+  public void diff_branchRedirect() throws Exception {
+    RevCommit parent = repo.commit().create();
+    repo.branch(MASTER).commit().parent(parent).create();
+    RevCommit main = repo.branch(MAIN).commit().parent(parent).create();
+    BranchRedirect branchRedirect =
+        new BranchRedirect() {
+          @Override
+          protected Optional<String> getRedirectBranch(Repository repo, String sourceBranch) {
+            if (MASTER.equals(toFullBranchName(sourceBranch))) {
+              return Optional.of(MAIN);
+            }
+            return Optional.empty();
+          }
+        };
+
+    GitilesView view;
+
+    view = getView("/repo/+diff/master^..master", branchRedirect);
+    assertThat(view.getType()).isEqualTo(Type.DIFF);
+    assertThat(view.getRevision().getName()).isEqualTo(MAIN);
+    assertThat(view.getRevision().getId()).isEqualTo(main);
+    assertThat(view.getOldRevision().getName()).isEqualTo("refs/heads/main^");
+    assertThat(view.getOldRevision().getId()).isEqualTo(parent);
+    assertThat(view.getPathPart()).isEmpty();
+
+    view = getView("/repo/+diff/master..master^", branchRedirect);
+    assertThat(view.getType()).isEqualTo(Type.DIFF);
+    assertThat(view.getRevision().getName()).isEqualTo("refs/heads/main^");
+    assertThat(view.getRevision().getId()).isEqualTo(parent);
+    assertThat(view.getOldRevision().getName()).isEqualTo(MAIN);
+    assertThat(view.getOldRevision().getId()).isEqualTo(main);
+    assertThat(view.getPathPart()).isEmpty();
+
+    view = getView("/repo/+diff/master^..master/", branchRedirect);
+    assertThat(view.getType()).isEqualTo(Type.DIFF);
+    assertThat(view.getRevision().getName()).isEqualTo(MAIN);
+    assertThat(view.getRevision().getId()).isEqualTo(main);
+    assertThat(view.getOldRevision().getName()).isEqualTo("refs/heads/main^");
+    assertThat(view.getOldRevision().getId()).isEqualTo(parent);
+    assertThat(view.getPathPart()).isEmpty();
+
+    view = getView("/repo/+diff/master^..master/foo", branchRedirect);
+    assertThat(view.getType()).isEqualTo(Type.DIFF);
+    assertThat(view.getRevision().getName()).isEqualTo(MAIN);
+    assertThat(view.getRevision().getId()).isEqualTo(main);
+    assertThat(view.getOldRevision().getName()).isEqualTo("refs/heads/main^");
+    assertThat(view.getOldRevision().getId()).isEqualTo(parent);
+    assertThat(view.getPathPart()).isEqualTo("foo");
+
+    view = getView("/repo/+diff/refs/heads/master^..refs/heads/master", branchRedirect);
+    assertThat(view.getType()).isEqualTo(Type.DIFF);
+    assertThat(view.getRevision().getName()).isEqualTo(MAIN);
+    assertThat(view.getRevision().getId()).isEqualTo(main);
+    assertThat(view.getOldRevision().getName()).isEqualTo("refs/heads/main^");
+    assertThat(view.getOldRevision().getId()).isEqualTo(parent);
+    assertThat(view.getPathPart()).isEmpty();
+  }
+
+  @Test
+  public void path_branchRedirect() throws Exception {
+    RevCommit parent = repo.commit().create();
+    RevCommit main = repo.branch(MAIN).commit().parent(parent).create();
+    repo.branch(MASTER).commit().parent(parent).create();
+    BranchRedirect branchRedirect =
+        new BranchRedirect() {
+          @Override
+          protected Optional<String> getRedirectBranch(Repository repo, String sourceBranch) {
+            if (MASTER.equals(toFullBranchName(sourceBranch))) {
+              return Optional.of(MAIN);
+            }
+            return Optional.empty();
+          }
+        };
+
+    repo.branch("refs/heads/stable").commit().create();
+    GitilesView view;
+
+    view = getView("/repo/+show/master/", branchRedirect);
+    assertThat(view.getRevision().getName()).isEqualTo(MAIN);
+    assertThat(view.getType()).isEqualTo(Type.PATH);
+    assertThat(view.getRevision().getId()).isEqualTo(main);
+    assertThat(view.getPathPart()).isEmpty();
+
+    view = getView("/repo/+show/master/foo", branchRedirect);
+    assertThat(view.getRevision().getName()).isEqualTo(MAIN);
+    assertThat(view.getType()).isEqualTo(Type.PATH);
+    assertThat(view.getRevision().getId()).isEqualTo(main);
+    assertThat(view.getPathPart()).isEqualTo("foo");
+  }
+
+  private static String toFullBranchName(String sourceBranch) {
+    if (sourceBranch.startsWith(Constants.R_REFS)) {
+      return sourceBranch;
+    }
+    return Constants.R_HEADS + sourceBranch;
+  }
+
   private String getRedirectUrl(String pathAndQuery) throws ServletException, IOException {
-    TestViewFilter.Result result = TestViewFilter.service(repo, pathAndQuery);
+    TestViewFilter.Result result = TestViewFilter.service(repo, pathAndQuery, new BranchRedirect());
     assertThat(result.getResponse().getStatus()).isEqualTo(302);
     return result.getResponse().getHeader(HttpHeaders.LOCATION);
   }
 
   private GitilesView getView(String pathAndQuery) throws ServletException, IOException {
-    TestViewFilter.Result result = TestViewFilter.service(repo, pathAndQuery);
+    TestViewFilter.Result result = TestViewFilter.service(repo, pathAndQuery, new BranchRedirect());
     FakeHttpServletResponse resp = result.getResponse();
     assertWithMessage("expected non-redirect status, got " + resp.getStatus())
         .that(resp.getStatus() < 300 || resp.getStatus() >= 400)
         .isTrue();
     return result.getView();
   }
+
+  private GitilesView getView(String pathAndQuery, BranchRedirect branchRedirect)
+      throws ServletException, IOException {
+    TestViewFilter.Result result = TestViewFilter.service(repo, pathAndQuery, branchRedirect);
+    FakeHttpServletResponse resp = result.getResponse();
+    assertWithMessage("expected non-redirect status, got " + resp.getStatus())
+        .that(resp.getStatus() < 300 || resp.getStatus() >= 400 || resp.getStatus() == 302)
+        .isTrue();
+    return result.getView();
+  }
+
+  private FakeHttpServletResponse getResponse(String pathAndQuery, BranchRedirect branchRedirect)
+      throws ServletException, IOException {
+    TestViewFilter.Result result = TestViewFilter.service(repo, pathAndQuery, branchRedirect);
+    return result.getResponse();
+  }
 }
diff --git a/lib/BUILD b/lib/BUILD
index a6f026f..0f9620e 100644
--- a/lib/BUILD
+++ b/lib/BUILD
@@ -18,7 +18,7 @@
     "html-types",
     "jsr305",
     "servlet-api_2_5",
-    "servlet-api_3_1",
+    "servlet-api",
     "gson",
     "guava",
     "guava-failureaccess",
@@ -29,3 +29,58 @@
     "ow2-asm-tree",
     "ow2-asm-util",
 ]]
+
+java_library(
+    name = "slf4j-api",
+    exports = ["@log-api//jar"],
+)
+
+java_library(
+    name = "slf4j-simple",
+    runtime_deps = [
+        ":slf4j-api",
+        "@slf4j-simple//jar",
+    ],
+)
+
+java_library(
+    name = "jgit",
+    visibility = ["//visibility:public"],
+    exports = ["@jgit//org.eclipse.jgit:jgit"],
+    runtime_deps = [
+        ":slf4j-api",
+        "@javaewah//jar",
+    ],
+)
+
+java_library(
+    name = "jgit-archive",
+    visibility = ["//visibility:public"],
+    exports = ["@jgit//org.eclipse.jgit.archive:jgit-archive"],
+    runtime_deps = [":jgit"],
+)
+
+java_library(
+    name = "jgit-junit",
+    testonly = True,
+    visibility = ["//visibility:public"],
+    exports = ["@jgit//org.eclipse.jgit.junit:junit"],
+    runtime_deps = [":jgit"],
+)
+
+java_library(
+    name = "jgit-servlet",
+    visibility = ["//visibility:public"],
+    exports = ["@jgit//org.eclipse.jgit.http.server:jgit-servlet"],
+    runtime_deps = [":jgit"],
+)
+
+java_library(
+    name = "tukaani-xz",
+    exports = ["@tukaani-xz//jar"],
+)
+
+java_library(
+    name = "commons-compress",
+    exports = ["@commons-compress//jar"],
+)
diff --git a/lib/jetty/BUILD b/lib/jetty/BUILD
index 4f58b3d..521fd22 100644
--- a/lib/jetty/BUILD
+++ b/lib/jetty/BUILD
@@ -8,7 +8,7 @@
     name = "servlet",
     exports = [
         ":security",
-        "//lib:servlet-api_3_1",  # Different from the rest of gitiles-server.
+        "//lib:servlet-api",  # Different from the rest of gitiles-server.
         "@servlet//jar",
     ],
 )
diff --git a/lib/jgit/BUILD b/lib/jgit/BUILD
deleted file mode 100644
index d813fd7..0000000
--- a/lib/jgit/BUILD
+++ /dev/null
@@ -1,44 +0,0 @@
-load("@rules_java//java:defs.bzl", "java_library")
-
-package(
-    default_visibility = ["//visibility:public"],
-)
-
-java_library(
-    name = "jgit-servlet",
-    exports = ["@jgit-servlet//jar"],
-)
-
-java_library(
-    name = "jgit",
-    exports = ["@jgit-lib//jar"],
-)
-
-java_library(
-    name = "jgit-archive",
-    exports = [
-        ":commons-compress",
-        ":jgit-archive_library",
-        ":tukaani-xz",
-    ],
-)
-
-java_library(
-    name = "tukaani-xz",
-    exports = ["@tukaani-xz//jar"],
-)
-
-java_library(
-    name = "commons-compress",
-    exports = ["@commons-compress//jar"],
-)
-
-java_library(
-    name = "jgit-archive_library",
-    exports = ["@jgit-archive//jar"],
-)
-
-java_library(
-    name = "junit",
-    exports = ["@jgit-junit//jar"],
-)
diff --git a/lib/slf4j/BUILD b/lib/slf4j/BUILD
deleted file mode 100644
index 2719aa8..0000000
--- a/lib/slf4j/BUILD
+++ /dev/null
@@ -1,18 +0,0 @@
-load("@rules_java//java:defs.bzl", "java_library")
-
-package(
-    default_visibility = ["//visibility:public"],
-)
-
-java_library(
-    name = "slf4j-api",
-    exports = ["@slf4j-api//jar"],
-)
-
-java_library(
-    name = "slf4j-simple",
-    runtime_deps = [
-        ":slf4j-api",
-        "@slf4j-simple//jar",
-    ],
-)
diff --git a/modules/jgit b/modules/jgit
new file mode 160000
index 0000000..1f062c6
--- /dev/null
+++ b/modules/jgit
@@ -0,0 +1 @@
+Subproject commit 1f062c64be839a66e9ddd7faa0040312ef8ea774
diff --git a/tools/BUILD b/tools/BUILD
index 5cfe48c..ec76f1b 100644
--- a/tools/BUILD
+++ b/tools/BUILD
@@ -1,14 +1,13 @@
 load(
     "@bazel_tools//tools/jdk:default_java_toolchain.bzl",
-    "JDK9_JVM_OPTS",
     "default_java_toolchain",
 )
 load("@rules_java//java:defs.bzl", "java_package_configuration")
 
 default_java_toolchain(
-    name = "error_prone_warnings_toolchain",
-    bootclasspath = ["@bazel_tools//tools/jdk:platformclasspath.jar"],
-    jvm_opts = JDK9_JVM_OPTS,
+    name = "error_prone_warnings_toolchain_java11",
+    source_version = "11",
+    target_version = "11",
     package_configuration = [
         ":error_prone",
     ],
diff --git a/tools/workspace-status.sh b/tools/workspace-status.sh
deleted file mode 100755
index 9cc40e9..0000000
--- a/tools/workspace-status.sh
+++ /dev/null
@@ -1,17 +0,0 @@
-#!/usr/bin/env bash
-
-# This script will be run by bazel when the build process starts to
-# generate key-value information that represents the status of the
-# workspace. The output should be like
-#
-# KEY1 VALUE1
-# KEY2 VALUE2
-#
-# If the script exits with non-zero code, it's considered as a failure
-# and the output will be discarded.
-
-function rev() {
-  cd $1; git describe --always --match "v[0-9].*" --dirty
-}
-
-echo STABLE_BUILD_GITILES_LABEL $(rev .)
diff --git a/tools/workspace_status.py b/tools/workspace_status.py
new file mode 100644
index 0000000..bd0689a
--- /dev/null
+++ b/tools/workspace_status.py
@@ -0,0 +1,46 @@
+#!/usr/bin/env python3
+
+# This script will be run by bazel when the build process starts to
+# generate key-value information that represents the status of the
+# workspace. The output should be like
+#
+# KEY1 VALUE1
+# KEY2 VALUE2
+#
+# If the script exits with non-zero code, it's considered as a failure
+# and the output will be discarded.
+
+from __future__ import print_function
+import os
+import subprocess
+import sys
+
+ROOT = os.path.abspath(__file__)
+while not os.path.exists(os.path.join(ROOT, 'WORKSPACE')):
+    ROOT = os.path.dirname(ROOT)
+CMD = ['git', 'describe', '--always', '--match', 'v[0-9].*', '--dirty']
+
+
+def revision(directory, parent):
+    try:
+        os.chdir(directory)
+        return subprocess.check_output(CMD).strip().decode("utf-8")
+    except OSError as err:
+        print('could not invoke git: %s' % err, file=sys.stderr)
+        sys.exit(1)
+    except subprocess.CalledProcessError as err:
+        # ignore "not a git repository error" to report unknown version
+        return None
+    finally:
+        os.chdir(parent)
+
+
+print("STABLE_BUILD_GITILES_LABEL %s" % revision(ROOT, ROOT))
+for kind in ['modules']:
+    kind_dir = os.path.join(ROOT, kind)
+    for d in os.listdir(kind_dir):
+        p = os.path.join(kind_dir, d)
+        if os.path.isdir(p):
+            v = revision(p, ROOT)
+            print('STABLE_BUILD_%s_LABEL %s' % (os.path.basename(p).upper(),
+                                                v if v else 'unknown'))