Merge "Fix merged email for direct pushes"
diff --git a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
index 1fa8de7..3c39ea1 100644
--- a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
+++ b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
@@ -1817,6 +1817,10 @@
     logger.atFinest().log(
         "Received REST request: %s %s (parameters: %s)",
         req.getMethod(), req.getRequestURI(), getParameterNames(req));
+    Optional.ofNullable(req.getHeader(X_GERRIT_DEADLINE))
+        .ifPresent(
+            clientProvidedDeadline ->
+                logger.atFine().log("%s = %s", X_GERRIT_DEADLINE, clientProvidedDeadline));
     logger.atFinest().log("Calling user: %s", globals.currentUser.get().getLoggableName());
     logger.atFinest().log(
         "Groups: %s", lazy(() -> globals.currentUser.get().getEffectiveGroups().getKnownGroups()));
diff --git a/java/com/google/gerrit/server/DeadlineChecker.java b/java/com/google/gerrit/server/DeadlineChecker.java
index 9ef5c4c..5662e50 100644
--- a/java/com/google/gerrit/server/DeadlineChecker.java
+++ b/java/com/google/gerrit/server/DeadlineChecker.java
@@ -170,6 +170,26 @@
     Optional<ServerDeadline> serverSideDeadline =
         getServerSideDeadline(deadlineConfigs, requestInfo);
     Optional<Long> clientedProvidedTimeout = parseTimeout(clientProvidedTimeoutValue);
+    logDeadlines(serverSideDeadline, clientedProvidedTimeout);
+
+    this.cancellationReason =
+        clientedProvidedTimeout.isPresent()
+            ? RequestStateProvider.Reason.CLIENT_PROVIDED_DEADLINE_EXCEEDED
+            : RequestStateProvider.Reason.SERVER_DEADLINE_EXCEEDED;
+    this.timeoutName =
+        clientedProvidedTimeout
+            .map(clientTimeout -> "client.timeout")
+            .orElse(
+                serverSideDeadline
+                    .map(serverDeadline -> serverDeadline.id() + ".timeout")
+                    .orElse("timeout"));
+    this.timeout =
+        clientedProvidedTimeout.orElse(serverSideDeadline.map(ServerDeadline::timeout).orElse(0L));
+    this.deadline = timeout > 0 ? Optional.of(start + timeout) : Optional.empty();
+  }
+
+  private void logDeadlines(
+      Optional<ServerDeadline> serverSideDeadline, Optional<Long> clientedProvidedTimeout) {
     if (serverSideDeadline.isPresent()) {
       if (clientedProvidedTimeout.isPresent()) {
         logger.atFine().log(
@@ -185,21 +205,11 @@
             TimeUnit.MILLISECONDS.convert(
                 serverSideDeadline.get().timeout(), TimeUnit.NANOSECONDS));
       }
+    } else if (clientedProvidedTimeout.isPresent()) {
+      logger.atFine().log(
+          "applying client provided deadline (timeout = %sms)",
+          TimeUnit.MILLISECONDS.convert(clientedProvidedTimeout.get(), TimeUnit.NANOSECONDS));
     }
-    this.cancellationReason =
-        clientedProvidedTimeout.isPresent()
-            ? RequestStateProvider.Reason.CLIENT_PROVIDED_DEADLINE_EXCEEDED
-            : RequestStateProvider.Reason.SERVER_DEADLINE_EXCEEDED;
-    this.timeoutName =
-        clientedProvidedTimeout
-            .map(clientTimeout -> "client.timeout")
-            .orElse(
-                serverSideDeadline
-                    .map(serverDeadline -> serverDeadline.id() + ".timeout")
-                    .orElse("timeout"));
-    this.timeout =
-        clientedProvidedTimeout.orElse(serverSideDeadline.map(ServerDeadline::timeout).orElse(0L));
-    this.deadline = timeout > 0 ? Optional.of(start + timeout) : Optional.empty();
   }
 
   private Optional<ServerDeadline> getServerSideDeadline(
diff --git a/java/com/google/gerrit/server/mail/send/ChangeEmail.java b/java/com/google/gerrit/server/mail/send/ChangeEmail.java
index 48ab397..1a2e150 100644
--- a/java/com/google/gerrit/server/mail/send/ChangeEmail.java
+++ b/java/com/google/gerrit/server/mail/send/ChangeEmail.java
@@ -41,11 +41,10 @@
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.mail.send.ProjectWatch.Watchers;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
-import com.google.gerrit.server.patch.PatchList;
-import com.google.gerrit.server.patch.PatchListEntry;
-import com.google.gerrit.server.patch.PatchListNotAvailableException;
-import com.google.gerrit.server.patch.PatchListObjectTooLargeException;
+import com.google.gerrit.server.patch.DiffNotAvailableException;
+import com.google.gerrit.server.patch.FilePathAdapter;
 import com.google.gerrit.server.patch.PatchSetInfoNotAvailableException;
+import com.google.gerrit.server.patch.filediff.FileDiffOutput;
 import com.google.gerrit.server.permissions.ChangePermission;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -66,6 +65,7 @@
 import org.apache.james.mime4j.dom.field.FieldName;
 import org.eclipse.jgit.diff.DiffFormatter;
 import org.eclipse.jgit.internal.JGitText;
+import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.util.RawParseUtils;
 import org.eclipse.jgit.util.TemporaryBuffer;
@@ -269,17 +269,23 @@
 
       if (patchSet != null) {
         detail.append("---\n");
-        PatchList patchList = getPatchList();
-        for (PatchListEntry p : patchList.getPatches()) {
-          if (Patch.isMagic(p.getNewName())) {
+        Map<String, FileDiffOutput> modifiedFiles = listModifiedFiles();
+        for (FileDiffOutput fileDiff : modifiedFiles.values()) {
+          if (fileDiff.newPath().isPresent() && Patch.isMagic(fileDiff.newPath().get())) {
             continue;
           }
           detail
-              .append(p.getChangeType().getCode())
+              .append(fileDiff.changeType().getCode())
               .append(" ")
-              .append(p.getNewName())
+              .append(
+                  FilePathAdapter.getNewPath(
+                      fileDiff.oldPath(), fileDiff.newPath(), fileDiff.changeType()))
               .append("\n");
         }
+        Integer insertions =
+            modifiedFiles.values().stream().map(FileDiffOutput::insertions).reduce(0, Integer::sum);
+        Integer deletions =
+            modifiedFiles.values().stream().map(FileDiffOutput::deletions).reduce(0, Integer::sum);
         detail.append(
             MessageFormat.format(
                 "" //
@@ -287,9 +293,9 @@
                     + "{1,choice,0#0 insertions|1#1 insertion|1<{1} insertions}(+), " //
                     + "{2,choice,0#0 deletions|1#1 deletion|1<{2} deletions}(-)" //
                     + "\n",
-                patchList.getPatches().size() - 1, //
-                patchList.getInsertions(), //
-                patchList.getDeletions()));
+                modifiedFiles.size() - 1, //
+                insertions, //
+                deletions));
         detail.append("\n");
       }
       return detail.toString();
@@ -300,7 +306,8 @@
   }
 
   /** Get the patch list corresponding to patch set patchSetId of this change. */
