Merge "Remove unused property disableDiffPrefs"
diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt
index 84e68ed..acf65a5 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -4343,7 +4343,7 @@
 before it is focefully cancelled.
 +
 The receive timeout cannot be overriden by setting a higher
-link:user-upload#deadline[deadline] on the git push request.
+link:user-upload.html#deadline[deadline] on the git push request.
 +
 Default is 4 minutes. If no unit is specified, milliseconds
 is assumed.
diff --git a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
index 8064482..18a693f 100644
--- a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
@@ -202,7 +202,6 @@
 import org.eclipse.jgit.util.FS;
 import org.eclipse.jgit.util.SystemReader;
 import org.junit.After;
-import org.junit.AfterClass;
 import org.junit.Before;
 import org.junit.ClassRule;
 import org.junit.Rule;
@@ -365,7 +364,7 @@
     }
   }
 
-  @AfterClass
+  @ConfigSuite.AfterConfig
   public static void stopCommonServer() throws Exception {
     if (commonServer != null) {
       try {
diff --git a/java/com/google/gerrit/acceptance/TestMetricMaker.java b/java/com/google/gerrit/acceptance/TestMetricMaker.java
index d60ef1a..2620f99 100644
--- a/java/com/google/gerrit/acceptance/TestMetricMaker.java
+++ b/java/com/google/gerrit/acceptance/TestMetricMaker.java
@@ -34,11 +34,11 @@
  *
  * <pre>
  * public class MyTest extends AbstractDaemonTest {
- *   @Inject private TestMetricMaker testMetricMaker;
+ *   {@literal @}Inject private TestMetricMaker testMetricMaker;
  *
  *   ...
  *
- *   @Test
+ *   {@literal @}Test
  *   public void testFoo() throws Exception {
  *     testMetricMaker.reset();
  *     doSomething();
diff --git a/java/com/google/gerrit/lucene/WrappableSearcherManager.java b/java/com/google/gerrit/lucene/WrappableSearcherManager.java
index 4044b90..c164b29 100644
--- a/java/com/google/gerrit/lucene/WrappableSearcherManager.java
+++ b/java/com/google/gerrit/lucene/WrappableSearcherManager.java
@@ -53,7 +53,6 @@
  * {@link #maybeRefresh}. Finally, be sure to call {@link #close} once you are done.
  *
  * @see SearcherFactory
- * @lucene.experimental
  */
 // This file was copied from:
 // https://github.com/apache/lucene-solr/blob/lucene_solr_5_0/lucene/core/src/java/org/apache/lucene/search/SearcherManager.java
diff --git a/java/com/google/gerrit/server/change/PatchSetInserter.java b/java/com/google/gerrit/server/change/PatchSetInserter.java
index f093958..209901d 100644
--- a/java/com/google/gerrit/server/change/PatchSetInserter.java
+++ b/java/com/google/gerrit/server/change/PatchSetInserter.java
@@ -104,6 +104,7 @@
   private boolean allowClosed;
   private boolean sendEmail = true;
   private String topic;
+  private boolean storeCopiedVotes = true;
 
   // Fields set during some phase of BatchUpdate.Op.
   private Change change;
@@ -203,6 +204,17 @@
     return this;
   }
 
+  /**
+   * We always want to store copied votes except when the change is getting submitted and a new
+   * patch-set is created on submit (using submit strategies such as "REBASE_ALWAYS"). In such
+   * cases, we already store the votes of the new patch-sets in SubmitStrategyOp#saveApprovals. We
+   * should not also store the copied votes.
+   */
+  public PatchSetInserter setStoreCopiedVotes(boolean storeCopiedVotes) {
+    this.storeCopiedVotes = storeCopiedVotes;
+    return this;
+  }
+
   public Change getChange() {
     checkState(change != null, "getChange() only valid after executing update");
     return change;
@@ -288,7 +300,9 @@
 
     // Approvals that are being set in the new patch-set during this operation are not available yet
     // outside of the scope of this method. Only copied approvals are set here.
-    approvalsUtil.byPatchSet(ctx.getNotes(), patchSet).forEach(a -> update.putCopiedApproval(a));
+    if (storeCopiedVotes) {
+      approvalsUtil.byPatchSet(ctx.getNotes(), patchSet).forEach(a -> update.putCopiedApproval(a));
+    }
 
     return true;
   }
diff --git a/java/com/google/gerrit/server/change/RebaseChangeOp.java b/java/com/google/gerrit/server/change/RebaseChangeOp.java
index 4952464..3e67cca 100644
--- a/java/com/google/gerrit/server/change/RebaseChangeOp.java
+++ b/java/com/google/gerrit/server/change/RebaseChangeOp.java
@@ -92,6 +92,7 @@
   private boolean detailedCommitMessage;
   private boolean postMessage = true;
   private boolean sendEmail = true;
+  private boolean storeCopiedVotes = true;
   private boolean matchAuthorToCommitterDate = false;
 
   private CodeReviewCommit rebasedCommit;
@@ -169,6 +170,17 @@
     return this;
   }
 
+  /**
+   * We always want to store copied votes except when the change is getting submitted and a new
+   * patch-set is created on submit (using submit strategies such as "REBASE_ALWAYS"). In such
+   * cases, we already store the votes of the new patch-sets in SubmitStrategyOp#saveApprovals. We
+   * should not also store the copied votes.
+   */
+  public RebaseChangeOp setStoreCopiedVotes(boolean storeCopiedVotes) {
+    this.storeCopiedVotes = storeCopiedVotes;
+    return this;
+  }
+
   public RebaseChangeOp setSendEmail(boolean sendEmail) {
     this.sendEmail = sendEmail;
     return this;
@@ -219,7 +231,10 @@
             .setFireRevisionCreated(fireRevisionCreated)
             .setCheckAddPatchSetPermission(checkAddPatchSetPermission)
             .setValidate(validate)
-            .setSendEmail(sendEmail);
+            .setSendEmail(sendEmail)
+            // The votes are automatically copied and they don't count as copied votes. See
+            // method's javadoc.
+            .setStoreCopiedVotes(storeCopiedVotes);
 
     if (!rebasedCommit.getFilesWithGitConflicts().isEmpty()
         && !notes.getChange().isWorkInProgress()) {
diff --git a/java/com/google/gerrit/server/notedb/CommitRewriter.java b/java/com/google/gerrit/server/notedb/CommitRewriter.java
index e940b1e..eabee65 100644
--- a/java/com/google/gerrit/server/notedb/CommitRewriter.java
+++ b/java/com/google/gerrit/server/notedb/CommitRewriter.java
@@ -30,6 +30,7 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
@@ -117,6 +118,16 @@
     public boolean verifyCommits = true;
     /** Whether to compute and output the diff of the commit history for the backfilled refs. */
     public boolean outputDiff = true;
+
+    /** Max number of refs to update in a single {@link BatchRefUpdate}. */
+    public int maxRefsInBatch = 10000;
+    /**
+     * Max number of refs to fix by a single {@link RefsUpdate#backfillProject} run. Since second
+     * run on the same set of refs is a no-op, running with this option in a loop will eventually
+     * fix all refs. Number of executed {@link BatchRefUpdate} depends on {@link #maxRefsInBatch}
+     * option.
+     */
+    public int maxRefsToUpdate = 50000;
   }
 
   /** Result of the backfill run for a project. */
@@ -239,15 +250,24 @@
    */
   public BackfillResult backfillProject(
       Project.NameKey project, Repository repo, RunOptions options) {
+
+    checkState(
+        options.maxRefsInBatch > 0 && options.maxRefsToUpdate > 0,
+        "Expected maxRefsInBatch>0 && <= maxRefsToUpdate>0");
+    checkState(
+        options.maxRefsInBatch <= options.maxRefsToUpdate,
+        "Expected maxRefsInBatch(%s) <= maxRefsToUpdate(%s)",
+        options.maxRefsInBatch,
+        options.maxRefsToUpdate);
     BackfillResult result = new BackfillResult();
     result.ok = true;
-    try (RevWalk revWalk = new RevWalk(repo);
-        ObjectInserter ins = newPackInserter(repo)) {
-      BatchRefUpdate bru = repo.getRefDatabase().newBatchUpdate();
-      bru.setForceRefLog(true);
-      bru.setRefLogMessage(CommitRewriter.class.getName(), false);
-      bru.setAllowNonFastForwards(true);
+    int refsInUpdate = 0;
+    RefsUpdate refsUpdate = null;
+    try {
       for (Ref ref : repo.getRefDatabase().getRefsByPrefix(RefNames.REFS_CHANGES)) {
+        if (result.fixedRefDiff.size() >= options.maxRefsToUpdate) {
+          return result;
+        }
         Change.Id changeId = Change.Id.fromRef(ref.getName());
         if (changeId == null || !ref.getName().equals(RefNames.changeMetaRef(changeId))) {
           continue;
@@ -262,14 +282,26 @@
               logger.atWarning().withCause(e).log("Failed to run verification on ref %s", ref);
             }
           }
+          if (refsUpdate == null) {
+            refsUpdate = RefsUpdate.create(repo);
+          }
           ChangeFixProgress changeFixProgress =
-              backfillChange(revWalk, ins, ref, accountsInChange, options);
+              backfillChange(refsUpdate, ref, accountsInChange, options);
           if (changeFixProgress.anyFixesApplied) {
-            bru.addCommand(
-                new ReceiveCommand(ref.getObjectId(), changeFixProgress.newTipId, ref.getName()));
+            refsInUpdate++;
+            refsUpdate
+                .batchRefUpdate()
+                .addCommand(
+                    new ReceiveCommand(
+                        ref.getObjectId(), changeFixProgress.newTipId, ref.getName()));
             result.fixedRefDiff.put(ref.getName(), changeFixProgress.commitDiffs);
           }
-
+          if (refsInUpdate >= options.maxRefsInBatch
+              || result.fixedRefDiff.size() >= options.maxRefsToUpdate) {
+            processUpdate(options, refsUpdate);
+            refsUpdate = null;
+            refsInUpdate = 0;
+          }
           if (!changeFixProgress.isValidAfterFix) {
             result.refsStillInvalidAfterFix.add(ref.getName());
           }
@@ -278,21 +310,34 @@
           result.refsFailedToFix.add(ref.getName());
         }
       }
-
-      if (!bru.getCommands().isEmpty()) {
-        if (!options.dryRun) {
-          ins.flush();
-          RefUpdateUtil.executeChecked(bru, revWalk);
-        }
-      }
+      processUpdate(options, refsUpdate);
     } catch (IOException e) {
-      logger.atWarning().withCause(e).log("Failed to fix project %s", project.get());
+      logger.atWarning().log("Failed to fix project %s. Reason: %s", project.get(), e.getMessage());
       result.ok = false;
+    } finally {
+      if (refsUpdate != null) {
+        refsUpdate.close();
+      }
     }
 
     return result;
   }
 
+  /** Executes a single {@link RefsUpdate#batchRefUpdate}. */
+  private void processUpdate(RunOptions options, @Nullable RefsUpdate refsUpdate)
+      throws IOException {
+    if (refsUpdate == null) {
+      return;
+    }
+    if (!refsUpdate.batchRefUpdate().getCommands().isEmpty()) {
+      if (!options.dryRun) {
+        refsUpdate.inserter().flush();
+        RefUpdateUtil.executeChecked(refsUpdate.batchRefUpdate(), refsUpdate.revWalk());
+      }
+    }
+    refsUpdate.close();
+  }
+
   /**
    * Retrieves accounts, that are associated with a change (e.g. reviewers, commenters, etc.). These
    * accounts are used to verify that commits do not contain user data. See {@link #verifyCommit}
@@ -376,8 +421,7 @@
    * ChangeFixProgress#newTipId}.
    */
   public ChangeFixProgress backfillChange(
-      RevWalk revWalk,
-      ObjectInserter inserter,
+      RefsUpdate refsUpdate,
       Ref ref,
       ImmutableSet<AccountState> accountsInChange,
       RunOptions options)
@@ -385,17 +429,17 @@
 
     ObjectId oldTip = ref.getObjectId();
     // Walk from the first commit of the branch.
-    revWalk.reset();
-    revWalk.markStart(revWalk.parseCommit(oldTip));
-    revWalk.sort(RevSort.TOPO);
+    refsUpdate.revWalk().reset();
+    refsUpdate.revWalk().markStart(refsUpdate.revWalk().parseCommit(oldTip));
+    refsUpdate.revWalk().sort(RevSort.TOPO);
 
-    revWalk.sort(RevSort.REVERSE);
+    refsUpdate.revWalk().sort(RevSort.REVERSE);
 
     RevCommit originalCommit;
 
     boolean rewriteStarted = false;
     ChangeFixProgress changeFixProgress = new ChangeFixProgress(ref.getName());
-    while ((originalCommit = revWalk.next()) != null) {
+    while ((originalCommit = refsUpdate.revWalk().next()) != null) {
 
       changeFixProgress.updateAuthorId =
           parseIdent(changeFixProgress, originalCommit.getAuthorIdent());
@@ -453,7 +497,8 @@
       cb.setEncoding(originalCommit.getEncoding());
       byte[] newCommitContent = cb.build();
       checkCommitModification(originalCommit, newCommitContent);
-      changeFixProgress.newTipId = inserter.insert(Constants.OBJ_COMMIT, newCommitContent);
+      changeFixProgress.newTipId =
+          refsUpdate.inserter().insert(Constants.OBJ_COMMIT, newCommitContent);
       // Only compute diff if the content of the commit was actually changed.
       if (options.outputDiff && needsFix) {
         String diff = computeDiff(originalCommit.getRawBuffer(), newCommitContent);
@@ -1283,4 +1328,33 @@
 
     abstract Optional<String> email();
   }
+
+  /**
+   * Objects, needed to fix Refs in a single {@link BatchRefUpdate}. Number of changes in a batch
+   * are limited by {@link RunOptions#maxRefsInBatch}.
+   */
+  @AutoValue
+  abstract static class RefsUpdate implements AutoCloseable {
+    static RefsUpdate create(Repository repo) {
+      RevWalk revWalk = new RevWalk(repo);
+      ObjectInserter inserter = newPackInserter(repo);
+      BatchRefUpdate bru = repo.getRefDatabase().newBatchUpdate();
+      bru.setForceRefLog(true);
+      bru.setRefLogMessage(CommitRewriter.class.getName(), false);
+      bru.setAllowNonFastForwards(true);
+      return new AutoValue_CommitRewriter_RefsUpdate(bru, revWalk, inserter);
+    }
+
+    @Override
+    public void close() {
+      inserter().close();
+      revWalk().close();
+    }
+
+    abstract BatchRefUpdate batchRefUpdate();
+
+    abstract RevWalk revWalk();
+
+    abstract ObjectInserter inserter();
+  }
 }
diff --git a/java/com/google/gerrit/server/submit/RebaseSubmitStrategy.java b/java/com/google/gerrit/server/submit/RebaseSubmitStrategy.java
index cc3b75d..355d25f 100644
--- a/java/com/google/gerrit/server/submit/RebaseSubmitStrategy.java
+++ b/java/com/google/gerrit/server/submit/RebaseSubmitStrategy.java
@@ -211,7 +211,10 @@
                 .setPostMessage(false)
                 .setSendEmail(false)
                 .setMatchAuthorToCommitterDate(
-                    args.project.is(BooleanProjectConfig.MATCH_AUTHOR_TO_COMMITTER_DATE));
+                    args.project.is(BooleanProjectConfig.MATCH_AUTHOR_TO_COMMITTER_DATE))
+                // The votes are automatically copied and they don't count as copied votes. See
+                // method's javadoc.
+                .setStoreCopiedVotes(/* storeCopiedVotes = */ false);
         try {
           rebaseOp.updateRepo(ctx);
         } catch (MergeConflictException | NoSuchChangeException e) {
diff --git a/java/com/google/gerrit/server/util/CommitMessageUtil.java b/java/com/google/gerrit/server/util/CommitMessageUtil.java
index 55e3951..dc9c2d9 100644
--- a/java/com/google/gerrit/server/util/CommitMessageUtil.java
+++ b/java/com/google/gerrit/server/util/CommitMessageUtil.java
@@ -51,7 +51,7 @@
    * the commit message.
    *
    * @throws BadRequestException if the commit message is null or empty
-   * @returns the trimmed message with a trailing newline character
+   * @return the trimmed message with a trailing newline character
    */
   public static String checkAndSanitizeCommitMessage(@Nullable String commitMessage)
       throws BadRequestException {
diff --git a/java/com/google/gerrit/testing/ConfigSuite.java b/java/com/google/gerrit/testing/ConfigSuite.java
index d7ae397..3101c48 100644
--- a/java/com/google/gerrit/testing/ConfigSuite.java
+++ b/java/com/google/gerrit/testing/ConfigSuite.java
@@ -25,7 +25,9 @@
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
 import java.lang.annotation.Annotation;
+import java.lang.annotation.ElementType;
 import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
 import java.lang.annotation.Target;
 import java.lang.reflect.Field;
 import java.lang.reflect.InvocationTargetException;
@@ -35,11 +37,15 @@
 import java.lang.reflect.Type;
 import java.util.List;
 import java.util.Map;
+import org.junit.internal.runners.statements.RunAfters;
+import org.junit.internal.runners.statements.RunBefores;
+import org.junit.rules.TestRule;
 import org.junit.runner.Runner;
 import org.junit.runners.BlockJUnit4ClassRunner;
 import org.junit.runners.Suite;
 import org.junit.runners.model.FrameworkMethod;
 import org.junit.runners.model.InitializationError;
+import org.junit.runners.model.Statement;
 
 /**
  * Suite to run tests with different {@code gerrit.config} values.
@@ -47,6 +53,9 @@
  * <p>For each {@link Config} method in the class and base classes, a new group of tests is created
  * with the {@link Parameter} field set to the config.
  *
+ * <p>Additional actions can be executed before or after each group of tests using
+ * {@literal @}BeforeConfig, {@literal @}AfterConfig or {@literal @}ConfigRule annotations.
+ *
  * <pre>
  * {@literal @}RunWith(ConfigSuite.class)
  * public abstract class MyAbstractTest {
@@ -128,6 +137,38 @@
   @Retention(RUNTIME)
   public static @interface Name {}
 
+  /**
+   * Annotation for methods which should be run after executing group of tests with a new
+   * configuration.
+   *
+   * <p>Works similar to {@link org.junit.AfterClass}, but a method can be executed multiple times
+   * if a test class provides multiple configs.
+   */
+  @Retention(RetentionPolicy.RUNTIME)
+  @Target(ElementType.METHOD)
+  public @interface AfterConfig {}
+
+  /**
+   * Annotation for methods which should be run before executing group of tests with a new
+   * configuration.
+   *
+   * <p>Works similar to {@link org.junit.BeforeClass}, but a method can be executed multiple times
+   * if a test class provides multiple configs.
+   */
+  @Retention(RetentionPolicy.RUNTIME)
+  @Target(ElementType.METHOD)
+  public @interface BeforeConfig {}
+
+  /**
+   * Annotation for fields or methods which wraps all tests with the same config
+   *
+   * <p>Works similar to {@link org.junit.ClassRule}, but Statement evaluates multiple time - ones
+   * for each config provided by a test class.
+   */
+  @Retention(RetentionPolicy.RUNTIME)
+  @Target({ElementType.FIELD, ElementType.METHOD})
+  public @interface ConfigRule {}
+
   private static class ConfigRunner extends BlockJUnit4ClassRunner {
     private final org.eclipse.jgit.lib.Config cfg;
     private final Field parameterField;
@@ -168,6 +209,26 @@
       String n = method.getName();
       return name == null ? n : n + "[" + name + "]";
     }
+
+    @Override
+    protected Statement withBeforeClasses(Statement statement) {
+      List<FrameworkMethod> befores = getTestClass().getAnnotatedMethods(BeforeConfig.class);
+      return befores.isEmpty() ? statement : new RunBefores(statement, befores, null);
+    }
+
+    @Override
+    protected Statement withAfterClasses(Statement statement) {
+      List<FrameworkMethod> afters = getTestClass().getAnnotatedMethods(AfterConfig.class);
+      return afters.isEmpty() ? statement : new RunAfters(statement, afters, null);
+    }
+
+    @Override
+    protected List<TestRule> classRules() {
+      List<TestRule> result =
+          getTestClass().getAnnotatedMethodValues(null, ConfigRule.class, TestRule.class);
+      result.addAll(getTestClass().getAnnotatedFieldValues(null, ConfigRule.class, TestRule.class));
+      return result;
+    }
   }
 
   private static List<Runner> runnersFor(Class<?> clazz) {
diff --git a/java/com/google/gerrit/testing/SystemPropertiesTestRule.java b/java/com/google/gerrit/testing/SystemPropertiesTestRule.java
new file mode 100644
index 0000000..c7a3542
--- /dev/null
+++ b/java/com/google/gerrit/testing/SystemPropertiesTestRule.java
@@ -0,0 +1,67 @@
+// Copyright (C) 2021 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.
+
+package com.google.gerrit.testing;
+
+import static com.google.common.base.Preconditions.checkState;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.common.Nullable;
+import java.util.Map;
+import java.util.Optional;
+import org.junit.rules.ExternalResource;
+
+/** Setup system properties before tests and return previous value after tests are finished */
+public class SystemPropertiesTestRule extends ExternalResource {
+  ImmutableMap<String, Optional<String>> properties;
+  @Nullable ImmutableMap<String, Optional<String>> previousValues;
+
+  public SystemPropertiesTestRule(String key, String value) {
+    this(ImmutableMap.of(key, Optional.of(value)));
+  }
+
+  public SystemPropertiesTestRule(Map<String, Optional<String>> properties) {
+    this.properties = ImmutableMap.copyOf(properties);
+  }
+
+  @Override
+  protected void before() throws Throwable {
+    super.before();
+    checkState(
+        previousValues == null,
+        "after() wasn't called after the previous call to the before() method");
+    ImmutableMap.Builder<String, Optional<String>> previousValuesBuilder = ImmutableMap.builder();
+    for (String key : properties.keySet()) {
+      previousValuesBuilder.put(key, Optional.ofNullable(System.getProperty(key)));
+    }
+    previousValues = previousValuesBuilder.build();
+    properties.entrySet().stream().forEach(this::setSystemProperty);
+  }
+
+  @Override
+  protected void after() {
+    checkState(previousValues != null, "before() wasn't called");
+    previousValues.entrySet().stream().forEach(this::setSystemProperty);
+    previousValues = null;
+    super.after();
+  }
+
+  private void setSystemProperty(Map.Entry<String, Optional<String>> keyValue) {
+    if (keyValue.getValue().isPresent()) {
+      System.setProperty(keyValue.getKey(), keyValue.getValue().get());
+    } else {
+      System.clearProperty(keyValue.getKey());
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java
index fff3cb6..b3592e3 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java
@@ -14,9 +14,11 @@
 
 package com.google.gerrit.acceptance.rest.change;
 
+import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_REVISION;
 import static com.google.gerrit.extensions.client.ListChangesOption.MESSAGES;
+import static java.util.Comparator.comparing;
 
 import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.ExtensionRegistry;
@@ -26,6 +28,10 @@
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.FooterConstants;
 import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.Project.NameKey;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.SubmitInput;
 import com.google.gerrit.extensions.client.ChangeStatus;
 import com.google.gerrit.extensions.client.InheritableBoolean;
@@ -403,4 +409,54 @@
 
     assertSubmittable(change2Result.getChangeId());
   }
+
+  @Test
+  public void stickyVoteStoredOnSubmitOnNewPatchset_withoutCopyCondition() throws Exception {
+    try (ProjectConfigUpdate u = updateProject(NameKey.parse("All-Projects"))) {
+      u.getConfig()
+          .updateLabelType(
+              LabelId.CODE_REVIEW, b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
+      u.save();
+    }
+    stickyVoteStoredOnSubmitOnNewPatchset();
+  }
+
+  @Test
+  public void stickyVoteStoredOnSubmitOnNewPatchset_withCopyCondition() throws Exception {
+    // Code-Review will be sticky.
+    try (ProjectConfigUpdate u = updateProject(NameKey.parse("All-Projects"))) {
+      u.getConfig()
+          .updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyCondition("has:unchanged-files"));
+      u.save();
+    }
+    stickyVoteStoredOnSubmitOnNewPatchset();
+  }
+
+  private void stickyVoteStoredOnSubmitOnNewPatchset() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    // Add a new vote.
+    ReviewInput input = new ReviewInput().label(LabelId.CODE_REVIEW, 2);
+    gApi.changes().id(r.getChangeId()).current().review(input);
+
+    // Submit, also keeping the Code-Review +2 vote.
+    gApi.changes().id(r.getChangeId()).current().submit();
+
+    // The last approval is stored on the submitted patch-set which was created by cherry-pick
+    // during submit.
+    PatchSetApproval patchSetApprovals =
+        Iterables.getLast(
+            r.getChange().notes().getApprovalsWithCopied().values().stream()
+                .filter(a -> a.labelId().equals(LabelId.create(LabelId.CODE_REVIEW)))
+                .sorted(comparing(a -> a.patchSetId().get()))
+                .collect(toImmutableList()));
+
+    assertThat(patchSetApprovals.patchSetId().get()).isEqualTo(2);
+    assertThat(patchSetApprovals.label()).isEqualTo(LabelId.CODE_REVIEW);
+    assertThat(patchSetApprovals.value()).isEqualTo((short) 2);
+
+    // The approval is not copied since we don't need to persist copied votes on submit, only
+    // persist votes normally.
+    assertThat(patchSetApprovals.copied()).isFalse();
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByRebaseAlwaysIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByRebaseAlwaysIT.java
index eeeac2a..aa93815 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByRebaseAlwaysIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByRebaseAlwaysIT.java
@@ -14,20 +14,27 @@
 
 package com.google.gerrit.acceptance.rest.change;
 
+import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_REVISION;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static java.util.Comparator.comparing;
 
 import com.google.common.base.Throwables;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.ExtensionRegistry;
 import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestProjectInput;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.FooterConstants;
+import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.Project.NameKey;
 import com.google.gerrit.exceptions.InternalServerWithUserMessageException;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.ChangeInfo;
@@ -159,6 +166,56 @@
     }
   }
 
+  @Test
+  public void stickyVoteStoredOnSubmitOnNewPatchset_withoutCopyCondition() throws Exception {
+    try (ProjectConfigUpdate u = updateProject(NameKey.parse("All-Projects"))) {
+      u.getConfig()
+          .updateLabelType(
+              LabelId.CODE_REVIEW, b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
+      u.save();
+    }
+    stickyVoteStoredOnSubmitOnNewPatchset();
+  }
+
+  @Test
+  public void stickyVoteStoredOnSubmitOnNewPatchset_withCopyCondition() throws Exception {
+    // Code-Review will be sticky.
+    try (ProjectConfigUpdate u = updateProject(NameKey.parse("All-Projects"))) {
+      u.getConfig()
+          .updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyCondition("has:unchanged-files"));
+      u.save();
+    }
+    stickyVoteStoredOnSubmitOnNewPatchset();
+  }
+
+  private void stickyVoteStoredOnSubmitOnNewPatchset() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    // Add a new vote.
+    ReviewInput input = new ReviewInput().label(LabelId.CODE_REVIEW, 2);
+    gApi.changes().id(r.getChangeId()).current().review(input);
+
+    // Submit, also keeping the Code-Review +2 vote.
+    gApi.changes().id(r.getChangeId()).current().submit();
+
+    // The last approval is stored on the submitted patch-set which was created by rebase during
+    // submit.
+    PatchSetApproval patchSetApprovals =
+        Iterables.getLast(
+            r.getChange().notes().getApprovalsWithCopied().values().stream()
+                .filter(a -> a.labelId().equals(LabelId.create(LabelId.CODE_REVIEW)))
+                .sorted(comparing(a -> a.patchSetId().get()))
+                .collect(toImmutableList()));
+
+    assertThat(patchSetApprovals.patchSetId().get()).isEqualTo(2);
+    assertThat(patchSetApprovals.label()).isEqualTo(LabelId.CODE_REVIEW);
+    assertThat(patchSetApprovals.value()).isEqualTo((short) 2);
+
+    // The approval is not copied since we don't need to persist copied votes on submit, only
+    // persist votes normally.
+    assertThat(patchSetApprovals.copied()).isFalse();
+  }
+
   private void assertLatestRevisionHasFooters(PushOneCommit.Result change) throws Throwable {
     RevCommit c = getCurrentCommit(change);
     assertThat(c.getFooterLines(FooterConstants.CHANGE_ID)).isNotEmpty();
diff --git a/javatests/com/google/gerrit/acceptance/testsuite/index/DefaultIndexBindingIT.java b/javatests/com/google/gerrit/acceptance/testsuite/index/DefaultIndexBindingIT.java
index f5e4e09..8e0d4bb 100644
--- a/javatests/com/google/gerrit/acceptance/testsuite/index/DefaultIndexBindingIT.java
+++ b/javatests/com/google/gerrit/acceptance/testsuite/index/DefaultIndexBindingIT.java
@@ -20,29 +20,19 @@
 import com.google.gerrit.index.IndexType;
 import com.google.gerrit.index.testing.AbstractFakeIndex;
 import com.google.gerrit.server.index.change.ChangeIndexCollection;
+import com.google.gerrit.testing.SystemPropertiesTestRule;
 import javax.inject.Inject;
-import org.junit.AfterClass;
-import org.junit.BeforeClass;
+import org.junit.ClassRule;
 import org.junit.Test;
 
 /** Test to check that the expected index backend was bound depending on sys/env properties. */
 public class DefaultIndexBindingIT extends AbstractDaemonTest {
+  @ClassRule
+  public static SystemPropertiesTestRule systemProperties =
+      new SystemPropertiesTestRule(IndexType.SYS_PROP, "");
 
   @Inject private ChangeIndexCollection changeIndex;
 
-  private static String propertyBeforeTest;
-
-  @BeforeClass
-  public static void setup() {
-    propertyBeforeTest = System.getProperty(IndexType.SYS_PROP);
-    System.setProperty(IndexType.SYS_PROP, "");
-  }
-
-  @AfterClass
-  public static void teardown() {
-    System.setProperty(IndexType.SYS_PROP, propertyBeforeTest);
-  }
-
   @Test
   public void fakeIsBoundByDefault() throws Exception {
     assertThat(System.getProperty(IndexType.SYS_PROP)).isEmpty();
diff --git a/javatests/com/google/gerrit/acceptance/testsuite/index/FakeIndexBindingIT.java b/javatests/com/google/gerrit/acceptance/testsuite/index/FakeIndexBindingIT.java
index 4122426..acb2e5a 100644
--- a/javatests/com/google/gerrit/acceptance/testsuite/index/FakeIndexBindingIT.java
+++ b/javatests/com/google/gerrit/acceptance/testsuite/index/FakeIndexBindingIT.java
@@ -20,29 +20,19 @@
 import com.google.gerrit.index.IndexType;
 import com.google.gerrit.index.testing.AbstractFakeIndex;
 import com.google.gerrit.server.index.change.ChangeIndexCollection;
+import com.google.gerrit.testing.SystemPropertiesTestRule;
 import javax.inject.Inject;
-import org.junit.AfterClass;
-import org.junit.BeforeClass;
+import org.junit.ClassRule;
 import org.junit.Test;
 
 /** Test to check that the expected index backend was bound depending on sys/env properties. */
 public class FakeIndexBindingIT extends AbstractDaemonTest {
+  @ClassRule
+  public static SystemPropertiesTestRule systemProperties =
+      new SystemPropertiesTestRule(IndexType.SYS_PROP, "fake");
 
   @Inject private ChangeIndexCollection changeIndex;
 
-  private static String propertyBeforeTest;
-
-  @BeforeClass
-  public static void setup() {
-    propertyBeforeTest = System.getProperty(IndexType.SYS_PROP);
-    System.setProperty(IndexType.SYS_PROP, "fake");
-  }
-
-  @AfterClass
-  public static void teardown() {
-    System.setProperty(IndexType.SYS_PROP, propertyBeforeTest);
-  }
-
   @Test
   public void fakeIsBoundWhenConfigured() throws Exception {
     assertThat(System.getProperty(IndexType.SYS_PROP)).isEqualTo("fake");
diff --git a/javatests/com/google/gerrit/acceptance/testsuite/index/LuceneIndexBindingIT.java b/javatests/com/google/gerrit/acceptance/testsuite/index/LuceneIndexBindingIT.java
index 31e31fd..5dd6f01 100644
--- a/javatests/com/google/gerrit/acceptance/testsuite/index/LuceneIndexBindingIT.java
+++ b/javatests/com/google/gerrit/acceptance/testsuite/index/LuceneIndexBindingIT.java
@@ -20,29 +20,19 @@
 import com.google.gerrit.index.IndexType;
 import com.google.gerrit.lucene.LuceneChangeIndex;
 import com.google.gerrit.server.index.change.ChangeIndexCollection;
+import com.google.gerrit.testing.SystemPropertiesTestRule;
 import javax.inject.Inject;
-import org.junit.AfterClass;
-import org.junit.BeforeClass;
+import org.junit.ClassRule;
 import org.junit.Test;
 
 /** Test to check that the expected index backend was bound depending on sys/env properties. */
 public class LuceneIndexBindingIT extends AbstractDaemonTest {
+  @ClassRule
+  public static SystemPropertiesTestRule systemProperties =
+      new SystemPropertiesTestRule(IndexType.SYS_PROP, "lucene");
 
   @Inject private ChangeIndexCollection changeIndex;
 
-  private static String propertyBeforeTest;
-
-  @BeforeClass
-  public static void setup() {
-    propertyBeforeTest = System.getProperty(IndexType.SYS_PROP);
-    System.setProperty(IndexType.SYS_PROP, "lucene");
-  }
-
-  @AfterClass
-  public static void teardown() {
-    System.setProperty(IndexType.SYS_PROP, propertyBeforeTest);
-  }
-
   @Test
   public void luceneIsBoundWhenConfigured() throws Exception {
     assertThat(System.getProperty(IndexType.SYS_PROP)).isEqualTo("lucene");
diff --git a/javatests/com/google/gerrit/server/notedb/CommitRewriterTest.java b/javatests/com/google/gerrit/server/notedb/CommitRewriterTest.java
index 056c7dc..98721fd 100644
--- a/javatests/com/google/gerrit/server/notedb/CommitRewriterTest.java
+++ b/javatests/com/google/gerrit/server/notedb/CommitRewriterTest.java
@@ -24,6 +24,7 @@
 import static java.util.Objects.requireNonNull;
 
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.entities.AttentionSetUpdate.Operation;
@@ -33,6 +34,7 @@
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.entities.SubmitRecord;
+import com.google.gerrit.git.RefUpdateUtil;
 import com.google.gerrit.json.OutputFormat;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
@@ -48,7 +50,9 @@
 import java.sql.Timestamp;
 import java.util.Arrays;
 import java.util.List;
+import java.util.Map;
 import java.util.stream.IntStream;
+import org.eclipse.jgit.lib.BatchRefUpdate;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Ref;
@@ -56,6 +60,8 @@
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevSort;
 import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.transport.ReceiveCommand;
+import org.junit.After;
 import org.junit.Before;
 import org.junit.Test;
 
@@ -70,6 +76,21 @@
   @Before
   public void setUp() throws Exception {}
 
+  @After
+  public void cleanUp() throws Exception {
+    BatchRefUpdate bru = repo.getRefDatabase().newBatchUpdate();
+    bru.setAllowNonFastForwards(true);
+    for (Ref ref : repo.getRefDatabase().getRefsByPrefix(RefNames.REFS_CHANGES)) {
+      Change.Id changeId = Change.Id.fromRef(ref.getName());
+      if (changeId == null || !ref.getName().equals(RefNames.changeMetaRef(changeId))) {
+        continue;
+      }
+      bru.addCommand(new ReceiveCommand(ref.getObjectId(), ObjectId.zeroId(), ref.getName()));
+    }
+
+    RefUpdateUtil.executeChecked(bru, repo);
+  }
+
   @Test
   public void validHistoryNoOp() throws Exception {
     String tag = "jenkins";
@@ -157,6 +178,138 @@
   }
 
   @Test
+  public void numRefs_greater_maxRefsToUpdate_allFixed() throws Exception {
+    int numberOfChanges = 12;
+    ImmutableMap.Builder<String, Ref> refsToOldMetaBuilder = new ImmutableMap.Builder<>();
+    for (int i = 0; i < numberOfChanges; i++) {
+      Change c = newChange();
+      ChangeUpdate update = newUpdate(c, changeOwner);
+      update.setChangeMessage("Change has been successfully merged by " + changeOwner.getName());
+      update.commit();
+      ChangeUpdate updateWithSubject = newUpdate(c, changeOwner);
+      updateWithSubject.setSubjectForCommit("Update with subject");
+      updateWithSubject.commit();
+      String refName = RefNames.changeMetaRef(c.getId());
+      Ref metaRefBeforeRewrite = repo.exactRef(refName);
+      refsToOldMetaBuilder.put(refName, metaRefBeforeRewrite);
+    }
+    ImmutableMap<String, Ref> refsToOldMeta = refsToOldMetaBuilder.build();
+
+    RunOptions options = new RunOptions();
+    options.dryRun = false;
+    options.outputDiff = false;
+    options.verifyCommits = false;
+    options.maxRefsInBatch = 10;
+    options.maxRefsToUpdate = 12;
+    BackfillResult backfillResult = rewriter.backfillProject(project, repo, options);
+    assertThat(backfillResult.fixedRefDiff.keySet()).isEqualTo(refsToOldMeta.keySet());
+    for (Map.Entry<String, Ref> refEntry : refsToOldMeta.entrySet()) {
+      Ref metaRefAfterRewrite = repo.exactRef(refEntry.getKey());
+      assertThat(refEntry.getValue()).isNotEqualTo(metaRefAfterRewrite);
+    }
+  }
+
+  @Test
+  public void maxRefsToUpdate_coversAllInvalid_inMultipleBatches() throws Exception {
+    testMaxRefsToUpdate(
+        /*numberOfInvalidChanges=*/ 11,
+        /*numberOfValidChanges=*/ 9,
+        /*maxRefsToUpdate=*/ 12,
+        /*maxRefsInBatch=*/ 2);
+  }
+
+  @Test
+  public void maxRefsToUpdate_coversAllInvalid_inSingleBatch() throws Exception {
+    testMaxRefsToUpdate(
+        /*numberOfInvalidChanges=*/ 11,
+        /*numberOfValidChanges=*/ 9,
+        /*maxRefsToUpdate=*/ 12,
+        /*maxRefsInBatch=*/ 12);
+  }
+
+  @Test
+  public void moreInvalidRefs_thenMaxRefsToUpdate_inMultipleBatches() throws Exception {
+    testMaxRefsToUpdate(
+        /*numberOfInvalidChanges=*/ 11,
+        /*numberOfValidChanges=*/ 9,
+        /*maxRefsToUpdate=*/ 10,
+        /*maxRefsInBatch=*/ 2);
+  }
+
+  @Test
+  public void moreInvalidRefs_thenMaxRefsToUpdate_inSingleBatch() throws Exception {
+    testMaxRefsToUpdate(
+        /*numberOfInvalidChanges=*/ 11,
+        /*numberOfValidChanges=*/ 9,
+        /*maxRefsToUpdate=*/ 10,
+        /*maxRefsInBatch=*/ 10);
+  }
+
+  private void testMaxRefsToUpdate(
+      int numberOfInvalidChanges, int numberOfValidChanges, int maxRefsToUpdate, int maxRefsInBatch)
+      throws Exception {
+    ImmutableMap.Builder<String, ObjectId> expectedFixedRefsToOldMetaBuilder =
+        new ImmutableMap.Builder<>();
+    ImmutableMap.Builder<String, ObjectId> expectedSkippedRefsToOldMetaBuilder =
+        new ImmutableMap.Builder<>();
+    for (int i = 0; i < numberOfValidChanges; i++) {
+      Change c = newChange();
+      ChangeUpdate updateWithSubject = newUpdate(c, changeOwner);
+      updateWithSubject.setSubjectForCommit("Update with subject");
+      updateWithSubject.commit();
+      String refName = RefNames.changeMetaRef(c.getId());
+      Ref metaRefBeforeRewrite = repo.exactRef(refName);
+      expectedSkippedRefsToOldMetaBuilder.put(refName, metaRefBeforeRewrite.getObjectId());
+    }
+    for (int i = 0; i < numberOfInvalidChanges; i++) {
+      Change c = newChange();
+      ChangeUpdate update = newUpdate(c, changeOwner);
+      update.setChangeMessage("Change has been successfully merged by " + changeOwner.getName());
+      update.commit();
+      ChangeUpdate updateWithSubject = newUpdate(c, changeOwner);
+      updateWithSubject.setSubjectForCommit("Update with subject");
+      updateWithSubject.commit();
+      String refName = RefNames.changeMetaRef(c.getId());
+      Ref metaRefBeforeRewrite = repo.exactRef(refName);
+      if (i < maxRefsToUpdate) {
+        expectedFixedRefsToOldMetaBuilder.put(refName, metaRefBeforeRewrite.getObjectId());
+      } else {
+        expectedSkippedRefsToOldMetaBuilder.put(refName, metaRefBeforeRewrite.getObjectId());
+      }
+    }
+    ImmutableMap<String, ObjectId> expectedFixedRefsToOldMeta =
+        expectedFixedRefsToOldMetaBuilder.build();
+    ImmutableMap<String, ObjectId> expectedSkippedRefsToOldMeta =
+        expectedSkippedRefsToOldMetaBuilder.build();
+    RunOptions options = new RunOptions();
+    options.dryRun = false;
+    options.outputDiff = false;
+    options.verifyCommits = false;
+    options.maxRefsInBatch = maxRefsInBatch;
+    options.maxRefsToUpdate = maxRefsToUpdate;
+    BackfillResult backfillResult = rewriter.backfillProject(project, repo, options);
+    assertThat(backfillResult.fixedRefDiff.keySet()).isEqualTo(expectedFixedRefsToOldMeta.keySet());
+    for (Map.Entry<String, ObjectId> refEntry : expectedFixedRefsToOldMeta.entrySet()) {
+      Ref metaRefAfterRewrite = repo.exactRef(refEntry.getKey());
+      assertThat(refEntry.getValue()).isNotEqualTo(metaRefAfterRewrite.getObjectId());
+    }
+    for (Map.Entry<String, ObjectId> refEntry : expectedSkippedRefsToOldMeta.entrySet()) {
+      Ref metaRefAfterRewrite = repo.exactRef(refEntry.getKey());
+      assertThat(refEntry.getValue()).isEqualTo(metaRefAfterRewrite.getObjectId());
+    }
+    RunOptions secondRunOptions = new RunOptions();
+    secondRunOptions.dryRun = false;
+    secondRunOptions.outputDiff = false;
+    secondRunOptions.verifyCommits = false;
+    secondRunOptions.maxRefsInBatch = maxRefsInBatch;
+    secondRunOptions.maxRefsToUpdate = numberOfInvalidChanges + numberOfValidChanges;
+    BackfillResult secondRunResult = rewriter.backfillProject(project, repo, options);
+    int expectedSecondRunResult =
+        numberOfInvalidChanges > maxRefsToUpdate ? numberOfInvalidChanges - maxRefsToUpdate : 0;
+    assertThat(secondRunResult.fixedRefDiff.keySet().size()).isEqualTo(expectedSecondRunResult);
+  }
+
+  @Test
   public void fixAuthorIdent() throws Exception {
     Change c = newChange();
     Timestamp when = TimeUtil.nowTs();
diff --git a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
index 9ebee9c..65b725b 100644
--- a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
+++ b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
@@ -1726,6 +1726,8 @@
       assertQuery(predicate + "\"2009-10-01 21:02:00\"", change1);
       assertQuery(predicate + "2009-10-01", change1);
       assertQuery(predicate + "2009-10-03", change2, change1);
+      assertQuery(predicate + "\"2009-09-30 21:00:00 -0000\"", change1);
+      assertQuery(predicate + "\"2009-10-02 03:00:00 -0000\"", change2, change1);
     }
 
     // Same test as above, but using filter code path.
@@ -1742,6 +1744,12 @@
       assertQuery(makeIndexedPredicateFilterQuery(predicate + "\"2009-10-01 21:02:00\""), change1);
       assertQuery(makeIndexedPredicateFilterQuery(predicate + "2009-10-01"), change1);
       assertQuery(makeIndexedPredicateFilterQuery(predicate + "2009-10-03"), change2, change1);
+      assertQuery(
+          makeIndexedPredicateFilterQuery(predicate + "\"2009-09-30 21:00:00 -0000\""), change1);
+      assertQuery(
+          makeIndexedPredicateFilterQuery(predicate + "\"2009-10-02 03:00:00 -0000\""),
+          change2,
+          change1);
     }
   }
 
@@ -1763,6 +1771,8 @@
       assertQuery(predicate + "\"2009-10-01 20:59:59 -0000\"", change2);
       assertQuery(predicate + "2009-10-01", change2);
       assertQuery(predicate + "2009-09-30", change2, change1);
+      assertQuery(predicate + "\"2009-09-30 21:00:00 -0000\"", change2, change1);
+      assertQuery(predicate + "\"2009-10-02 03:00:00 -0000\"", change2);
     }
 
     // Same test as above, but using filter code path.
@@ -1774,6 +1784,12 @@
           makeIndexedPredicateFilterQuery(predicate + "\"2009-10-01 20:59:59 -0000\""), change2);
       assertQuery(makeIndexedPredicateFilterQuery(predicate + "2009-10-01"), change2);
       assertQuery(makeIndexedPredicateFilterQuery(predicate + "2009-09-30"), change2, change1);
+      assertQuery(
+          makeIndexedPredicateFilterQuery(predicate + "\"2009-09-30 21:00:00 -0000\""),
+          change2,
+          change1);
+      assertQuery(
+          makeIndexedPredicateFilterQuery(predicate + "\"2009-10-02 03:00:00 -0000\""), change2);
     }
   }
 
diff --git a/javatests/com/google/gerrit/testing/BUILD b/javatests/com/google/gerrit/testing/BUILD
index 5774707..9443b0d 100644
--- a/javatests/com/google/gerrit/testing/BUILD
+++ b/javatests/com/google/gerrit/testing/BUILD
@@ -7,6 +7,7 @@
     deps = [
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/testing:gerrit-test-util",
+        "//lib:jgit",
         "//lib/truth",
     ],
 )
diff --git a/javatests/com/google/gerrit/testing/ConfigSuiteTest.java b/javatests/com/google/gerrit/testing/ConfigSuiteTest.java
new file mode 100644
index 0000000..1ec30da
--- /dev/null
+++ b/javatests/com/google/gerrit/testing/ConfigSuiteTest.java
@@ -0,0 +1,266 @@
+// Copyright (C) 2021 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.
+
+package com.google.gerrit.testing;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.verify;
+
+import com.google.gerrit.testing.ConfigSuite.AfterConfig;
+import com.google.gerrit.testing.ConfigSuite.BeforeConfig;
+import com.google.gerrit.testing.ConfigSuite.ConfigRule;
+import org.eclipse.jgit.lib.Config;
+import org.junit.After;
+import org.junit.AfterClass;
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.ClassRule;
+import org.junit.Test;
+import org.junit.rules.TestRule;
+import org.junit.runner.Description;
+import org.junit.runner.Result;
+import org.junit.runner.RunWith;
+import org.junit.runner.notification.RunNotifier;
+import org.junit.runners.model.Statement;
+import org.mockito.InOrder;
+import org.mockito.Mock;
+import org.mockito.Mockito;
+import org.mockito.junit.MockitoJUnitRunner;
+
+@RunWith(MockitoJUnitRunner.class)
+public class ConfigSuiteTest {
+  @Mock private ConfigBasedTestListener configBasedTestListener;
+  private RunNotifier notifier;
+
+  @Before
+  public void setUp() {
+    notifier = new RunNotifier();
+    ConfigBasedTest.setConfigBasedTestListener(configBasedTestListener);
+  }
+
+  @After
+  public void tearDown() {
+    ConfigBasedTest.setConfigBasedTestListener(null);
+  }
+
+  @Test
+  public void methodsExecuteInCorrectOrder() throws Exception {
+    InOrder inOrder = Mockito.inOrder(configBasedTestListener);
+    new ConfigSuite(ConfigBasedTest.class).run(notifier);
+    inOrder.verify(configBasedTestListener, Mockito.times(1)).classRuleExecuted();
+    inOrder.verify(configBasedTestListener, Mockito.times(1)).beforeClassExecuted();
+    // default config
+    inOrder.verify(configBasedTestListener, Mockito.times(1)).configRuleExectued();
+    inOrder.verify(configBasedTestListener, Mockito.times(1)).beforeConfigExecuted();
+    inOrder.verify(configBasedTestListener, Mockito.times(2)).testExecuted(any(), any(), any());
+    inOrder.verify(configBasedTestListener, Mockito.times(1)).afterConfigExecuted();
+    // first config
+    inOrder.verify(configBasedTestListener, Mockito.times(1)).configRuleExectued();
+    inOrder.verify(configBasedTestListener, Mockito.times(1)).beforeConfigExecuted();
+    inOrder.verify(configBasedTestListener, Mockito.times(2)).testExecuted(any(), any(), any());
+    inOrder.verify(configBasedTestListener, Mockito.times(1)).afterConfigExecuted();
+    // second config
+    inOrder.verify(configBasedTestListener, Mockito.times(1)).configRuleExectued();
+    inOrder.verify(configBasedTestListener, Mockito.times(1)).beforeConfigExecuted();
+    inOrder.verify(configBasedTestListener, Mockito.times(2)).testExecuted(any(), any(), any());
+    inOrder.verify(configBasedTestListener, Mockito.times(1)).afterConfigExecuted();
+
+    inOrder.verify(configBasedTestListener, Mockito.times(1)).afterClassExecuted();
+  }
+
+  @Test
+  public void beforeClassExecutedOnce() throws Exception {
+    new ConfigSuite(ConfigBasedTest.class).run(notifier);
+    verify(configBasedTestListener, Mockito.times(1)).beforeClassExecuted();
+  }
+
+  @Test
+  public void afterClassExecutedOnce() throws Exception {
+    new ConfigSuite(ConfigBasedTest.class).run(notifier);
+    verify(configBasedTestListener, Mockito.times(1)).afterClassExecuted();
+  }
+
+  @Test
+  public void classRuleExecutedOnlyOnce() throws Exception {
+    new ConfigSuite(ConfigBasedTest.class).run(notifier);
+    verify(configBasedTestListener, Mockito.times(1)).classRuleExecuted();
+  }
+
+  @Test
+  public void beforeConfigExecutedForEachConfig() throws Exception {
+    new ConfigSuite(ConfigBasedTest.class).run(notifier);
+    // default, firstConfig, secondConfig
+    verify(configBasedTestListener, Mockito.times(3)).beforeConfigExecuted();
+  }
+
+  @Test
+  public void afterConfigExecutedForEachConfig() throws Exception {
+    new ConfigSuite(ConfigBasedTest.class).run(notifier);
+    // default, firstConfig, secondConfig
+    verify(configBasedTestListener, Mockito.times(3)).afterConfigExecuted();
+  }
+
+  @Test
+  public void configRuleExecutedForEachConfig() throws Exception {
+    new ConfigSuite(ConfigBasedTest.class).run(notifier);
+    // default, firstConfig, secondConfig
+    verify(configBasedTestListener, Mockito.times(3)).afterConfigExecuted();
+  }
+
+  @Test
+  public void testsExecuteWithCorrectConfigAndName() throws Exception {
+    new ConfigSuite(ConfigBasedTest.class).run(notifier);
+    verify(configBasedTestListener, Mockito.times(6)).testExecuted(any(), any(), any());
+
+    verify(configBasedTestListener, Mockito.times(1)).testExecuted("test1", "default", null);
+    verify(configBasedTestListener, Mockito.times(1)).testExecuted("test2", "default", null);
+    verify(configBasedTestListener, Mockito.times(1))
+        .testExecuted("test1", "firstValue", "firstConfig");
+    verify(configBasedTestListener, Mockito.times(1))
+        .testExecuted("test2", "firstValue", "firstConfig");
+    verify(configBasedTestListener, Mockito.times(1))
+        .testExecuted("test1", "secondValue", "secondConfig");
+    verify(configBasedTestListener, Mockito.times(1))
+        .testExecuted("test2", "secondValue", "secondConfig");
+  }
+
+  @Test
+  public void testResultWasSuccessful() throws Exception {
+    Result result = new Result();
+    notifier.addListener(result.createListener());
+    new ConfigSuite(ConfigBasedTest.class).run(notifier);
+    assertThat(result.wasSuccessful()).isTrue();
+  }
+
+  @Test
+  public void failedTestResultNotMissed() throws Exception {
+    Result result = new Result();
+    notifier.addListener(result.createListener());
+    new ConfigSuite(FailedConfigBasedTest.class).run(notifier);
+    // 3 fails with 3 different configs
+    assertThat(result.wasSuccessful()).isFalse();
+    assertThat(result.getFailureCount()).isEqualTo(3);
+  }
+
+  interface ConfigBasedTestListener {
+    void beforeClassExecuted();
+
+    void afterClassExecuted();
+
+    void classRuleExecuted();
+
+    void configRuleExectued();
+
+    void testExecuted(String testName, String testValue, String configName);
+
+    void beforeConfigExecuted();
+
+    void afterConfigExecuted();
+  }
+
+  public static class ConfigBasedTest {
+    private static ConfigBasedTestListener listener;
+
+    public static void setConfigBasedTestListener(ConfigBasedTestListener listener) {
+      ConfigBasedTest.listener = listener;
+    }
+
+    @BeforeClass
+    public static void beforeClass() {
+      listener.beforeClassExecuted();
+    }
+
+    @AfterClass
+    public static void afterClass() {
+      listener.afterClassExecuted();
+    }
+
+    @ClassRule
+    public static TestRule classRule =
+        new TestRule() {
+          @Override
+          public Statement apply(Statement statement, Description description) {
+            return new Statement() {
+              @Override
+              public void evaluate() throws Throwable {
+                listener.classRuleExecuted();
+                statement.evaluate();
+              }
+            };
+          }
+        };
+
+    @ConfigRule
+    public static TestRule configRule =
+        new TestRule() {
+          @Override
+          public Statement apply(Statement statement, Description description) {
+            return new Statement() {
+              @Override
+              public void evaluate() throws Throwable {
+                listener.configRuleExectued();
+                statement.evaluate();
+              }
+            };
+          }
+        };
+
+    @BeforeConfig
+    public static void beforeConfig() {
+      listener.beforeConfigExecuted();
+    }
+
+    @AfterConfig
+    public static void afterConfig() {
+      listener.afterConfigExecuted();
+    }
+
+    @ConfigSuite.Config
+    public static Config firstConfig() {
+      Config cfg = new Config();
+      cfg.setString("gerrit", null, "testValue", "firstValue");
+      return cfg;
+    }
+
+    @ConfigSuite.Config
+    public static Config secondConfig() {
+      Config cfg = new Config();
+      cfg.setString("gerrit", null, "testValue", "secondValue");
+      return cfg;
+    }
+
+    @ConfigSuite.Parameter public Config config;
+    @ConfigSuite.Name public String name;
+
+    @Test
+    public void test1() {
+      String testValue = config.getString("gerrit", null, "testValue");
+      listener.testExecuted("test1", testValue != null ? testValue : "default", name);
+    }
+
+    @Test
+    public void test2() {
+      String testValue = config.getString("gerrit", null, "testValue");
+      listener.testExecuted("test2", testValue != null ? testValue : "default", name);
+    }
+  }
+
+  public static class FailedConfigBasedTest extends ConfigBasedTest {
+    @Test
+    public void failedTest() {
+      assertThat(true).isFalse();
+    }
+  }
+}
diff --git a/tools/BUILD b/tools/BUILD
index e44ae78..36507a9 100644
--- a/tools/BUILD
+++ b/tools/BUILD
@@ -240,8 +240,8 @@
         "-Xep:InstanceOfAndCastMatchWrongType:ERROR",
         "-Xep:InstantTemporalUnit:ERROR",
         "-Xep:IntLongMath:ERROR",
-        # "-Xep:InvalidBlockTag:WARN",
-        "-Xep:InvalidInlineTag:WARN",
+        "-Xep:InvalidBlockTag:ERROR",
+        "-Xep:InvalidInlineTag:ERROR",
         "-Xep:InvalidJavaTimeConstant:ERROR",
         "-Xep:InvalidLink:ERROR",
         # "-Xep:InvalidParam:WARN",