-  protected PatchList getPatchList(int patchSetId) throws PatchListNotAvailableException {
+  protected Map<String, FileDiffOutput> listModifiedFiles(int patchSetId)
+      throws DiffNotAvailableException {
     PatchSet ps;
     if (patchSetId == patchSet.number()) {
       ps = patchSet;
@@ -308,18 +315,20 @@
       try {
         ps = args.patchSetUtil.get(changeData.notes(), PatchSet.id(change.getId(), patchSetId));
       } catch (StorageException e) {
-        throw new PatchListNotAvailableException("Failed to get patchSet", e);
+        throw new DiffNotAvailableException("Failed to get patchSet", e);
       }
     }
-    return args.patchListCache.get(change, ps);
+    return args.diffOperations.listModifiedFilesAgainstParent(
+        change.getProject(), ps.commitId(), /* parentNum= */ 0);
   }
 
   /** Get the patch list corresponding to this patch set. */
-  protected PatchList getPatchList() throws PatchListNotAvailableException {
+  protected Map<String, FileDiffOutput> listModifiedFiles() throws DiffNotAvailableException {
     if (patchSet != null) {
-      return args.patchListCache.get(change, patchSet);
+      return args.diffOperations.listModifiedFilesAgainstParent(
+          change.getProject(), patchSet.commitId(), /* parentNum= */ 0);
     }
-    throw new PatchListNotAvailableException("no patchSet specified");
+    throw new DiffNotAvailableException("no patchSet specified");
   }
 
   /** Get the project entity the change is in; null if its been deleted. */
@@ -566,18 +575,15 @@
 
   /** Show patch set as unified difference. */
   public String getUnifiedDiff() {
-    PatchList patchList;
+    Map<String, FileDiffOutput> modifiedFiles;
     try {
-      patchList = getPatchList();
-      if (patchList.getOldId() == null) {
+      modifiedFiles = listModifiedFiles();
+      if (modifiedFiles.isEmpty()) {
         // Octopus merges are not well supported for diff output by Gerrit.
         // Currently these always have a null oldId in the PatchList.
         return "[Octopus merge; cannot be formatted as a diff.]\n";
       }
-    } catch (PatchListObjectTooLargeException e) {
-      logger.atWarning().log("Cannot format patch %s", e.getMessage());
-      return "";
-    } catch (PatchListNotAvailableException e) {
+    } catch (DiffNotAvailableException e) {
       logger.atSevere().withCause(e).log("Cannot format patch");
       return "";
     }
@@ -587,9 +593,11 @@
     try (DiffFormatter fmt = new DiffFormatter(buf)) {
       try (Repository git = args.server.openRepository(change.getProject())) {
         try {
+          ObjectId oldId = modifiedFiles.values().iterator().next().oldCommitId();
+          ObjectId newId = modifiedFiles.values().iterator().next().newCommitId();
           fmt.setRepository(git);
           fmt.setDetectRenames(true);
-          fmt.format(patchList.getOldId(), patchList.getNewId());
+          fmt.format(oldId, newId);
           return RawParseUtils.decode(buf.toByteArray());
         } catch (IOException e) {
           if (JGitText.get().inMemoryBufferLimitExceeded.equals(e.getMessage())) {
diff --git a/java/com/google/gerrit/server/mail/send/CommentSender.java b/java/com/google/gerrit/server/mail/send/CommentSender.java
index ac6c2f3..4593584 100644
--- a/java/com/google/gerrit/server/mail/send/CommentSender.java
+++ b/java/com/google/gerrit/server/mail/send/CommentSender.java
@@ -36,10 +36,9 @@
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.mail.receive.Protocol;
+import com.google.gerrit.server.patch.DiffNotAvailableException;
 import com.google.gerrit.server.patch.PatchFile;
-import com.google.gerrit.server.patch.PatchList;
-import com.google.gerrit.server.patch.PatchListNotAvailableException;
-import com.google.gerrit.server.patch.PatchListObjectTooLargeException;
+import com.google.gerrit.server.patch.filediff.FileDiffOutput;
 import com.google.gerrit.server.util.LabelVote;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
@@ -198,24 +197,24 @@
         currentGroup = new FileCommentGroup();
         currentGroup.filename = c.key.filename;
         currentGroup.patchSetId = c.key.patchSetId;
-        // Get the patch list:
-        PatchList patchList = null;
+        // Get the modified files:
+        Map<String, FileDiffOutput> modifiedFiles = null;
         try {
-          patchList = getPatchList(c.key.patchSetId);
-        } catch (PatchListObjectTooLargeException e) {
-          logger.atWarning().log("Failed to get patch list: %s", e.getMessage());
-        } catch (PatchListNotAvailableException e) {
-          logger.atSevere().withCause(e).log("Failed to get patch list");
+          modifiedFiles = listModifiedFiles(c.key.patchSetId);
+        } catch (DiffNotAvailableException e) {
+          logger.atSevere().withCause(e).log("Failed to get modified files");
         }
 
         groups.add(currentGroup);
-        if (patchList != null) {
+        if (modifiedFiles != null && !modifiedFiles.isEmpty()) {
           try {
-            currentGroup.fileData = new PatchFile(repo, patchList, c.key.filename);
+            currentGroup.fileData = new PatchFile(repo, modifiedFiles, c.key.filename);
           } catch (IOException e) {
             logger.atWarning().withCause(e).log(
                 "Cannot load %s from %s in %s",
-                c.key.filename, patchList.getNewId().name(), projectState.getName());
+                c.key.filename,
+                modifiedFiles.values().iterator().next().newCommitId().name(),
+                projectState.getName());
             currentGroup.fileData = null;
           }
         }
diff --git a/java/com/google/gerrit/server/mail/send/EmailArguments.java b/java/com/google/gerrit/server/mail/send/EmailArguments.java
index 735e34a..96effc1 100644
--- a/java/com/google/gerrit/server/mail/send/EmailArguments.java
+++ b/java/com/google/gerrit/server/mail/send/EmailArguments.java
@@ -35,7 +35,7 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.mail.EmailSettings;
 import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.patch.PatchListCache;
+import com.google.gerrit.server.patch.DiffOperations;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.project.ProjectCache;
@@ -72,7 +72,7 @@
   final PermissionBackend permissionBackend;
   final GroupBackend groupBackend;
   final AccountCache accountCache;
-  final PatchListCache patchListCache;
+  final DiffOperations diffOperations;
   final PatchSetUtil patchSetUtil;
   final ApprovalsUtil approvalsUtil;
   final Provider<FromAddressGenerator> fromAddressGenerator;
@@ -106,7 +106,7 @@
       PermissionBackend permissionBackend,
       GroupBackend groupBackend,
       AccountCache accountCache,
-      PatchListCache patchListCache,
+      DiffOperations diffOperations,
       PatchSetUtil patchSetUtil,
       ApprovalsUtil approvalsUtil,
       Provider<FromAddressGenerator> fromAddressGenerator,
@@ -137,7 +137,7 @@
     this.permissionBackend = permissionBackend;
     this.groupBackend = groupBackend;
     this.accountCache = accountCache;
-    this.patchListCache = patchListCache;
+    this.diffOperations = diffOperations;
     this.patchSetUtil = patchSetUtil;
     this.approvalsUtil = approvalsUtil;
     this.fromAddressGenerator = fromAddressGenerator;
diff --git a/java/com/google/gerrit/server/patch/PatchFile.java b/java/com/google/gerrit/server/patch/PatchFile.java
index ca5223d..3cc89f85 100644
--- a/java/com/google/gerrit/server/patch/PatchFile.java
+++ b/java/com/google/gerrit/server/patch/PatchFile.java
@@ -18,7 +18,9 @@
 
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.exceptions.NoSuchEntityException;
+import com.google.gerrit.server.patch.filediff.FileDiffOutput;
 import java.io.IOException;
+import java.util.Map;
 import org.eclipse.jgit.errors.CorruptObjectException;
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
 import org.eclipse.jgit.errors.MissingObjectException;
@@ -35,7 +37,7 @@
 /** State supporting processing of a single {@link Patch} instance. */
 public class PatchFile {
   private final Repository repo;
-  private final PatchListEntry entry;
+  private final FileDiffOutput diff;
   private final RevTree aTree;
   private final RevTree bTree;
 
@@ -51,21 +53,25 @@
   private Text a;
   private Text b;
 
-  public PatchFile(Repository repo, PatchList patchList, String fileName)
-      throws MissingObjectException, IncorrectObjectTypeException, IOException {
+  public PatchFile(Repository repo, Map<String, FileDiffOutput> modifiedFiles, String fileName)
+      throws IOException {
     this.repo = repo;
-    this.entry = patchList.get(fileName);
+    this.diff =
+        modifiedFiles.values().stream()
+            .filter(f -> f.newPath().isPresent() && f.newPath().get().equals(fileName))
+            .findFirst()
+            .orElse(FileDiffOutput.empty(fileName, ObjectId.zeroId(), ObjectId.zeroId()));
 
     try (ObjectReader reader = repo.newObjectReader();
         RevWalk rw = new RevWalk(reader)) {
-      final RevCommit bCommit = rw.parseCommit(patchList.getNewId());
+      final RevCommit bCommit = rw.parseCommit(diff.newCommitId());
 
       if (Patch.COMMIT_MSG.equals(fileName)) {
-        if (patchList.getComparisonType().isAgainstParentOrAutoMerge()) {
+        if (diff.comparisonType().isAgainstParentOrAutoMerge()) {
           a = Text.EMPTY;
         } else {
           // For the initial commit, we have an empty tree on Side A
-          RevObject object = rw.parseAny(patchList.getOldId());
+          RevObject object = rw.parseAny(diff.oldCommitId());
           a = object instanceof RevCommit ? Text.forCommit(reader, object) : Text.EMPTY;
         }
         b = Text.forCommit(reader, bCommit);
@@ -74,18 +80,18 @@
         bTree = null;
       } else if (Patch.MERGE_LIST.equals(fileName)) {
         // For the initial commit, we have an empty tree on Side A
-        RevObject object = rw.parseAny(patchList.getOldId());
+        RevObject object = rw.parseAny(diff.oldCommitId());
         a =
             object instanceof RevCommit
-                ? Text.forMergeList(patchList.getComparisonType(), reader, object)
+                ? Text.forMergeList(diff.comparisonType(), reader, object)
                 : Text.EMPTY;
-        b = Text.forMergeList(patchList.getComparisonType(), reader, bCommit);
+        b = Text.forMergeList(diff.comparisonType(), reader, bCommit);
 
         aTree = null;
         bTree = null;
       } else {
-        if (patchList.getOldId() != null) {
-          aTree = rw.parseTree(patchList.getOldId());
+        if (diff.oldCommitId() != null) {
+          aTree = rw.parseTree(diff.oldCommitId());
         } else {
           final RevCommit p = bCommit.getParent(0);
           rw.parseHeaders(p);
@@ -97,11 +103,11 @@
   }
 
   private String getOldName() {
-    String name = entry.getOldName();
+    String name = FilePathAdapter.getOldPath(diff.oldPath(), diff.changeType());
     if (name != null) {
       return name;
     }
-    return entry.getNewName();
+    return FilePathAdapter.getNewPath(diff.oldPath(), diff.newPath(), diff.changeType());
   }
 
   /**
@@ -123,7 +129,10 @@
 
       case 1:
         if (b == null) {
-          b = load(bTree, entry.getNewName());
+          b =
+              load(
+                  bTree,
+                  FilePathAdapter.getNewPath(diff.oldPath(), diff.newPath(), diff.changeType()));
         }
         return b.getString(line - 1);
 
diff --git a/java/com/google/gerrit/server/patch/filediff/FileDiffOutput.java b/java/com/google/gerrit/server/patch/filediff/FileDiffOutput.java
index 76f6eb7..242c1a4 100644
--- a/java/com/google/gerrit/server/patch/filediff/FileDiffOutput.java
+++ b/java/com/google/gerrit/server/patch/filediff/FileDiffOutput.java
@@ -37,7 +37,10 @@
 public abstract class FileDiffOutput implements Serializable {
   private static final long serialVersionUID = 1L;
 
-  /** The 20 bytes SHA-1 object ID of the old git commit used in the diff. */
+  /**
+   * The 20 bytes SHA-1 object ID of the old git commit used in the diff, or {@link
+   * ObjectId#zeroId()} if {@link #newCommitId()} was a root commit.
+   */
   public abstract ObjectId oldCommitId();
 
   /** The 20 bytes SHA-1 object ID of the new git commit used in the diff. */
diff --git a/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiff.java b/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiff.java
index a502a46..2f23c8c 100644
--- a/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiff.java
+++ b/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiff.java
@@ -34,6 +34,7 @@
 import org.eclipse.jgit.diff.DiffEntry;
 import org.eclipse.jgit.lib.AbbreviatedObjectId;
 import org.eclipse.jgit.lib.FileMode;
+import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.patch.FileHeader;
 
 /**
@@ -120,7 +121,10 @@
   /** The file name at the new git tree identified by {@link #newId()} */
   public abstract Optional<String> newPath();
 
-  /** The 20 bytes SHA-1 object ID of the old git tree of the diff. */
+  /**
+   * The 20 bytes SHA-1 object ID of the old git tree of the diff, or {@link ObjectId#zeroId()} if
+   * {@link #newId()} was a root git tree (i.e. has no parents).
+   */
   public abstract AbbreviatedObjectId oldId();
 
   /** The 20 bytes SHA-1 object ID of the new git tree of the diff. */
diff --git a/java/com/google/gerrit/server/project/SubmitRequirementsAdapter.java b/java/com/google/gerrit/server/project/SubmitRequirementsAdapter.java
index 5d8b99a..0383cdc 100644
--- a/java/com/google/gerrit/server/project/SubmitRequirementsAdapter.java
+++ b/java/com/google/gerrit/server/project/SubmitRequirementsAdapter.java
@@ -70,7 +70,7 @@
 
   private static List<SubmitRequirementResult> createFromCustomSubmitRecord(
       SubmitRecord record, ObjectId psCommitId) {
-    if (record.labels.isEmpty()) {
+    if (record.labels == null || record.labels.isEmpty()) {
       SubmitRequirement sr =
           SubmitRequirement.builder()
               .setName(record.ruleName)
diff --git a/java/com/google/gerrit/server/rules/PrologEnvironment.java b/java/com/google/gerrit/server/rules/PrologEnvironment.java
index 7d626da..bc0bb1a 100644
--- a/java/com/google/gerrit/server/rules/PrologEnvironment.java
+++ b/java/com/google/gerrit/server/rules/PrologEnvironment.java
@@ -22,7 +22,7 @@
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.PluginConfigFactory;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.patch.PatchListCache;
+import com.google.gerrit.server.patch.DiffOperations;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.project.ProjectCache;
@@ -173,7 +173,7 @@
     private final PermissionBackend permissionBackend;
     private final GitRepositoryManager repositoryManager;
     private final PluginConfigFactory pluginConfigFactory;
-    private final PatchListCache patchListCache;
+    private final DiffOperations diffOperations;
     private final PatchSetInfoFactory patchSetInfoFactory;
     private final IdentifiedUser.GenericFactory userFactory;
     private final Provider<AnonymousUser> anonymousUser;
@@ -188,7 +188,7 @@
         PermissionBackend permissionBackend,
         GitRepositoryManager repositoryManager,
         PluginConfigFactory pluginConfigFactory,
-        PatchListCache patchListCache,
+        DiffOperations diffOperations,
         PatchSetInfoFactory patchSetInfoFactory,
         IdentifiedUser.GenericFactory userFactory,
         Provider<AnonymousUser> anonymousUser,
@@ -199,7 +199,7 @@
       this.permissionBackend = permissionBackend;
       this.repositoryManager = repositoryManager;
       this.pluginConfigFactory = pluginConfigFactory;
-      this.patchListCache = patchListCache;
+      this.diffOperations = diffOperations;
       this.patchSetInfoFactory = patchSetInfoFactory;
       this.userFactory = userFactory;
       this.anonymousUser = anonymousUser;
@@ -237,8 +237,8 @@
       return pluginConfigFactory;
     }
 
-    public PatchListCache getPatchListCache() {
-      return patchListCache;
+    public DiffOperations getDiffOperations() {
+      return diffOperations;
     }
 
     public PatchSetInfoFactory getPatchSetInfoFactory() {
diff --git a/java/com/google/gerrit/server/rules/StoredValues.java b/java/com/google/gerrit/server/rules/StoredValues.java
index 1e08a24..1d10c1f 100644
--- a/java/com/google/gerrit/server/rules/StoredValues.java
+++ b/java/com/google/gerrit/server/rules/StoredValues.java
@@ -21,7 +21,6 @@
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
 import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PatchSetUtil;
@@ -30,10 +29,9 @@
 import com.google.gerrit.server.account.Emails;
 import com.google.gerrit.server.config.PluginConfigFactory;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.patch.PatchList;
-import com.google.gerrit.server.patch.PatchListCache;
-import com.google.gerrit.server.patch.PatchListKey;
-import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.gerrit.server.patch.DiffNotAvailableException;
+import com.google.gerrit.server.patch.DiffOperations;
+import com.google.gerrit.server.patch.filediff.FileDiffOutput;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -87,24 +85,27 @@
         }
       };
 
-  public static final StoredValue<PatchList> PATCH_LIST =
-      new StoredValue<PatchList>() {
+  public static final StoredValue<Map<String, FileDiffOutput>> DIFF_LIST =
+      new StoredValue<Map<String, FileDiffOutput>>() {
         @Override
-        public PatchList createValue(Prolog engine) {
+        public Map<String, FileDiffOutput> createValue(Prolog engine) {
           PrologEnvironment env = (PrologEnvironment) engine.control;
           PatchSet ps = getPatchSet(engine);
-          PatchListCache plCache = env.getArgs().getPatchListCache();
+          DiffOperations diffOperations = env.getArgs().getDiffOperations();
           Change change = getChange(engine);
           Project.NameKey project = change.getProject();
-          Whitespace ws = Whitespace.IGNORE_NONE;
-          PatchListKey plKey = PatchListKey.againstDefaultBase(ps.commitId(), ws);
-          PatchList patchList;
+          Map<String, FileDiffOutput> diffList;
           try {
-            patchList = plCache.get(plKey, project);
-          } catch (PatchListNotAvailableException e) {
-            throw new SystemException(String.format("Cannot create %s: %s", plKey, e.getMessage()));
+            diffList =
+                diffOperations.listModifiedFilesAgainstParent(
+                    project, ps.commitId(), /* parentNum= */ 0);
+          } catch (DiffNotAvailableException e) {
+            throw new SystemException(
+                String.format(
+                    "Cannot create modified files for project %s, commit Id %s: %s",
+                    project, ps.commitId(), e.getMessage()));
           }
-          return patchList;
+          return diffList;
         }
       };
 
diff --git a/java/gerrit/BUILD b/java/gerrit/BUILD
index db831b7..fea2696 100644
--- a/java/gerrit/BUILD
+++ b/java/gerrit/BUILD
@@ -5,6 +5,7 @@
     srcs = glob(["**/*.java"]),
     visibility = ["//visibility:public"],
     deps = [
+        "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/server",
diff --git a/java/gerrit/PRED_commit_edits_2.java b/java/gerrit/PRED_commit_edits_2.java
index 12e7086..6083010 100644
--- a/java/gerrit/PRED_commit_edits_2.java
+++ b/java/gerrit/PRED_commit_edits_2.java
@@ -14,10 +14,13 @@
 
 package gerrit;
 
+import com.google.common.collect.Iterables;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Patch;
-import com.google.gerrit.server.patch.PatchList;
-import com.google.gerrit.server.patch.PatchListEntry;
+import com.google.gerrit.server.patch.FilePathAdapter;
 import com.google.gerrit.server.patch.Text;
+import com.google.gerrit.server.patch.filediff.FileDiffOutput;
+import com.google.gerrit.server.patch.filediff.TaggedEdit;
 import com.google.gerrit.server.rules.StoredValues;
 import com.googlecode.prolog_cafe.exceptions.IllegalTypeException;
 import com.googlecode.prolog_cafe.exceptions.JavaException;
@@ -31,7 +34,9 @@
 import com.googlecode.prolog_cafe.lang.VariableTerm;
 import java.io.IOException;
 import java.util.List;
+import java.util.Map;
 import java.util.regex.Pattern;
+import java.util.stream.Collectors;
 import org.eclipse.jgit.diff.Edit;
 import org.eclipse.jgit.errors.CorruptObjectException;
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
@@ -40,7 +45,6 @@
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectReader;
 import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevTree;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.treewalk.TreeWalk;
@@ -69,27 +73,26 @@
     Pattern fileRegex = getRegexParameter(a1);
     Pattern editRegex = getRegexParameter(a2);
 
-    PatchList pl = StoredValues.PATCH_LIST.get(engine);
+    Map<String, FileDiffOutput> modifiedFiles = StoredValues.DIFF_LIST.get(engine);
+    FileDiffOutput firstDiff = Iterables.getFirst(modifiedFiles.values(), /* defaultValue= */ null);
+    if (firstDiff == null) {
+      // No available diffs. We cannot identify old and new commit IDs.
+      engine.fail();
+    }
     Repository repo = StoredValues.REPOSITORY.get(engine);
 
     try (ObjectReader reader = repo.newObjectReader();
         RevWalk rw = new RevWalk(reader)) {
-      final RevTree aTree;
-      final RevTree bTree;
-      final RevCommit bCommit = rw.parseCommit(pl.getNewId());
+      final RevTree aTree =
+          firstDiff.oldCommitId().equals(ObjectId.zeroId())
+              ? null
+              : rw.parseTree(firstDiff.oldCommitId());
+      final RevTree bTree = rw.parseCommit(firstDiff.newCommitId()).getTree();
 
-      if (pl.getOldId() != null) {
-        aTree = rw.parseTree(pl.getOldId());
-      } else {
-        // Octopus merge with unknown automatic merge result, since the
-        // web UI returns no files to match against, just fail.
-        return engine.fail();
-      }
-      bTree = bCommit.getTree();
-
-      for (PatchListEntry entry : pl.getPatches()) {
-        String newName = entry.getNewName();
-        String oldName = entry.getOldName();
+      for (FileDiffOutput entry : modifiedFiles.values()) {
+        String newName =
+            FilePathAdapter.getNewPath(entry.oldPath(), entry.newPath(), entry.changeType());
+        String oldName = FilePathAdapter.getOldPath(entry.oldPath(), entry.changeType());
 
         if (Patch.isMagic(newName)) {
           continue;
@@ -97,7 +100,8 @@
 
         if (fileRegex.matcher(newName).find()
             || (oldName != null && fileRegex.matcher(oldName).find())) {
-          List<Edit> edits = entry.getEdits();
+          List<Edit> edits =
+              entry.edits().stream().map(TaggedEdit::jgitEdit).collect(Collectors.toList());
           if (edits.isEmpty()) {
             continue;
           }
@@ -141,10 +145,10 @@
     return Pattern.compile(term.name(), Pattern.MULTILINE);
   }
 
-  private Text load(ObjectId tree, String path, ObjectReader reader)
+  private Text load(@Nullable ObjectId tree, String path, ObjectReader reader)
       throws MissingObjectException, IncorrectObjectTypeException, CorruptObjectException,
           IOException {
-    if (path == null) {
+    if (tree == null || path == null) {
       return Text.EMPTY;
     }
     final TreeWalk tw = TreeWalk.forPath(reader, path, tree);
diff --git a/java/gerrit/PRED_commit_stats_3.java b/java/gerrit/PRED_commit_stats_3.java
index 286bc2c..82fad3d 100644
--- a/java/gerrit/PRED_commit_stats_3.java
+++ b/java/gerrit/PRED_commit_stats_3.java
@@ -15,8 +15,7 @@
 package gerrit;
 
 import com.google.gerrit.entities.Patch;
-import com.google.gerrit.server.patch.PatchList;
-import com.google.gerrit.server.patch.PatchListEntry;
+import com.google.gerrit.server.patch.filediff.FileDiffOutput;
 import com.google.gerrit.server.rules.StoredValues;
 import com.googlecode.prolog_cafe.exceptions.PrologException;
 import com.googlecode.prolog_cafe.lang.IntegerTerm;
@@ -24,7 +23,8 @@
 import com.googlecode.prolog_cafe.lang.Predicate;
 import com.googlecode.prolog_cafe.lang.Prolog;
 import com.googlecode.prolog_cafe.lang.Term;
-import java.util.List;
+import java.util.Collection;
+import java.util.Map;
 
 /**
  * Exports basic commit statistics.
@@ -49,25 +49,30 @@
     Term a2 = arg2.dereference();
     Term a3 = arg3.dereference();
 
-    PatchList pl = StoredValues.PATCH_LIST.get(engine);
+    Map<String, FileDiffOutput> modifiedFiles = StoredValues.DIFF_LIST.get(engine);
     // Account for magic files
     if (!a1.unify(
-        new IntegerTerm(pl.getPatches().size() - countMagicFiles(pl.getPatches())), engine.trail)) {
+        new IntegerTerm(modifiedFiles.size() - countMagicFiles(modifiedFiles.values())),
+        engine.trail)) {
       return engine.fail();
     }
-    if (!a2.unify(new IntegerTerm(pl.getInsertions()), engine.trail)) {
+    Integer insertions =
+        modifiedFiles.values().stream().map(FileDiffOutput::insertions).reduce(0, Integer::sum);
+    Integer deletions =
+        modifiedFiles.values().stream().map(FileDiffOutput::deletions).reduce(0, Integer::sum);
+    if (!a2.unify(new IntegerTerm(insertions), engine.trail)) {
       return engine.fail();
     }
-    if (!a3.unify(new IntegerTerm(pl.getDeletions()), engine.trail)) {
+    if (!a3.unify(new IntegerTerm(deletions), engine.trail)) {
       return engine.fail();
     }
     return cont;
   }
 
-  private int countMagicFiles(List<PatchListEntry> entries) {
+  private int countMagicFiles(Collection<FileDiffOutput> entries) {
     int count = 0;
-    for (PatchListEntry e : entries) {
-      if (Patch.isMagic(e.getNewName())) {
+    for (FileDiffOutput e : entries) {
+      if (e.newPath().isPresent() && Patch.isMagic(e.newPath().get())) {
         count++;
       }
     }
diff --git a/java/gerrit/PRED_files_1.java b/java/gerrit/PRED_files_1.java
index ac45449..dbf96da 100644
--- a/java/gerrit/PRED_files_1.java
+++ b/java/gerrit/PRED_files_1.java
@@ -15,7 +15,8 @@
 package gerrit;
 
 import com.google.gerrit.entities.Patch;
-import com.google.gerrit.server.patch.PatchListEntry;
+import com.google.gerrit.server.patch.FilePathAdapter;
+import com.google.gerrit.server.patch.filediff.FileDiffOutput;
 import com.google.gerrit.server.rules.StoredValues;
 import com.googlecode.prolog_cafe.exceptions.PrologException;
 import com.googlecode.prolog_cafe.lang.ListTerm;
@@ -26,8 +27,8 @@
 import com.googlecode.prolog_cafe.lang.SymbolTerm;
 import com.googlecode.prolog_cafe.lang.Term;
 import java.io.IOException;
+import java.util.Collection;
 import java.util.HashSet;
-import java.util.List;
 import java.util.Set;
 import java.util.stream.Collectors;
 import org.eclipse.jgit.lib.FileMode;
@@ -54,17 +55,20 @@
 
     try (RevWalk revWalk = new RevWalk(StoredValues.REPOSITORY.get(engine))) {
       RevCommit commit = revWalk.parseCommit(StoredValues.getPatchSet(engine).commitId());
-      List<PatchListEntry> patches = StoredValues.PATCH_LIST.get(engine).getPatches();
+      Collection<FileDiffOutput> modifiedFiles = StoredValues.DIFF_LIST.get(engine).values();
       Set<String> submodules =
-          getAllSubmodulePaths(StoredValues.REPOSITORY.get(engine), commit, patches);
-      for (PatchListEntry entry : patches) {
-        if (Patch.isMagic(entry.getNewName())) {
+          getAllSubmodulePaths(StoredValues.REPOSITORY.get(engine), commit, modifiedFiles);
+      for (FileDiffOutput fileDiff : modifiedFiles) {
+        if (fileDiff.newPath().isPresent() && Patch.isMagic(fileDiff.newPath().get())) {
           continue;
         }
-        SymbolTerm fileNameTerm = SymbolTerm.create(entry.getNewName());
-        SymbolTerm changeType = SymbolTerm.create(entry.getChangeType().getCode());
+        String newPath =
+            FilePathAdapter.getNewPath(
+                fileDiff.oldPath(), fileDiff.newPath(), fileDiff.changeType());
+        SymbolTerm fileNameTerm = SymbolTerm.create(newPath);
+        SymbolTerm changeType = SymbolTerm.create(fileDiff.changeType().getCode());
         SymbolTerm fileType;
-        if (submodules.contains(entry.getNewName())) {
+        if (submodules.contains(newPath)) {
           fileType = SymbolTerm.create("SUBMODULE");
         } else {
           fileType = SymbolTerm.create("REGULAR");
@@ -83,14 +87,14 @@
 
   /** Returns the paths for all {@code GITLINK} files. */
   private static Set<String> getAllSubmodulePaths(
-      Repository repository, RevCommit commit, List<PatchListEntry> patches)
+      Repository repository, RevCommit commit, Collection<FileDiffOutput> modifiedFiles)
       throws PrologException, IOException {
     Set<String> submodules = new HashSet<>();
     try (TreeWalk treeWalk = new TreeWalk(repository)) {
       treeWalk.addTree(commit.getTree());
       Set<String> allPaths =
-          patches.stream()
-              .map(PatchListEntry::getNewName)
+          modifiedFiles.stream()
+              .map(f -> FilePathAdapter.getNewPath(f.oldPath(), f.newPath(), f.changeType()))
               .filter(f -> !Patch.isMagic(f))
               .collect(Collectors.toSet());
       treeWalk.setFilter(PathFilterGroup.createFromStrings(allPaths));
diff --git a/javatests/com/google/gerrit/server/project/SubmitRequirementsAdapterTest.java b/javatests/com/google/gerrit/server/project/SubmitRequirementsAdapterTest.java
index 7e1e821..2212fe7 100644
--- a/javatests/com/google/gerrit/server/project/SubmitRequirementsAdapterTest.java
+++ b/javatests/com/google/gerrit/server/project/SubmitRequirementsAdapterTest.java
@@ -17,6 +17,7 @@
 import static com.google.common.truth.Truth.assertThat;
 
 import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.LabelFunction;
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.LabelValue;
@@ -72,7 +73,7 @@
   }
 
   @Test
-  public void defaultSubmitRule_WithLabelsAllPass() {
+  public void defaultSubmitRule_withLabelsAllPass() {
     SubmitRecord submitRecord =
         createSubmitRecord(
             "gerrit~DefaultSubmitRule",
@@ -100,7 +101,7 @@
   }
 
   @Test
-  public void defaultSubmitRule_WithLabelsAllNeed() {
+  public void defaultSubmitRule_withLabelsAllNeed() {
     SubmitRecord submitRecord =
         createSubmitRecord(
             "gerrit~DefaultSubmitRule",
@@ -128,7 +129,7 @@
   }
 
   @Test
-  public void customSubmitRule_NoLabels_WithStatusOk() {
+  public void customSubmitRule_noLabels_withStatusOk() {
     SubmitRecord submitRecord =
         createSubmitRecord("gerrit~IgnoreSelfApprovalRule", Status.OK, Arrays.asList());
 
@@ -145,7 +146,24 @@
   }
 
   @Test
-  public void customSubmitRule_NoLabels_WithStatusNotReady() {
+  public void customSubmitRule_nullLabels_withStatusOk() {
+    SubmitRecord submitRecord =
+        createSubmitRecord("gerrit~IgnoreSelfApprovalRule", Status.OK, /* labels= */ null);
+
+    List<SubmitRequirementResult> requirements =
+        SubmitRequirementsAdapter.createResult(submitRecord, labelTypes, psCommitId);
+
+    assertThat(requirements).hasSize(1);
+    assertResult(
+        requirements.get(0),
+        /* reqName= */ "gerrit~IgnoreSelfApprovalRule",
+        /* submitExpression= */ "rule:gerrit~IgnoreSelfApprovalRule",
+        SubmitRequirementResult.Status.SATISFIED,
+        SubmitRequirementExpressionResult.Status.PASS);
+  }
+
+  @Test
+  public void customSubmitRule_noLabels_withStatusNotReady() {
     SubmitRecord submitRecord =
         createSubmitRecord("gerrit~IgnoreSelfApprovalRule", Status.NOT_READY, Arrays.asList());
 
@@ -162,7 +180,7 @@
   }
 
   @Test
-  public void customSubmitRule_WithLabels() {
+  public void customSubmitRule_withLabels() {
     SubmitRecord submitRecord =
         createSubmitRecord(
             "gerrit~PrologRule",
@@ -203,7 +221,7 @@
   }
 
   private SubmitRecord createSubmitRecord(
-      String ruleName, SubmitRecord.Status status, List<Label> labels) {
+      String ruleName, SubmitRecord.Status status, @Nullable List<Label> labels) {
     SubmitRecord record = new SubmitRecord();
     record.ruleName = ruleName;
     record.status = status;
diff --git a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts
index 1908df0..6dd67e4 100644
--- a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts
+++ b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts
@@ -41,6 +41,7 @@
 import {ParsedChangeInfo} from '../../../types/types';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {TokenHighlightLayer} from '../gr-diff-builder/token-highlight-layer';
+import {KnownExperimentId} from '../../../services/flags/flags';
 
 export interface GrApplyFixDialog {
   $: {
@@ -98,7 +99,11 @@
   })
   _disableApplyFixButton = false;
 
-  layers = [new TokenHighlightLayer(this)];
+  layers = appContext.flagsService.isEnabled(
+    KnownExperimentId.TOKEN_HIGHLIGHTING
+  )
+    ? [new TokenHighlightLayer(this)]
+    : [];
 
   private refitOverlay?: () => void;
 
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
index fa3ddf4..c4fed53 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
@@ -412,7 +412,11 @@
 
   private _getLayers(path: string): DiffLayer[] {
     const layers = [];
-    layers.push(new TokenHighlightLayer(this));
+    if (
+      appContext.flagsService.isEnabled(KnownExperimentId.TOKEN_HIGHLIGHTING)
+    ) {
+      layers.push(new TokenHighlightLayer(this));
+    }
     layers.push(this.syntaxLayer);
     // Get layers from plugins (if any).
     layers.push(...this.jsAPI.getDiffLayers(path));
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js
index b24b3ba..344f9d8 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js
@@ -1270,7 +1270,7 @@
     test('gr-diff-host provides syntax highlighting layer', async () => {
       stubRestApi('getDiff').returns(Promise.resolve({content: []}));
       await element.reload();
-      assert.equal(element.$.diff.layers[1], element.syntaxLayer);
+      assert.equal(element.$.diff.layers[0], element.syntaxLayer);
     });
 
     test('rendering normal-sized diff does not disable syntax', () => {
@@ -1324,7 +1324,7 @@
     test('gr-diff-host provides syntax highlighting layer', async () => {
       stubRestApi('getDiff').returns(Promise.resolve({content: []}));
       await element.reload();
-      assert.equal(element.$.diff.layers[1], element.syntaxLayer);
+      assert.equal(element.$.diff.layers[0], element.syntaxLayer);
     });
 
     test('syntax layer should be disabled', () => {
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
index 39a87a2..d6aae5c 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
@@ -54,6 +54,7 @@
 import {CustomKeyboardEvent} from '../../../types/events';
 import {LineNumber, FILE} from '../../diff/gr-diff/gr-diff-line';
 import {GrButton} from '../gr-button/gr-button';
+import {KnownExperimentId} from '../../../services/flags/flags';
 import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
 import {RenderPreferences} from '../../../api/diff';
 import {
@@ -211,6 +212,8 @@
 
   private readonly reporting = appContext.reportingService;
 
+  private readonly flagsService = appContext.flagsService;
+
   private readonly commentsService = appContext.commentsService;
 
   readonly storage = appContext.storageService;
@@ -357,7 +360,9 @@
   _getLayers(diff?: DiffInfo) {
     if (!diff) return [];
     const layers = [];
-    layers.push(new TokenHighlightLayer(this));
+    if (this.flagsService.isEnabled(KnownExperimentId.TOKEN_HIGHLIGHTING)) {
+      layers.push(new TokenHighlightLayer(this));
+    }
     layers.push(this.syntaxLayer);
     return layers;
   }
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints.ts
index f7475bf..b039a7e 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints.ts
@@ -104,7 +104,7 @@
    * which endpoints to dynamically add to the page.
    */
   registerModule(plugin: PluginApi, opts: Options) {
-    const endpoint = opts.endpoint!;
+    const endpoint = opts.endpoint;
     const dynamicEndpoint = opts.dynamicEndpoint;
     if (dynamicEndpoint) {
       if (!this._dynamicPlugins.has(dynamicEndpoint)) {
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints_test.js
deleted file mode 100644
index e3475ad..0000000
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints_test.js
+++ /dev/null
@@ -1,166 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * 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.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import {resetPlugins} from '../../../test/test-utils.js';
-import './gr-js-api-interface.js';
-import {GrPluginEndpoints} from './gr-plugin-endpoints.js';
-import {_testOnly_initGerritPluginApi} from './gr-gerrit.js';
-
-const pluginApi = _testOnly_initGerritPluginApi();
-
-suite('gr-plugin-endpoints tests', () => {
-  let instance;
-  let pluginFoo;
-  let pluginBar;
-  let domHook;
-
-  setup(() => {
-    domHook = {};
-    instance = new GrPluginEndpoints();
-    pluginApi.install(p => { pluginFoo = p; }, '0.1',
-        'http://test.com/plugins/testplugin/static/foo.js');
-    instance.registerModule(
-        pluginFoo,
-        {
-          endpoint: 'a-place',
-          type: 'decorate',
-          moduleName: 'foo-module',
-          domHook,
-        }
-    );
-    pluginApi.install(p => { pluginBar = p; }, '0.1',
-        'http://test.com/plugins/testplugin/static/bar.js');
-    instance.registerModule(
-        pluginBar,
-        {
-          endpoint: 'a-place',
-          type: 'style',
-          moduleName: 'bar-module',
-          domHook,
-        }
-    );
-  });
-
-  teardown(() => {
-    resetPlugins();
-  });
-
-  test('getDetails all', () => {
-    assert.deepEqual(instance.getDetails('a-place'), [
-      {
-        moduleName: 'foo-module',
-        plugin: pluginFoo,
-        pluginUrl: pluginFoo._url,
-        type: 'decorate',
-        domHook,
-        slot: undefined,
-      },
-      {
-        moduleName: 'bar-module',
-        plugin: pluginBar,
-        pluginUrl: pluginBar._url,
-        type: 'style',
-        domHook,
-        slot: undefined,
-      },
-    ]);
-  });
-
-  test('getDetails by type', () => {
-    assert.deepEqual(instance.getDetails('a-place', {type: 'style'}), [
-      {
-        moduleName: 'bar-module',
-        plugin: pluginBar,
-        pluginUrl: pluginBar._url,
-        type: 'style',
-        domHook,
-        slot: undefined,
-      },
-    ]);
-  });
-
-  test('getDetails by module', () => {
-    assert.deepEqual(
-        instance.getDetails('a-place', {moduleName: 'foo-module'}),
-        [
-          {
-            moduleName: 'foo-module',
-            plugin: pluginFoo,
-            pluginUrl: pluginFoo._url,
-            type: 'decorate',
-            domHook,
-            slot: undefined,
-          },
-        ]);
-  });
-
-  test('getModules', () => {
-    assert.deepEqual(
-        instance.getModules('a-place'), ['foo-module', 'bar-module']);
-  });
-
-  test('getPlugins', () => {
-    assert.deepEqual(
-        instance.getPlugins('a-place'), [pluginFoo._url]);
-  });
-
-  test('onNewEndpoint', () => {
-    const newModuleStub = sinon.stub();
-    instance.setPluginsReady();
-    instance.onNewEndpoint('a-place', newModuleStub);
-    instance.registerModule(
-        pluginFoo,
-        {
-          endpoint: 'a-place',
-          type: 'replace',
-          moduleName: 'zaz-module',
-          domHook,
-        });
-    assert.deepEqual(newModuleStub.lastCall.args[0], {
-      moduleName: 'zaz-module',
-      plugin: pluginFoo,
-      pluginUrl: pluginFoo._url,
-      type: 'replace',
-      domHook,
-      slot: undefined,
-    });
-  });
-
-  test('reuse dom hooks', () => {
-    instance.registerModule(
-        pluginFoo, 'a-place', 'decorate', 'foo-module', domHook);
-    assert.deepEqual(instance.getDetails('a-place'), [
-      {
-        moduleName: 'foo-module',
-        plugin: pluginFoo,
-        pluginUrl: pluginFoo._url,
-        type: 'decorate',
-        domHook,
-        slot: undefined,
-      },
-      {
-        moduleName: 'bar-module',
-        plugin: pluginBar,
-        pluginUrl: pluginBar._url,
-        type: 'style',
-        domHook,
-        slot: undefined,
-      },
-    ]);
-  });
-});
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints_test.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints_test.ts
new file mode 100644
index 0000000..c7bdfb4
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints_test.ts
@@ -0,0 +1,177 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * 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.
+ */
+import '../../../test/common-test-setup-karma';
+import {resetPlugins} from '../../../test/test-utils';
+import './gr-js-api-interface';
+import {GrPluginEndpoints} from './gr-plugin-endpoints';
+import {_testOnly_initGerritPluginApi} from './gr-gerrit';
+import {PluginApi} from '../../../api/plugin';
+import {HookApi, HookCallback, PluginElement} from '../../../api/hook';
+
+const pluginApi = _testOnly_initGerritPluginApi();
+
+export class MockHook<T extends PluginElement> implements HookApi<T> {
+  handleInstanceDetached(_: T) {}
+
+  handleInstanceAttached(_: T) {}
+
+  getLastAttached(): Promise<HTMLElement> {
+    throw new Error('unimplemented in mock');
+  }
+
+  getAllAttached() {
+    return [];
+  }
+
+  onAttached(_: HookCallback<T>) {
+    return this;
+  }
+
+  onDetached(_: HookCallback<T>) {
+    return this;
+  }
+
+  getModuleName() {
+    return 'MockHookApi-ModuleName';
+  }
+}
+
+suite('gr-plugin-endpoints tests', () => {
+  let instance: GrPluginEndpoints;
+  let decoratePlugin: PluginApi;
+  let stylePlugin: PluginApi;
+  let domHook: HookApi<PluginElement>;
+
+  setup(() => {
+    domHook = new MockHook<PluginElement>();
+    instance = new GrPluginEndpoints();
+    pluginApi.install(
+      plugin => (decoratePlugin = plugin),
+      '0.1',
+      'http://test.com/plugins/testplugin/static/decorate.js'
+    );
+    instance.registerModule(decoratePlugin, {
+      endpoint: 'my-endpoint',
+      type: 'decorate',
+      moduleName: 'decorate-module',
+      domHook,
+    });
+    pluginApi.install(
+      plugin => (stylePlugin = plugin),
+      '0.1',
+      'http://test.com/plugins/testplugin/static/style.js'
+    );
+    instance.registerModule(stylePlugin, {
+      endpoint: 'my-endpoint',
+      type: 'style',
+      moduleName: 'style-module',
+      domHook,
+    });
+  });
+
+  teardown(() => {
+    resetPlugins();
+  });
+
+  test('getDetails all', () => {
+    assert.deepEqual(instance.getDetails('my-endpoint'), [
+      {
+        moduleName: 'decorate-module',
+        plugin: decoratePlugin,
+        pluginUrl: decoratePlugin._url,
+        type: 'decorate',
+        domHook,
+        slot: undefined,
+      },
+      {
+        moduleName: 'style-module',
+        plugin: stylePlugin,
+        pluginUrl: stylePlugin._url,
+        type: 'style',
+        domHook,
+        slot: undefined,
+      },
+    ]);
+  });
+
+  test('getDetails by type', () => {
+    assert.deepEqual(
+      instance.getDetails('my-endpoint', {endpoint: 'a-place', type: 'style'}),
+      [
+        {
+          moduleName: 'style-module',
+          plugin: stylePlugin,
+          pluginUrl: stylePlugin._url,
+          type: 'style',
+          domHook,
+          slot: undefined,
+        },
+      ]
+    );
+  });
+
+  test('getDetails by module', () => {
+    assert.deepEqual(
+      instance.getDetails('my-endpoint', {
+        endpoint: 'my-endpoint',
+        moduleName: 'decorate-module',
+      }),
+      [
+        {
+          moduleName: 'decorate-module',
+          plugin: decoratePlugin,
+          pluginUrl: decoratePlugin._url,
+          type: 'decorate',
+          domHook,
+          slot: undefined,
+        },
+      ]
+    );
+  });
+
+  test('getModules', () => {
+    assert.deepEqual(instance.getModules('my-endpoint'), [
+      'decorate-module',
+      'style-module',
+    ]);
+  });
+
+  test('getPlugins URLs are unique', () => {
+    assert.equal(decoratePlugin._url, stylePlugin._url);
+    assert.deepEqual(instance.getPlugins('my-endpoint'), [decoratePlugin._url]);
+  });
+
+  test('onNewEndpoint', () => {
+    const newModuleStub = sinon.stub();
+    instance.setPluginsReady();
+    instance.onNewEndpoint('my-endpoint', newModuleStub);
+    instance.registerModule(decoratePlugin, {
+      endpoint: 'my-endpoint',
+      type: 'replace',
+      moduleName: 'replace-module',
+      domHook,
+    });
+    assert.deepEqual(newModuleStub.lastCall.args[0], {
+      moduleName: 'replace-module',
+      plugin: decoratePlugin,
+      pluginUrl: decoratePlugin._url,
+      type: 'replace',
+      domHook,
+      slot: undefined,
+    });
+  });
+});
diff --git a/polygerrit-ui/app/services/flags/flags.ts b/polygerrit-ui/app/services/flags/flags.ts
index 21f3aa4..ef5fde2 100644
--- a/polygerrit-ui/app/services/flags/flags.ts
+++ b/polygerrit-ui/app/services/flags/flags.ts
@@ -25,6 +25,7 @@
  */
 export enum KnownExperimentId {
   NEW_IMAGE_DIFF_UI = 'UiFeature__new_image_diff_ui',
+  TOKEN_HIGHLIGHTING = 'UiFeature__token_highlighting',
   CHECKS_DEVELOPER = 'UiFeature__checks_developer',
   NEW_REPLY_DIALOG = 'UiFeature__new_reply_dialog',
   SUBMIT_REQUIREMENTS_UI = 'UiFeature__submit_requirements_ui',