Merge "Allow hovercard for suggested owners"
diff --git a/java/com/google/gerrit/plugins/codeowners/acceptance/AbstractCodeOwnersTest.java b/java/com/google/gerrit/plugins/codeowners/acceptance/AbstractCodeOwnersTest.java
index 2f1be86..0ed3041 100644
--- a/java/com/google/gerrit/plugins/codeowners/acceptance/AbstractCodeOwnersTest.java
+++ b/java/com/google/gerrit/plugins/codeowners/acceptance/AbstractCodeOwnersTest.java
@@ -16,6 +16,7 @@
 
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.acceptance.GitUtil;
 import com.google.gerrit.acceptance.LightweightPluginDaemonTest;
@@ -35,11 +36,13 @@
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.plugins.codeowners.JgitPath;
 import com.google.gerrit.plugins.codeowners.acceptance.testsuite.CodeOwnerConfigOperations;
+import com.google.gerrit.plugins.codeowners.acceptance.testsuite.TestCodeOwnerConfigCreation.Builder;
 import com.google.gerrit.plugins.codeowners.config.CodeOwnersPluginConfiguration;
 import com.google.gerrit.plugins.codeowners.config.StatusConfig;
 import com.google.inject.Inject;
 import java.nio.file.Path;
 import java.util.Map;
+import java.util.function.Consumer;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
@@ -145,9 +148,30 @@
   protected void setCodeOwnersConfig(
       Project.NameKey project, @Nullable String subsection, String key, String value)
       throws Exception {
+    updateCodeOwnersConfig(
+        project,
+        codeOwnersConfig ->
+            codeOwnersConfig.setString(
+                CodeOwnersPluginConfiguration.SECTION_CODE_OWNERS, subsection, key, value));
+  }
+
+  protected void setCodeOwnersConfig(
+      Project.NameKey project,
+      @Nullable String subsection,
+      String key,
+      ImmutableList<String> values)
+      throws Exception {
+    updateCodeOwnersConfig(
+        project,
+        codeOwnersConfig ->
+            codeOwnersConfig.setStringList(
+                CodeOwnersPluginConfiguration.SECTION_CODE_OWNERS, subsection, key, values));
+  }
+
+  private void updateCodeOwnersConfig(Project.NameKey project, Consumer<Config> configUpdater)
+      throws Exception {
     Config codeOwnersConfig = new Config();
-    codeOwnersConfig.setString(
-        CodeOwnersPluginConfiguration.SECTION_CODE_OWNERS, subsection, key, value);
+    configUpdater.accept(codeOwnersConfig);
     try (TestRepository<Repository> testRepo =
         new TestRepository<>(repoManager.openRepository(project))) {
       Ref ref = testRepo.getRepository().exactRef(RefNames.REFS_CONFIG);
@@ -164,16 +188,20 @@
   }
 
   protected void createOwnersOverrideLabel() throws RestApiException {
+    createOwnersOverrideLabel("Owners-Override");
+  }
+
+  protected void createOwnersOverrideLabel(String labelName) throws RestApiException {
     LabelDefinitionInput input = new LabelDefinitionInput();
     input.values = ImmutableMap.of("+1", "Override", " 0", "No Override");
-    gApi.projects().name(project.get()).label("Owners-Override").create(input).get();
+    gApi.projects().name(project.get()).label(labelName).create(input).get();
 
     // Allow to vote on the Owners-Override label.
     projectOperations
         .project(project)
         .forUpdate()
         .add(
-            TestProjectUpdate.allowLabel("Owners-Override")
+            TestProjectUpdate.allowLabel(labelName)
                 .range(0, 1)
                 .ref("refs/*")
                 .group(REGISTERED_USERS)
@@ -203,6 +231,69 @@
   }
 
   /**
+   * Creates a non-parseable code owner config file at the given path.
+   *
+   * @param path path of the code owner config file
+   */
+  protected void createNonParseableCodeOwnerConfig(String path) throws Exception {
+    disableCodeOwnersForProject(project);
+    String changeId =
+        createChange("Add invalid code owners file", JgitPath.of(path).get(), "INVALID")
+            .getChangeId();
+    approve(changeId);
+    gApi.changes().id(changeId).current().submit();
+    enableCodeOwnersForProject(project);
+  }
+
+  /**
+   * Creates a default code owner config with the given test accounts as code owners.
+   *
+   * @param testAccounts the accounts of the users that should be code owners
+   */
+  protected void setAsDefaultCodeOwners(TestAccount... testAccounts) {
+    setAsCodeOwners(RefNames.REFS_CONFIG, "/", testAccounts);
+  }
+
+  /**
+   * Creates a root code owner config with the given test accounts as code owners.
+   *
+   * @param testAccounts the accounts of the users that should be code owners
+   */
+  protected void setAsRootCodeOwners(TestAccount... testAccounts) {
+    setAsCodeOwners("/", testAccounts);
+  }
+
+  /**
+   * Creates a code owner config at the given path with the given test accounts as code owners.
+   *
+   * @param path the path of the code owner config file
+   * @param testAccounts the accounts of the users that should be code owners
+   */
+  protected void setAsCodeOwners(String path, TestAccount... testAccounts) {
+    setAsCodeOwners("master", path, testAccounts);
+  }
+
+  /**
+   * Creates a code owner config at the given path with the given test accounts as code owners.
+   *
+   * @param branchName the name of the branch in which the code owner config should be created
+   * @param path the path of the code owner config file
+   * @param testAccounts the accounts of the users that should be code owners
+   */
+  private void setAsCodeOwners(String branchName, String path, TestAccount... testAccounts) {
+    Builder newCodeOwnerConfigBuilder =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(project)
+            .branch(branchName)
+            .folderPath(path);
+    for (TestAccount testAccount : testAccounts) {
+      newCodeOwnerConfigBuilder.addCodeOwnerEmail(testAccount.email());
+    }
+    newCodeOwnerConfigBuilder.create();
+  }
+
+  /**
    * Creates a new change for the given test account.
    *
    * @param testAccount the account that should own the new change
diff --git a/java/com/google/gerrit/plugins/codeowners/acceptance/testsuite/TestPathExpressions.java b/java/com/google/gerrit/plugins/codeowners/acceptance/testsuite/TestPathExpressions.java
index ac9ee4d..b0ebb67 100644
--- a/java/com/google/gerrit/plugins/codeowners/acceptance/testsuite/TestPathExpressions.java
+++ b/java/com/google/gerrit/plugins/codeowners/acceptance/testsuite/TestPathExpressions.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.plugins.codeowners.acceptance.testsuite;
 
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerBackend;
+import com.google.gerrit.plugins.codeowners.backend.FindOwnersGlobMatcher;
 import com.google.gerrit.plugins.codeowners.backend.GlobMatcher;
 import com.google.gerrit.plugins.codeowners.backend.PathExpressionMatcher;
 import com.google.gerrit.plugins.codeowners.backend.SimplePathExpressionMatcher;
@@ -47,6 +48,7 @@
   public String matchFileTypeInCurrentFolder(String fileType) {
     PathExpressionMatcher pathExpressionMatcher = getPathExpressionMatcher();
     if (pathExpressionMatcher instanceof GlobMatcher
+        || pathExpressionMatcher instanceof FindOwnersGlobMatcher
         || pathExpressionMatcher instanceof SimplePathExpressionMatcher) {
       return "*." + fileType;
     }
@@ -63,7 +65,8 @@
    */
   public String matchAllFilesInSubfolder(String subfolder) {
     PathExpressionMatcher pathExpressionMatcher = getPathExpressionMatcher();
-    if (pathExpressionMatcher instanceof GlobMatcher) {
+    if (pathExpressionMatcher instanceof GlobMatcher
+        || pathExpressionMatcher instanceof FindOwnersGlobMatcher) {
       return subfolder + "/**";
     } else if (pathExpressionMatcher instanceof SimplePathExpressionMatcher) {
       return subfolder + "/...";
diff --git a/java/com/google/gerrit/plugins/codeowners/api/BranchCodeOwners.java b/java/com/google/gerrit/plugins/codeowners/api/BranchCodeOwners.java
index 1836ca9..22bc543 100644
--- a/java/com/google/gerrit/plugins/codeowners/api/BranchCodeOwners.java
+++ b/java/com/google/gerrit/plugins/codeowners/api/BranchCodeOwners.java
@@ -32,7 +32,20 @@
   CodeOwnerConfigFilesRequest codeOwnerConfigFiles() throws RestApiException;
 
   abstract class CodeOwnerConfigFilesRequest {
+    private boolean includeNonParsableFiles;
     private String email;
+    private String path;
+
+    /** Includes non-parsable code owner config files into the result. */
+    public CodeOwnerConfigFilesRequest includeNonParsableFiles(boolean includeNonParsableFiles) {
+      this.includeNonParsableFiles = includeNonParsableFiles;
+      return this;
+    }
+
+    /** Whether non-parsable code owner config files should be included into the result. */
+    public boolean getIncludeNonParsableFiles() {
+      return includeNonParsableFiles;
+    }
 
     /**
      * Limits the returned code owner config files to those that contain the given email.
@@ -50,10 +63,31 @@
       return email;
     }
 
+    /**
+     * Limits the returned code owner config files to those that have a path matching the given
+     * glob.
+     *
+     * @param path the path glob that should be matched
+     */
+    public CodeOwnerConfigFilesRequest withPath(String path) {
+      this.path = path;
+      return this;
+    }
+
+    /** Returns the path glob that should be matched by the returned code owner config files/ */
+    @Nullable
+    public String getPath() {
+      return path;
+    }
+
     /** Executes the request and retrieves the paths of the requested code owner config file */
     public abstract List<String> paths() throws RestApiException;
   }
 
+  /** Renames an email in the code owner config files of the branch. */
+  RenameEmailResultInfo renameEmailInCodeOwnerConfigFiles(RenameEmailInput input)
+      throws RestApiException;
+
   /**
    * A default implementation which allows source compatibility when adding new methods to the
    * interface.
@@ -68,5 +102,11 @@
     public CodeOwnerConfigFilesRequest codeOwnerConfigFiles() throws RestApiException {
       throw new NotImplementedException();
     }
+
+    @Override
+    public RenameEmailResultInfo renameEmailInCodeOwnerConfigFiles(RenameEmailInput input)
+        throws RestApiException {
+      throw new NotImplementedException();
+    }
   }
 }
diff --git a/java/com/google/gerrit/plugins/codeowners/api/BranchCodeOwnersImpl.java b/java/com/google/gerrit/plugins/codeowners/api/BranchCodeOwnersImpl.java
index 6ed89a9..7052a1b 100644
--- a/java/com/google/gerrit/plugins/codeowners/api/BranchCodeOwnersImpl.java
+++ b/java/com/google/gerrit/plugins/codeowners/api/BranchCodeOwnersImpl.java
@@ -19,6 +19,7 @@
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.plugins.codeowners.restapi.GetCodeOwnerBranchConfig;
 import com.google.gerrit.plugins.codeowners.restapi.GetCodeOwnerConfigFiles;
+import com.google.gerrit.plugins.codeowners.restapi.RenameEmail;
 import com.google.gerrit.server.project.BranchResource;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -33,15 +34,18 @@
 
   private final GetCodeOwnerBranchConfig getCodeOwnerBranchConfig;
   private final Provider<GetCodeOwnerConfigFiles> getCodeOwnerConfigFilesProvider;
+  private final RenameEmail renameEmail;
   private final BranchResource branchResource;
 
   @Inject
   public BranchCodeOwnersImpl(
       GetCodeOwnerBranchConfig getCodeOwnerBranchConfig,
       Provider<GetCodeOwnerConfigFiles> getCodeOwnerConfigFilesProvider,
+      RenameEmail renameEmail,
       @Assisted BranchResource branchResource) {
     this.getCodeOwnerConfigFilesProvider = getCodeOwnerConfigFilesProvider;
     this.getCodeOwnerBranchConfig = getCodeOwnerBranchConfig;
+    this.renameEmail = renameEmail;
     this.branchResource = branchResource;
   }
 
@@ -60,9 +64,21 @@
       @Override
       public List<String> paths() throws RestApiException {
         GetCodeOwnerConfigFiles getCodeOwnerConfigFiles = getCodeOwnerConfigFilesProvider.get();
+        getCodeOwnerConfigFiles.setIncludeNonParsableFiles(getIncludeNonParsableFiles());
         getCodeOwnerConfigFiles.setEmail(getEmail());
+        getCodeOwnerConfigFiles.setPath(getPath());
         return getCodeOwnerConfigFiles.apply(branchResource).value();
       }
     };
   }
+
+  @Override
+  public RenameEmailResultInfo renameEmailInCodeOwnerConfigFiles(RenameEmailInput input)
+      throws RestApiException {
+    try {
+      return renameEmail.apply(branchResource, input).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot rename email", e);
+    }
+  }
 }
diff --git a/java/com/google/gerrit/plugins/codeowners/api/CodeOwnerBranchConfigInfo.java b/java/com/google/gerrit/plugins/codeowners/api/CodeOwnerBranchConfigInfo.java
index c0ae04b..6fb3fc7 100644
--- a/java/com/google/gerrit/plugins/codeowners/api/CodeOwnerBranchConfigInfo.java
+++ b/java/com/google/gerrit/plugins/codeowners/api/CodeOwnerBranchConfigInfo.java
@@ -13,6 +13,8 @@
 // limitations under the License.
 package com.google.gerrit.plugins.codeowners.api;
 
+import java.util.List;
+
 /**
  * Representation of the code owner branch configuration in the REST API.
  *
@@ -53,11 +55,14 @@
   public RequiredApprovalInfo requiredApproval;
 
   /**
-   * The approval that is required to override the code owners submit check.
+   * The approvals that count as override for the code owners submit check.
+   *
+   * <p>If multiple approvals are returned, any of them is sufficient to override the code owners
+   * submit check.
    *
    * <p>Not set if {@link #disabled} is {@code true}.
    */
-  public RequiredApprovalInfo overrideApproval;
+  public List<RequiredApprovalInfo> overrideApproval;
 
   /**
    * Whether the branch doesn't contain any code owner config file yet.
diff --git a/java/com/google/gerrit/plugins/codeowners/api/CodeOwnerProjectConfigInfo.java b/java/com/google/gerrit/plugins/codeowners/api/CodeOwnerProjectConfigInfo.java
index f559739..bb9e1df 100644
--- a/java/com/google/gerrit/plugins/codeowners/api/CodeOwnerProjectConfigInfo.java
+++ b/java/com/google/gerrit/plugins/codeowners/api/CodeOwnerProjectConfigInfo.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.plugins.codeowners.api;
 
+import java.util.List;
+
 /**
  * Representation of the code owner project configuration in the REST API.
  *
@@ -52,9 +54,12 @@
   public RequiredApprovalInfo requiredApproval;
 
   /**
-   * The approval that is required to override the code owners submit check.
+   * The approval that count as override for the code owners submit check.
+   *
+   * <p>If multiple approvals are returned, any of them is sufficient to override the code owners
+   * submit check.
    *
    * <p>Not set if {@code status.disabled} is {@code true}.
    */
-  public RequiredApprovalInfo overrideApproval;
+  public List<RequiredApprovalInfo> overrideApproval;
 }
diff --git a/java/com/google/gerrit/plugins/codeowners/api/CodeOwners.java b/java/com/google/gerrit/plugins/codeowners/api/CodeOwners.java
index 5d1182c..1cf6e9e 100644
--- a/java/com/google/gerrit/plugins/codeowners/api/CodeOwners.java
+++ b/java/com/google/gerrit/plugins/codeowners/api/CodeOwners.java
@@ -47,6 +47,7 @@
     private Set<ListAccountsOption> options = EnumSet.noneOf(ListAccountsOption.class);
     private Integer limit;
     private String revision;
+    private Long seed;
 
     /**
      * Lists the code owners for the given path.
@@ -93,6 +94,16 @@
     }
 
     /**
+     * Sets the seed that should be used to shuffle code owners that have the same score.
+     *
+     * @param seed seed that should be used to shuffle code owners that have the same score
+     */
+    public QueryRequest withSeed(long seed) {
+      this.seed = seed;
+      return this;
+    }
+
+    /**
      * Sets the branch revision from which the code owner configs should be read.
      *
      * <p>Not supported for querying code owners for a path in a change.
@@ -114,6 +125,11 @@
       return Optional.ofNullable(limit);
     }
 
+    /** Returns the seed that should be used to shuffle code owners that have the same score. */
+    public Optional<Long> getSeed() {
+      return Optional.ofNullable(seed);
+    }
+
     /** Returns the branch revision from which the code owner configs should be read. */
     public Optional<String> getRevision() {
       return Optional.ofNullable(revision);
diff --git a/java/com/google/gerrit/plugins/codeowners/api/CodeOwnersInBranchImpl.java b/java/com/google/gerrit/plugins/codeowners/api/CodeOwnersInBranchImpl.java
index d5cb276..27dc958 100644
--- a/java/com/google/gerrit/plugins/codeowners/api/CodeOwnersInBranchImpl.java
+++ b/java/com/google/gerrit/plugins/codeowners/api/CodeOwnersInBranchImpl.java
@@ -56,6 +56,7 @@
           GetCodeOwnersForPathInBranch getCodeOwners = getCodeOwnersProvider.get();
           getOptions().forEach(getCodeOwners::addOption);
           getLimit().ifPresent(getCodeOwners::setLimit);
+          getSeed().ifPresent(getCodeOwners::setSeed);
           getRevision().ifPresent(getCodeOwners::setRevision);
           CodeOwnersInBranchCollection.PathResource pathInBranchResource =
               codeOwnersInBranchCollection.parse(
diff --git a/java/com/google/gerrit/plugins/codeowners/api/CodeOwnersInChangeImpl.java b/java/com/google/gerrit/plugins/codeowners/api/CodeOwnersInChangeImpl.java
index 4c8cfdc..e5a25a3 100644
--- a/java/com/google/gerrit/plugins/codeowners/api/CodeOwnersInChangeImpl.java
+++ b/java/com/google/gerrit/plugins/codeowners/api/CodeOwnersInChangeImpl.java
@@ -61,6 +61,7 @@
           GetCodeOwnersForPathInChange getCodeOwners = getCodeOwnersProvider.get();
           getOptions().forEach(getCodeOwners::addOption);
           getLimit().ifPresent(getCodeOwners::setLimit);
+          getSeed().ifPresent(getCodeOwners::setSeed);
           CodeOwnersInChangeCollection.PathResource pathInChangeResource =
               codeOwnersInChangeCollection.parse(
                   revisionResource, IdString.fromDecoded(path.toString()));
diff --git a/java/com/google/gerrit/plugins/codeowners/api/GeneralInfo.java b/java/com/google/gerrit/plugins/codeowners/api/GeneralInfo.java
index 8562057..8caa0fe 100644
--- a/java/com/google/gerrit/plugins/codeowners/api/GeneralInfo.java
+++ b/java/com/google/gerrit/plugins/codeowners/api/GeneralInfo.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.plugins.codeowners.api;
 
+import com.google.gerrit.plugins.codeowners.backend.FallbackCodeOwners;
+
 /**
  * Representation of the general code owners configuration in the REST API.
  *
@@ -43,4 +45,7 @@
    * code owner override.
    */
   public String overrideInfoUrl;
+
+  /** Policy that controls who should own paths that have no code owners defined. */
+  public FallbackCodeOwners fallbackCodeOwners;
 }
diff --git a/java/com/google/gerrit/plugins/codeowners/api/RenameEmailInput.java b/java/com/google/gerrit/plugins/codeowners/api/RenameEmailInput.java
new file mode 100644
index 0000000..cf38e59
--- /dev/null
+++ b/java/com/google/gerrit/plugins/codeowners/api/RenameEmailInput.java
@@ -0,0 +1,32 @@
+// 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.
+
+package com.google.gerrit.plugins.codeowners.api;
+
+/**
+ * The input for the {@link com.google.gerrit.plugins.codeowners.restapi.RenameEmail} REST endpoint.
+ */
+public class RenameEmailInput {
+  /**
+   * Optional commit message that should be used for the commit that renames the email in the code
+   * owner config files.
+   */
+  public String message;
+
+  /** The old email that should be replaced with the new email. */
+  public String oldEmail;
+
+  /** The new email that should be used to replace the old email. */
+  public String newEmail;
+}
diff --git a/java/com/google/gerrit/plugins/codeowners/api/RenameEmailResultInfo.java b/java/com/google/gerrit/plugins/codeowners/api/RenameEmailResultInfo.java
new file mode 100644
index 0000000..6400734
--- /dev/null
+++ b/java/com/google/gerrit/plugins/codeowners/api/RenameEmailResultInfo.java
@@ -0,0 +1,25 @@
+// 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.
+
+package com.google.gerrit.plugins.codeowners.api;
+
+import com.google.gerrit.extensions.common.CommitInfo;
+
+/**
+ * The result of the {@link com.google.gerrit.plugins.codeowners.restapi.RenameEmail} REST endpoint.
+ */
+public class RenameEmailResultInfo {
+  /** The commit that did the email rename, not set if no update was performed. */
+  public CommitInfo commit;
+}
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/BackendModule.java b/java/com/google/gerrit/plugins/codeowners/backend/BackendModule.java
index adccb0f..a93c20d 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/BackendModule.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/BackendModule.java
@@ -22,6 +22,7 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.ServerInitiated;
 import com.google.gerrit.server.UserInitiated;
+import com.google.gerrit.server.restapi.change.OnPostReview;
 import com.google.inject.Provides;
 
 /** Guice module to bind code owner backends. */
@@ -45,6 +46,7 @@
     install(new CodeOwnerSubmitRule.Module());
 
     DynamicSet.bind(binder(), ExceptionHook.class).to(CodeOwnersExceptionHook.class);
+    DynamicSet.bind(binder(), OnPostReview.class).to(CodeOwnersOnPostReview.class);
   }
 
   @Provides
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheck.java b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheck.java
index dafbbbf..efbb831 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheck.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheck.java
@@ -20,6 +20,7 @@
 
 import com.google.common.collect.ImmutableSet;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
@@ -48,6 +49,7 @@
 import java.nio.file.Path;
 import java.util.Collections;
 import java.util.Optional;
+import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.atomic.AtomicReference;
 import java.util.stream.Stream;
 import java.util.stream.StreamSupport;
@@ -170,21 +172,6 @@
                 .projectName(changeNotes.getProjectName().get())
                 .changeId(changeNotes.getChangeId().get())
                 .build())) {
-      RequiredApproval requiredApproval =
-          codeOwnersPluginConfiguration.getRequiredApproval(changeNotes.getProjectName());
-      logger.atFine().log("requiredApproval = %s", requiredApproval);
-
-      Optional<RequiredApproval> overrideApproval =
-          codeOwnersPluginConfiguration.getOverrideApproval(changeNotes.getProjectName());
-      boolean hasOverride =
-          overrideApproval.isPresent() && hasOverride(overrideApproval.get(), changeNotes);
-      logger.atFine().log(
-          "hasOverride = %s (overrideApproval = %s)", hasOverride, overrideApproval);
-
-      BranchNameKey branch = changeNotes.getChange().getDest();
-      ObjectId revision = getDestBranchRevision(changeNotes.getChange());
-      logger.atFine().log("dest branch %s has revision %s", branch.branch(), revision.name());
-
       boolean enableImplicitApprovalFromUploader =
           codeOwnersPluginConfiguration.areImplicitApprovalsEnabled(changeNotes.getProjectName());
       Account.Id patchSetUploader = changeNotes.getCurrentPatchSet().uploader();
@@ -192,6 +179,20 @@
           "patchSetUploader = %d, implicit approval from uploader is %s",
           patchSetUploader.get(), enableImplicitApprovalFromUploader ? "enabled" : "disabled");
 
+      RequiredApproval requiredApproval =
+          codeOwnersPluginConfiguration.getRequiredApproval(changeNotes.getProjectName());
+      logger.atFine().log("requiredApproval = %s", requiredApproval);
+
+      ImmutableSet<RequiredApproval> overrideApprovals =
+          codeOwnersPluginConfiguration.getOverrideApproval(changeNotes.getProjectName());
+      boolean hasOverride = hasOverride(overrideApprovals, changeNotes, patchSetUploader);
+      logger.atFine().log(
+          "hasOverride = %s (overrideApprovals = %s)", hasOverride, overrideApprovals);
+
+      BranchNameKey branch = changeNotes.getChange().getDest();
+      ObjectId revision = getDestBranchRevision(changeNotes.getChange());
+      logger.atFine().log("dest branch %s has revision %s", branch.branch(), revision.name());
+
       CodeOwnerResolverResult globalCodeOwners =
           codeOwnerResolver
               .get()
@@ -200,16 +201,16 @@
       logger.atFine().log("global code owners = %s", globalCodeOwners);
 
       // If the branch doesn't contain any code owner config file yet, we apply special logic
-      // (project
-      // owners count as code owners) to allow bootstrapping the code owner configuration in the
-      // branch.
+      // (project owners count as code owners) to allow bootstrapping the code owner configuration
+      // in the branch.
       boolean isBootstrapping =
           !codeOwnerConfigScannerFactory.create().containsAnyCodeOwnerConfigFile(branch);
       logger.atFine().log("isBootstrapping = %s", isBootstrapping);
 
-      ImmutableSet<Account.Id> reviewerAccountIds = getReviewerAccountIds(changeNotes);
+      ImmutableSet<Account.Id> reviewerAccountIds =
+          getReviewerAccountIds(requiredApproval, changeNotes, patchSetUploader);
       ImmutableSet<Account.Id> approverAccountIds =
-          getApproverAccountIds(requiredApproval, changeNotes);
+          getApproverAccountIds(requiredApproval, changeNotes, patchSetUploader);
       logger.atFine().log("reviewers = %s, approvers = %s", reviewerAccountIds, approverAccountIds);
 
       return changedFiles
@@ -231,12 +232,99 @@
     }
   }
 
+  /**
+   * Gets the code owner status for all files/paths that were changed in the current revision of the
+   * given change assuming that there is only an approval from the given account.
+   *
+   * <p>This method doesn't take approvals from other users and global code owners into account.
+   *
+   * <p>The purpose of this method is to find the files/paths in a change that are owned by the
+   * given account.
+   *
+   * @param changeNotes the notes of the change for which the current code owner statuses should be
+   *     returned
+   * @param accountId the ID of the account for which an approval should be assumed
+   */
+  public Stream<FileCodeOwnerStatus> getFileStatusesForAccount(
+      ChangeNotes changeNotes, Account.Id accountId)
+      throws ResourceConflictException, IOException, PatchListNotAvailableException {
+    requireNonNull(changeNotes, "changeNotes");
+    requireNonNull(accountId, "accountId");
+    try (TraceTimer traceTimer =
+        TraceContext.newTimer(
+            "Compute file statuses for account",
+            Metadata.builder()
+                .projectName(changeNotes.getProjectName().get())
+                .changeId(changeNotes.getChangeId().get())
+                .build())) {
+      RequiredApproval requiredApproval =
+          codeOwnersPluginConfiguration.getRequiredApproval(changeNotes.getProjectName());
+      logger.atFine().log("requiredApproval = %s", requiredApproval);
+
+      BranchNameKey branch = changeNotes.getChange().getDest();
+      ObjectId revision = getDestBranchRevision(changeNotes.getChange());
+      logger.atFine().log("dest branch %s has revision %s", branch.branch(), revision.name());
+
+      // If the branch doesn't contain any code owner config file yet, we apply special logic
+      // (project owners count as code owners) to allow bootstrapping the code owner configuration
+      // in the branch.
+      boolean isBootstrapping =
+          !codeOwnerConfigScannerFactory.create().containsAnyCodeOwnerConfigFile(branch);
+      boolean isProjectOwner = isProjectOwner(changeNotes.getProjectName(), accountId);
+      logger.atFine().log(
+          "isBootstrapping = %s (isProjectOwner = %s)", isBootstrapping, isProjectOwner);
+      if (isBootstrapping && isProjectOwner) {
+        // Return all paths as approved.
+        return changedFiles
+            .compute(changeNotes.getProjectName(), changeNotes.getCurrentPatchSet().commitId())
+            .stream()
+            .map(
+                changedFile ->
+                    FileCodeOwnerStatus.create(
+                        changedFile,
+                        changedFile
+                            .newPath()
+                            .map(
+                                newPath ->
+                                    PathCodeOwnerStatus.create(newPath, CodeOwnerStatus.APPROVED)),
+                        changedFile
+                            .oldPath()
+                            .map(
+                                oldPath ->
+                                    PathCodeOwnerStatus.create(
+                                        oldPath, CodeOwnerStatus.APPROVED))));
+      }
+
+      return changedFiles
+          .compute(changeNotes.getProjectName(), changeNotes.getCurrentPatchSet().commitId())
+          .stream()
+          .map(
+              changedFile ->
+                  getFileStatus(
+                      branch,
+                      revision,
+                      /* globalCodeOwners= */ CodeOwnerResolverResult.createEmpty(),
+                      // Do not check for implicit approvals since implicit approvals of other users
+                      // should be ignored. For the given account we do not need to check for
+                      // implicit approvals since all owned files are already covered by the
+                      // explicit approval.
+                      /* enableImplicitApprovalFromUploader= */ false,
+                      /* patchSetUploader= */ null,
+                      /* reviewerAccountIds= */ ImmutableSet.of(),
+                      // Assume an explicit approval of the given account.
+                      /* approverAccountIds= */ ImmutableSet.of(accountId),
+                      /* hasOverride= */ false,
+                      /* isBootstrapping= */ false,
+                      changedFile));
+    }
+  }
+
   private FileCodeOwnerStatus getFileStatus(
       BranchNameKey branch,
       ObjectId revision,
       CodeOwnerResolverResult globalCodeOwners,
       boolean enableImplicitApprovalFromUploader,
-      Account.Id patchSetUploader,
+      @Nullable Account.Id patchSetUploader,
       ImmutableSet<Account.Id> reviewerAccountIds,
       ImmutableSet<Account.Id> approverAccountIds,
       boolean hasOverride,
@@ -295,7 +383,7 @@
       ObjectId revision,
       CodeOwnerResolverResult globalCodeOwners,
       boolean enableImplicitApprovalFromUploader,
-      Account.Id patchSetUploader,
+      @Nullable Account.Id patchSetUploader,
       ImmutableSet<Account.Id> reviewerAccountIds,
       ImmutableSet<Account.Id> approverAccountIds,
       boolean hasOverride,
@@ -341,7 +429,7 @@
       BranchNameKey branch,
       CodeOwnerResolverResult globalCodeOwners,
       boolean enableImplicitApprovalFromUploader,
-      Account.Id patchSetUploader,
+      @Nullable Account.Id patchSetUploader,
       ImmutableSet<Account.Id> reviewerAccountIds,
       ImmutableSet<Account.Id> approverAccountIds,
       Path absolutePath) {
@@ -361,6 +449,20 @@
       codeOwnerStatus = CodeOwnerStatus.PENDING;
     }
 
+    // Since there are no code owner config files in bootstrapping mode, fallback code owners also
+    // apply if they are configured. We can skip checking them if we already found that the file was
+    // approved.
+    if (codeOwnerStatus != CodeOwnerStatus.APPROVED) {
+      codeOwnerStatus =
+          getCodeOwnerStatusForFallbackCodeOwners(
+              codeOwnerStatus,
+              branch.project(),
+              enableImplicitApprovalFromUploader,
+              reviewerAccountIds,
+              approverAccountIds,
+              absolutePath);
+    }
+
     PathCodeOwnerStatus pathCodeOwnerStatus =
         PathCodeOwnerStatus.create(absolutePath, codeOwnerStatus);
     logger.atFine().log("pathCodeOwnerStatus = %s", pathCodeOwnerStatus);
@@ -373,7 +475,7 @@
       CodeOwnerResolverResult globalCodeOwners,
       ImmutableSet<Account.Id> approverAccountIds,
       boolean enableImplicitApprovalFromUploader,
-      Account.Id patchSetUploader) {
+      @Nullable Account.Id patchSetUploader) {
     return (enableImplicitApprovalFromUploader
             && isImplicitlyApprovedBootstrappingMode(
                 projectName, absolutePath, globalCodeOwners, patchSetUploader))
@@ -386,6 +488,8 @@
       Path absolutePath,
       CodeOwnerResolverResult globalCodeOwners,
       Account.Id patchSetUploader) {
+    requireNonNull(
+        patchSetUploader, "patchSetUploader must be set if implicit approvals are enabled");
     if (isProjectOwner(projectName, patchSetUploader)) {
       // The uploader of the patch set is a project owner and thus a code owner. This means there
       // is an implicit code owner approval from the patch set uploader so that the path is
@@ -460,7 +564,7 @@
       BranchNameKey branch,
       CodeOwnerResolverResult globalCodeOwners,
       boolean enableImplicitApprovalFromUploader,
-      Account.Id patchSetUploader,
+      @Nullable Account.Id patchSetUploader,
       ObjectId revision,
       ImmutableSet<Account.Id> reviewerAccountIds,
       ImmutableSet<Account.Id> approverAccountIds,
@@ -486,6 +590,8 @@
         codeOwnerStatus.set(CodeOwnerStatus.PENDING);
       }
 
+      AtomicBoolean hasRevelantCodeOwnerDefinitions = new AtomicBoolean(false);
+      AtomicBoolean parentCodeOwnersAreIgnored = new AtomicBoolean(false);
       codeOwnerConfigHierarchy.visit(
           branch,
           revision,
@@ -498,6 +604,10 @@
                 codeOwnerConfig.key().folderPath(),
                 codeOwnerConfig.key().fileName().orElse("<default>"));
 
+            if (codeOwners.hasRevelantCodeOwnerDefinitions()) {
+              hasRevelantCodeOwnerDefinitions.set(true);
+            }
+
             if (isApproved(
                 absolutePath,
                 codeOwners,
@@ -517,7 +627,29 @@
             // We need to continue to check if any of the higher-level code owners approved the
             // change or is a reviewer.
             return true;
+          },
+          codeOwnerConfigKey -> {
+            logger.atFine().log(
+                "code owner config %s ignores parent code owners for %s",
+                codeOwnerConfigKey, absolutePath);
+            parentCodeOwnersAreIgnored.set(true);
           });
+
+      // If no code owners have been defined for the file and if parent code owners are not ignored,
+      // the fallback code owners apply if they are configured. We can skip checking them if we
+      // already found that the file was approved.
+      if (codeOwnerStatus.get() != CodeOwnerStatus.APPROVED
+          && !hasRevelantCodeOwnerDefinitions.get()
+          && !parentCodeOwnersAreIgnored.get()) {
+        codeOwnerStatus.set(
+            getCodeOwnerStatusForFallbackCodeOwners(
+                codeOwnerStatus.get(),
+                branch.project(),
+                enableImplicitApprovalFromUploader,
+                reviewerAccountIds,
+                approverAccountIds,
+                absolutePath));
+      }
     }
 
     PathCodeOwnerStatus pathCodeOwnerStatus =
@@ -526,19 +658,82 @@
     return pathCodeOwnerStatus;
   }
 
+  /**
+   * Computes the code owner status for the given path based on the configured fallback code owners.
+   */
+  private CodeOwnerStatus getCodeOwnerStatusForFallbackCodeOwners(
+      CodeOwnerStatus codeOwnerStatus,
+      Project.NameKey project,
+      boolean enableImplicitApprovalFromUploader,
+      ImmutableSet<Account.Id> reviewerAccountIds,
+      ImmutableSet<Account.Id> approverAccountIds,
+      Path absolutePath) {
+    FallbackCodeOwners fallbackCodeOwners =
+        codeOwnersPluginConfiguration.getFallbackCodeOwners(project);
+    logger.atFine().log(
+        "getting code owner status for fallback code owners (fallback code owners = %s)",
+        fallbackCodeOwners);
+    switch (fallbackCodeOwners) {
+      case NONE:
+        logger.atFine().log("no fallback code owners");
+        return codeOwnerStatus;
+      case ALL_USERS:
+        return getCodeOwnerStatusIfAllUsersAreCodeOwners(
+            enableImplicitApprovalFromUploader,
+            reviewerAccountIds,
+            approverAccountIds,
+            absolutePath);
+    }
+
+    throw new StorageException(
+        String.format("unknown fallback code owners configured: %s", fallbackCodeOwners));
+  }
+
+  /** Computes the code owner status for the given path assuming that all users are code owners. */
+  private CodeOwnerStatus getCodeOwnerStatusIfAllUsersAreCodeOwners(
+      boolean enableImplicitApprovalFromUploader,
+      ImmutableSet<Account.Id> reviewerAccountIds,
+      ImmutableSet<Account.Id> approverAccountIds,
+      Path absolutePath) {
+    logger.atFine().log(
+        "getting code owner status for fallback code owners (all users are fallback code owners)");
+
+    if (enableImplicitApprovalFromUploader) {
+      logger.atFine().log(
+          "%s was implicitly approved by the patch set uploader since the uploader is a fallback"
+              + " code owner",
+          absolutePath);
+      return CodeOwnerStatus.APPROVED;
+    }
+
+    if (!approverAccountIds.isEmpty()) {
+      logger.atFine().log("%s was approved by a fallback code owner", absolutePath);
+      return CodeOwnerStatus.APPROVED;
+    } else if (!reviewerAccountIds.isEmpty()) {
+      logger.atFine().log("%s has a fallback code owner as reviewer", absolutePath);
+      return CodeOwnerStatus.PENDING;
+    }
+
+    logger.atFine().log("%s has no fallback code owner as a reviewer", absolutePath);
+    return CodeOwnerStatus.INSUFFICIENT_REVIEWERS;
+  }
+
   private boolean isApproved(
       Path absolutePath,
       CodeOwnerResolverResult codeOwners,
       ImmutableSet<Account.Id> approverAccountIds,
       boolean enableImplicitApprovalFromUploader,
-      Account.Id patchSetUploader) {
-    if (enableImplicitApprovalFromUploader
-        && (codeOwners.codeOwnersAccountIds().contains(patchSetUploader)
-            || codeOwners.ownedByAllUsers())) {
-      // If the uploader of the patch set owns the path, there is an implicit code owner
-      // approval from the patch set uploader so that the path is automatically approved.
-      logger.atFine().log("%s was implicitly approved by the patch set uploader", absolutePath);
-      return true;
+      @Nullable Account.Id patchSetUploader) {
+    if (enableImplicitApprovalFromUploader) {
+      requireNonNull(
+          patchSetUploader, "patchSetUploader must be set if implicit approvals are enabled");
+      if (codeOwners.codeOwnersAccountIds().contains(patchSetUploader)
+          || codeOwners.ownedByAllUsers()) {
+        // If the uploader of the patch set owns the path, there is an implicit code owner
+        // approval from the patch set uploader so that the path is automatically approved.
+        logger.atFine().log("%s was implicitly approved by the patch set uploader", absolutePath);
+        return true;
+      }
     }
 
     if (!Collections.disjoint(approverAccountIds, codeOwners.codeOwnersAccountIds())
@@ -604,8 +799,19 @@
    *
    * @param changeNotes the change notes
    */
-  private ImmutableSet<Account.Id> getReviewerAccountIds(ChangeNotes changeNotes) {
-    return changeNotes.getReviewers().byState(ReviewerStateInternal.REVIEWER);
+  private ImmutableSet<Account.Id> getReviewerAccountIds(
+      RequiredApproval requiredApproval, ChangeNotes changeNotes, Account.Id patchSetUploader) {
+    ImmutableSet<Account.Id> reviewerAccountIds =
+        changeNotes.getReviewers().byState(ReviewerStateInternal.REVIEWER);
+    if (requiredApproval.labelType().isIgnoreSelfApproval()
+        && reviewerAccountIds.contains(patchSetUploader)) {
+      logger.atFine().log(
+          "Removing patch set uploader %s from reviewers since the label of the required"
+              + " approval (%s) is configured to ignore self approvals",
+          patchSetUploader, requiredApproval.labelType());
+      return filterOutAccount(reviewerAccountIds, patchSetUploader);
+    }
+    return reviewerAccountIds;
   }
 
   /**
@@ -617,7 +823,59 @@
    * @param changeNotes the change notes
    */
   private ImmutableSet<Account.Id> getApproverAccountIds(
-      RequiredApproval requiredApproval, ChangeNotes changeNotes) {
+      RequiredApproval requiredApproval, ChangeNotes changeNotes, Account.Id patchSetUploader) {
+    ImmutableSet<Account.Id> approverAccountIds =
+        StreamSupport.stream(
+                approvalsUtil
+                    .byPatchSet(
+                        changeNotes,
+                        changeNotes.getCurrentPatchSet().id(),
+                        /** revWalk */
+                        null,
+                        /** repoConfig */
+                        null)
+                    .spliterator(),
+                /** parallel */
+                false)
+            .filter(requiredApproval::isApprovedBy)
+            .map(PatchSetApproval::accountId)
+            .collect(toImmutableSet());
+
+    if (requiredApproval.labelType().isIgnoreSelfApproval()
+        && approverAccountIds.contains(patchSetUploader)) {
+      logger.atFine().log(
+          "Removing patch set uploader %s from approvers since the label of the required"
+              + " approval (%s) is configured to ignore self approvals",
+          patchSetUploader, requiredApproval.labelType());
+      return filterOutAccount(approverAccountIds, patchSetUploader);
+    }
+
+    return approverAccountIds;
+  }
+
+  private ImmutableSet<Account.Id> filterOutAccount(
+      ImmutableSet<Account.Id> accountIds, Account.Id accountIdToFilterOut) {
+    return accountIds.stream()
+        .filter(accountId -> !accountId.equals(accountIdToFilterOut))
+        .collect(toImmutableSet());
+  }
+
+  /**
+   * Checks whether the given change has an override approval.
+   *
+   * @param overrideApprovals approvals that count as override for the code owners submit check.
+   * @param changeNotes the change notes
+   * @param patchSetUploader account ID of the patch set uploader
+   * @return whether the given change has an override approval
+   */
+  private boolean hasOverride(
+      ImmutableSet<RequiredApproval> overrideApprovals,
+      ChangeNotes changeNotes,
+      Account.Id patchSetUploader) {
+    ImmutableSet<RequiredApproval> overrideApprovalsThatIgnoreSelfApprovals =
+        overrideApprovals.stream()
+            .filter(overrideApproval -> overrideApproval.labelType().isIgnoreSelfApproval())
+            .collect(toImmutableSet());
     return StreamSupport.stream(
             approvalsUtil
                 .byPatchSet(
@@ -630,21 +888,26 @@
                 .spliterator(),
             /** parallel */
             false)
-        .filter(requiredApproval::isApprovedBy)
-        .map(PatchSetApproval::accountId)
-        .collect(toImmutableSet());
-  }
-
-  /**
-   * Checks whether the given change has an override approval.
-   *
-   * @param overrideApproval approval that is required to override the code owners submit check.
-   * @param changeNotes the change notes
-   * @return whether the given change has an override approval
-   */
-  private boolean hasOverride(RequiredApproval overrideApproval, ChangeNotes changeNotes) {
-    return changeNotes.getApprovals().get(changeNotes.getCurrentPatchSet().id()).stream()
-        .anyMatch(overrideApproval::isApprovedBy);
+        .filter(
+            approval -> {
+              // If the approval is from the patch set uploader and if it matches any of the labels
+              // for which self approvals are ignored, filter it out.
+              if (approval.accountId().equals(patchSetUploader)
+                  && overrideApprovalsThatIgnoreSelfApprovals.stream()
+                      .anyMatch(
+                          requiredApproval ->
+                              requiredApproval
+                                  .labelType()
+                                  .getLabelId()
+                                  .equals(approval.key().labelId()))) {
+                return false;
+              }
+              return true;
+            })
+        .anyMatch(
+            patchSetApproval ->
+                overrideApprovals.stream()
+                    .anyMatch(overrideApproval -> overrideApproval.isApprovedBy(patchSetApproval)));
   }
 
   /**
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerBackend.java b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerBackend.java
index 0f762e1..be5318d 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerBackend.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerBackend.java
@@ -16,6 +16,7 @@
 
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.restapi.NotImplementedException;
 import com.google.gerrit.server.IdentifiedUser;
 import java.nio.file.Path;
 import java.util.Optional;
@@ -52,11 +53,7 @@
    */
   default Optional<CodeOwnerConfig> getCodeOwnerConfig(
       CodeOwnerConfig.Key codeOwnerConfigKey, @Nullable ObjectId revision) {
-    return getCodeOwnerConfig(
-        codeOwnerConfigKey,
-        /** revWalk = */
-        null,
-        revision);
+    return getCodeOwnerConfig(codeOwnerConfigKey, /* revWalk= */ null, revision);
   }
 
   /**
@@ -118,4 +115,19 @@
   default Optional<PathExpressionMatcher> getPathExpressionMatcher() {
     return Optional.empty();
   }
+
+  /**
+   * Replaces the old email in the given code owner config file content with the new email.
+   *
+   * @param codeOwnerConfigFileContent the code owner config file content in which the old email
+   *     should be replaced with the new email
+   * @param oldEmail the email that should be replaced by the new email
+   * @param newEmail the email that should replace the old email
+   * @return the updated content
+   * @throws NotImplementedException if the backend doesn't support replacing emails in code owner
+   *     config files
+   */
+  default String replaceEmail(String codeOwnerConfigFileContent, String oldEmail, String newEmail) {
+    throw new NotImplementedException();
+  }
 }
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfig.java b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfig.java
index 0e116a0..d01c4a3 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfig.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfig.java
@@ -350,11 +350,7 @@
      * @return the code owner config key
      */
     public static Key create(BranchNameKey branchNameKey, Path folderPath) {
-      return create(
-          branchNameKey,
-          folderPath,
-          /** fileName = */
-          null);
+      return create(branchNameKey, folderPath, /* fileName= */ null);
     }
 
     /**
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigHierarchy.java b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigHierarchy.java
index e3bbb85..e9db8c8 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigHierarchy.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigHierarchy.java
@@ -28,6 +28,7 @@
 import java.io.IOException;
 import java.nio.file.Path;
 import java.util.Optional;
+import java.util.function.Consumer;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
@@ -74,10 +75,38 @@
       ObjectId revision,
       Path absolutePath,
       CodeOwnerConfigVisitor codeOwnerConfigVisitor) {
+    visit(
+        branchNameKey,
+        revision,
+        absolutePath,
+        codeOwnerConfigVisitor,
+        /* parentCodeOwnersIgnoredCallback= */ codeOwnerConfigKey -> {});
+  }
+
+  /**
+   * Visits the code owner configs in the given branch that apply for the given path by following
+   * the path hierarchy from the given path up to the root folder.
+   *
+   * @param branchNameKey project and branch from which the code owner configs should be visited
+   * @param revision the branch revision from which the code owner configs should be loaded
+   * @param absolutePath the path for which the code owner configs should be visited; the path must
+   *     be absolute; can be the path of a file or folder; the path may or may not exist
+   * @param codeOwnerConfigVisitor visitor that should be invoked for the applying code owner
+   *     configs
+   * @param parentCodeOwnersIgnoredCallback callback that is invoked for the first visited code
+   *     owner config that ignores parent code owners
+   */
+  public void visit(
+      BranchNameKey branchNameKey,
+      ObjectId revision,
+      Path absolutePath,
+      CodeOwnerConfigVisitor codeOwnerConfigVisitor,
+      Consumer<CodeOwnerConfig.Key> parentCodeOwnersIgnoredCallback) {
     requireNonNull(branchNameKey, "branch");
     requireNonNull(revision, "revision");
     requireNonNull(absolutePath, "absolutePath");
     requireNonNull(codeOwnerConfigVisitor, "codeOwnerConfigVisitor");
+    requireNonNull(parentCodeOwnersIgnoredCallback, "parentCodeOwnersIgnoredCallback");
     checkState(absolutePath.isAbsolute(), "path %s must be absolute", absolutePath);
 
     logger.atFine().log(
@@ -93,14 +122,19 @@
       // Read code owner config and invoke the codeOwnerConfigVisitor if the code owner config
       // exists.
       logger.atFine().log("inspecting code owner config for %s", ownerConfigFolder);
+      CodeOwnerConfig.Key codeOwnerConfigKey =
+          CodeOwnerConfig.Key.create(branchNameKey, ownerConfigFolder);
       Optional<PathCodeOwners> pathCodeOwners =
-          pathCodeOwnersFactory.create(
-              CodeOwnerConfig.Key.create(branchNameKey, ownerConfigFolder), revision, absolutePath);
+          pathCodeOwnersFactory.create(codeOwnerConfigKey, revision, absolutePath);
       if (pathCodeOwners.isPresent()) {
         logger.atFine().log("visit code owner config for %s", ownerConfigFolder);
         boolean visitFurtherCodeOwnerConfigs =
             codeOwnerConfigVisitor.visit(pathCodeOwners.get().getCodeOwnerConfig());
-        boolean ignoreParentCodeOwners = pathCodeOwners.get().ignoreParentCodeOwners();
+        boolean ignoreParentCodeOwners =
+            pathCodeOwners.get().resolveCodeOwnerConfig().ignoreParentCodeOwners();
+        if (ignoreParentCodeOwners) {
+          parentCodeOwnersIgnoredCallback.accept(codeOwnerConfigKey);
+        }
         logger.atFine().log(
             "visitFurtherCodeOwnerConfigs = %s, ignoreParentCodeOwners = %s",
             visitFurtherCodeOwnerConfigs, ignoreParentCodeOwners);
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigImportMode.java b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigImportMode.java
index c712b27..1e598df 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigImportMode.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigImportMode.java
@@ -30,14 +30,10 @@
    * <p>Imports of the referenced code owner config are resolved.
    */
   ALL(
-      /** importIgnoreParentCodeOwners = */
-      true,
-      /** importGlobalCodeOwnerSets = */
-      true,
-      /** importPerFileCodeOwnerSets = */
-      true,
-      /** resolveImportsOfImport = */
-      true),
+      /* importIgnoreParentCodeOwners= */ true,
+      /* importGlobalCodeOwnerSets= */ true,
+      /* importPerFileCodeOwnerSets= */ true,
+      /* resolveImportsOfImport= */ true),
 
   /**
    * Only global code owner sets (code owner sets without path expressions) should be imported from
@@ -53,14 +49,10 @@
    * <p>Imports of the referenced code owner config are resolved.
    */
   GLOBAL_CODE_OWNER_SETS_ONLY(
-      /** importIgnoreParentCodeOwners = */
-      false,
-      /** importGlobalCodeOwnerSets = */
-      true,
-      /** importPerFileCodeOwnerSets = */
-      false,
-      /** resolveImportsOfImport = */
-      true);
+      /* importIgnoreParentCodeOwners= */ false,
+      /* importGlobalCodeOwnerSets= */ true,
+      /* importPerFileCodeOwnerSets= */ false,
+      /* resolveImportsOfImport= */ true);
 
   private final boolean importIgnoreParentCodeOwners;
   private final boolean importGlobalCodeOwnerSets;
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigScanner.java b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigScanner.java
index 9b77316..c8c96ab 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigScanner.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigScanner.java
@@ -106,8 +106,7 @@
         branchNameKey,
         codeOwnerConfigVisitor,
         invalidCodeOwnerConfigCallback,
-        /** pathGlob = */
-        null);
+        /* pathGlob= */ null);
   }
 
   /**
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerResolver.java b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerResolver.java
index 6d9d05e..da32379 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerResolver.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerResolver.java
@@ -45,7 +45,6 @@
 import java.util.Optional;
 import java.util.Set;
 import java.util.concurrent.atomic.AtomicBoolean;
-import java.util.stream.Stream;
 
 /** Class to resolve {@link CodeOwnerReference}s to {@link CodeOwner}s. */
 public class CodeOwnerResolver {
@@ -154,7 +153,10 @@
                 .filePath(codeOwnerConfig.key().fileName().orElse("<default>"))
                 .build())) {
       logger.atFine().log("resolving path code owners for path %s", absolutePath);
-      return resolve(pathCodeOwnersFactory.create(codeOwnerConfig, absolutePath).get());
+      PathCodeOwnersResult pathCodeOwnersResult =
+          pathCodeOwnersFactory.create(codeOwnerConfig, absolutePath).resolveCodeOwnerConfig();
+      return resolve(
+          pathCodeOwnersResult.getPathCodeOwners(), pathCodeOwnersResult.hasUnresolvedImports());
     }
   }
 
@@ -176,8 +178,22 @@
    * @see #resolve(CodeOwnerReference)
    */
   public CodeOwnerResolverResult resolve(Set<CodeOwnerReference> codeOwnerReferences) {
+    return resolve(codeOwnerReferences, /* hasUnresolvedImports= */ false);
+  }
+
+  /**
+   * Resolves the given {@link CodeOwnerReference}s to {@link CodeOwner}s.
+   *
+   * @param codeOwnerReferences the code owner references that should be resolved
+   * @param hasUnresolvedImports whether there are unresolved imports
+   * @return the {@link CodeOwner} for the given code owner references
+   * @see #resolve(CodeOwnerReference)
+   */
+  private CodeOwnerResolverResult resolve(
+      Set<CodeOwnerReference> codeOwnerReferences, boolean hasUnresolvedImports) {
     requireNonNull(codeOwnerReferences, "codeOwnerReferences");
     AtomicBoolean ownedByAllUsers = new AtomicBoolean(false);
+    AtomicBoolean hasUnresolvedCodeOwners = new AtomicBoolean(false);
     ImmutableSet<CodeOwner> codeOwners =
         codeOwnerReferences.stream()
             .filter(
@@ -188,9 +204,19 @@
                   }
                   return true;
                 })
-            .flatMap(this::resolve)
+            .map(this::resolve)
+            .filter(
+                codeOwner -> {
+                  if (!codeOwner.isPresent()) {
+                    hasUnresolvedCodeOwners.set(true);
+                    return false;
+                  }
+                  return true;
+                })
+            .map(Optional::get)
             .collect(toImmutableSet());
-    return CodeOwnerResolverResult.create(codeOwners, ownedByAllUsers.get());
+    return CodeOwnerResolverResult.create(
+        codeOwners, ownedByAllUsers.get(), hasUnresolvedCodeOwners.get(), hasUnresolvedImports);
   }
 
   /**
@@ -214,11 +240,12 @@
    *       Gerrit core that also treats ambiguous identifiers as non-resolveable.
    * </ul>
    *
-   * <p>This methods checks whether the calling user can see the accounts of the code owners and
-   * returns code owners whose accounts are visible.
+   * <p>This methods checks whether the {@link #user} or the calling user (if {@link #user} is
+   * unset) can see the accounts of the code owners and returns code owners whose accounts are
+   * visible.
    *
    * <p>In addition code owners that are referenced by a secondary email are only returned if the
-   * calling user can see the secondary email:
+   * {@link #user} or the calling user (if {@link #user} is unset) can see the secondary email:
    *
    * <ul>
    *   <li>every user can see the own secondary emails
@@ -233,35 +260,34 @@
    * @return the {@link CodeOwner} for the code owner reference if it was resolved, otherwise {@link
    *     Optional#empty()}
    */
-  @VisibleForTesting
-  public Stream<CodeOwner> resolve(CodeOwnerReference codeOwnerReference) {
+  public Optional<CodeOwner> resolve(CodeOwnerReference codeOwnerReference) {
     String email = requireNonNull(codeOwnerReference, "codeOwnerReference").email();
     logger.atFine().log("resolving code owner reference %s", codeOwnerReference);
 
     if (!isEmailDomainAllowed(email)) {
       logger.atFine().log("domain of email %s is not allowed", email);
-      return Stream.of();
+      return Optional.empty();
     }
 
     Optional<AccountState> accountState =
         lookupEmail(email).flatMap(accountId -> lookupAccount(accountId, email));
     if (!accountState.isPresent()) {
       logger.atFine().log("no account for email %s", email);
-      return Stream.of();
+      return Optional.empty();
     }
     if (!accountState.get().account().isActive()) {
       logger.atFine().log("account for email %s is inactive", email);
-      return Stream.of();
+      return Optional.empty();
     }
     if (enforceVisibility && !isVisible(accountState.get(), email)) {
       logger.atFine().log(
-          "account %d or email %s not visible", accountState.get().account().id().get(), email);
-      return Stream.of();
+          "account %d of email %s is not visible", accountState.get().account().id().get(), email);
+      return Optional.empty();
     }
 
     CodeOwner codeOwner = CodeOwner.create(accountState.get().account().id());
     logger.atFine().log("resolved to code owner %s", codeOwner);
-    return Stream.of(codeOwner);
+    return Optional.of(codeOwner);
   }
 
   /** Whether the given account can be seen. */
@@ -323,43 +349,63 @@
   }
 
   /**
-   * Checks whether the given account and email are visible to the calling user.
+   * Checks whether the given account and email are visible to the {@link #user} or the calling user
+   * (if {@link #user} is unset).
    *
-   * <p>If the email is a secondary email it is only visible if it is owned by the calling user or
-   * if the calling user has the {@code Modify Account} global capability.
+   * <p>If the email is a secondary email it is only visible if
    *
-   * @param accountState the account for which it should be checked whether it's visible to the
-   *     calling user
+   * <ul>
+   *   <li>it is owned by the {@link #user} or the calling user (if {@link #user} is unset)
+   *   <li>if the {@link #user} or the calling user (if {@link #user} is unset) has the {@code
+   *       Modify Account} global capability
+   * </ul>
+   *
+   * @param accountState the account for which it should be checked whether it's visible to the user
    * @param email email that was used to reference the account
-   * @return {@code true} if the given account and email are visible to the calling user, otherwise
-   *     {@code false}
+   * @return {@code true} if the given account and email are visible to the user, otherwise {@code
+   *     false}
    */
   private boolean isVisible(AccountState accountState, String email) {
     if (!canSee(accountState)) {
       logger.atFine().log(
-          "cannot resolve code owner email %s: account %s is not visible to calling user",
-          email, accountState.account().id());
+          "cannot resolve code owner email %s: account %s is not visible to user %s",
+          email,
+          accountState.account().id(),
+          user != null ? user.getLoggableName() : currentUser.get().getLoggableName());
       return false;
     }
 
     if (!email.equals(accountState.account().preferredEmail())) {
       // the email is a secondary email of the account
 
-      if (currentUser.get().isIdentifiedUser()
+      if (user != null) {
+        if (user.hasEmailAddress(email)) {
+          // it's a secondary email of the user, users can always see their own secondary emails
+          return true;
+        }
+      } else if (currentUser.get().isIdentifiedUser()
           && currentUser.get().asIdentifiedUser().hasEmailAddress(email)) {
         // it's a secondary email of the calling user, users can always see their own secondary
         // emails
         return true;
       }
 
-      // the email is a secondary email of another account, check if the calling user can see
-      // secondary emails
+      // the email is a secondary email of another account, check if the user can see secondary
+      // emails
       try {
-        if (!permissionBackend.currentUser().test(GlobalPermission.MODIFY_ACCOUNT)) {
+        if (user != null) {
+          if (!permissionBackend.user(user).test(GlobalPermission.MODIFY_ACCOUNT)) {
+            logger.atFine().log(
+                "cannot resolve code owner email %s: account %s is referenced by secondary email,"
+                    + " but user %s cannot see secondary emails",
+                email, accountState.account().id(), user.getLoggableName());
+            return false;
+          }
+        } else if (!permissionBackend.currentUser().test(GlobalPermission.MODIFY_ACCOUNT)) {
           logger.atFine().log(
               "cannot resolve code owner email %s: account %s is referenced by secondary email,"
-                  + " but the calling user cannot see secondary emails",
-              email, accountState.account().id());
+                  + " but the calling user %s cannot see secondary emails",
+              email, accountState.account().id(), currentUser.get().getLoggableName());
           return false;
         }
       } catch (PermissionBackendException e) {
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerResolverResult.java b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerResolverResult.java
index 226d541..b5b71c0 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerResolverResult.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerResolverResult.java
@@ -43,17 +43,49 @@
    */
   public abstract boolean ownedByAllUsers();
 
+  /** Whether there are code owner references which couldn't be resolved. */
+  public abstract boolean hasUnresolvedCodeOwners();
+
+  /** Whether there are imports which couldn't be resolved. */
+  public abstract boolean hasUnresolvedImports();
+
+  /**
+   * Whether there are any code owners defined for the path, regardless of whether they can be
+   * resolved or not.
+   */
+  public boolean hasRevelantCodeOwnerDefinitions() {
+    return !codeOwners().isEmpty()
+        || ownedByAllUsers()
+        || hasUnresolvedCodeOwners()
+        || hasUnresolvedImports();
+  }
+
   @Override
   public String toString() {
     return MoreObjects.toStringHelper(this)
         .add("codeOwners", codeOwners())
         .add("ownedByAllUsers", ownedByAllUsers())
+        .add("hasUnresolvedCodeOwners", hasUnresolvedCodeOwners())
+        .add("hasUnresolvedImports", hasUnresolvedImports())
         .toString();
   }
 
   /** Creates a {@link CodeOwnerResolverResult} instance. */
   public static CodeOwnerResolverResult create(
-      ImmutableSet<CodeOwner> codeOwners, boolean ownedByAllUsers) {
-    return new AutoValue_CodeOwnerResolverResult(codeOwners, ownedByAllUsers);
+      ImmutableSet<CodeOwner> codeOwners,
+      boolean ownedByAllUsers,
+      boolean hasUnresolvedCodeOwners,
+      boolean hasUnresolvedImports) {
+    return new AutoValue_CodeOwnerResolverResult(
+        codeOwners, ownedByAllUsers, hasUnresolvedCodeOwners, hasUnresolvedImports);
+  }
+
+  /** Creates a empty {@link CodeOwnerResolverResult} instance. */
+  public static CodeOwnerResolverResult createEmpty() {
+    return new AutoValue_CodeOwnerResolverResult(
+        /* codeOwners= */ ImmutableSet.of(),
+        /* ownedByAllUsers= */ false,
+        /* hasUnresolvedCodeOwners= */ false,
+        /* hasUnresolvedImports= */ false);
   }
 }
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwners.java b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwners.java
index 07648ef..2165fda 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwners.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwners.java
@@ -70,10 +70,7 @@
     requireNonNull(codeOwnerConfigKey, "codeOwnerConfigKey");
     CodeOwnerBackend codeOwnerBackend =
         codeOwnersPluginConfiguration.getBackend(codeOwnerConfigKey.branchNameKey());
-    return codeOwnerBackend.getCodeOwnerConfig(
-        codeOwnerConfigKey,
-        /** revision = */
-        null);
+    return codeOwnerBackend.getCodeOwnerConfig(codeOwnerConfigKey, /* revision= */ null);
   }
 
   /**
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnersOnPostReview.java b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnersOnPostReview.java
new file mode 100644
index 0000000..0eb05f2
--- /dev/null
+++ b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnersOnPostReview.java
@@ -0,0 +1,259 @@
+// 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.
+
+package com.google.gerrit.plugins.codeowners.backend;
+
+import static com.google.common.base.Preconditions.checkState;
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static java.util.Comparator.comparing;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.plugins.codeowners.JgitPath;
+import com.google.gerrit.plugins.codeowners.api.CodeOwnerStatus;
+import com.google.gerrit.plugins.codeowners.config.CodeOwnersPluginConfiguration;
+import com.google.gerrit.plugins.codeowners.config.RequiredApproval;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.gerrit.server.restapi.change.OnPostReview;
+import com.google.gerrit.server.util.LabelVote;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.nio.file.Path;
+import java.util.Map;
+import java.util.Optional;
+import java.util.stream.Stream;
+
+/**
+ * Callback that is invoked on post review.
+ *
+ * <p>If a code owner approval was added, removed or changed, include in the change message that is
+ * being posted on vote, which of the files:
+ *
+ * <ul>
+ *   <li>are approved now
+ *   <li>are no longer approved
+ *   <li>are still approved
+ * </ul>
+ */
+@Singleton
+class CodeOwnersOnPostReview implements OnPostReview {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private final CodeOwnersPluginConfiguration codeOwnersPluginConfiguration;
+  private final CodeOwnerApprovalCheck codeOwnerApprovalCheck;
+
+  @Inject
+  CodeOwnersOnPostReview(
+      CodeOwnersPluginConfiguration codeOwnersPluginConfiguration,
+      CodeOwnerApprovalCheck codeOwnerApprovalCheck) {
+    this.codeOwnersPluginConfiguration = codeOwnersPluginConfiguration;
+    this.codeOwnerApprovalCheck = codeOwnerApprovalCheck;
+  }
+
+  @Override
+  public Optional<String> getChangeMessageAddOn(
+      IdentifiedUser user,
+      ChangeNotes changeNotes,
+      PatchSet patchSet,
+      Map<String, Short> oldApprovals,
+      Map<String, Short> approvals) {
+    if (codeOwnersPluginConfiguration.isDisabled(changeNotes.getChange().getDest())) {
+      return Optional.empty();
+    }
+
+    // code owner approvals are only computed for the current patch set
+    if (!changeNotes.getChange().currentPatchSetId().equals(patchSet.id())) {
+      return Optional.empty();
+    }
+
+    RequiredApproval requiredApproval =
+        codeOwnersPluginConfiguration.getRequiredApproval(changeNotes.getProjectName());
+
+    if (!oldApprovals.containsKey(requiredApproval.labelType().getName())) {
+      // if oldApprovals doesn't contain the label, the label was not changed
+      return Optional.empty();
+    }
+
+    return buildMessageForCodeOwnerApproval(
+        user, changeNotes, patchSet, oldApprovals, approvals, requiredApproval);
+  }
+
+  private Optional<String> buildMessageForCodeOwnerApproval(
+      IdentifiedUser user,
+      ChangeNotes changeNotes,
+      PatchSet patchSet,
+      Map<String, Short> oldApprovals,
+      Map<String, Short> approvals,
+      RequiredApproval requiredApproval) {
+    int maxPathsInChangeMessage =
+        codeOwnersPluginConfiguration.getMaxPathsInChangeMessages(changeNotes.getProjectName());
+    if (maxPathsInChangeMessage <= 0) {
+      return Optional.empty();
+    }
+
+    LabelVote newVote = getNewVote(requiredApproval, approvals);
+
+    ImmutableList<Path> ownedPaths = getOwnedPaths(changeNotes, user.getAccountId());
+    if (ownedPaths.isEmpty()) {
+      // the user doesn't own any of the modified paths
+      return Optional.empty();
+    }
+
+    boolean hasImplicitApprovalByUser =
+        codeOwnersPluginConfiguration.areImplicitApprovalsEnabled(changeNotes.getProjectName())
+            && patchSet.uploader().equals(user.getAccountId());
+
+    boolean noLongerExplicitlyApproved = false;
+    StringBuilder message = new StringBuilder();
+    if (isCodeOwnerApprovalNewlyApplied(requiredApproval, oldApprovals, newVote)) {
+      if (hasImplicitApprovalByUser) {
+        message.append(
+            String.format(
+                "By voting %s the following files are now explicitly code-owner approved by %s:\n",
+                newVote, user.getName()));
+      } else {
+        message.append(
+            String.format(
+                "By voting %s the following files are now code-owner approved by %s:\n",
+                newVote, user.getName()));
+      }
+    } else if (isCodeOwnerApprovalRemoved(requiredApproval, oldApprovals, newVote)) {
+      if (newVote.value() == 0) {
+        if (hasImplicitApprovalByUser) {
+          noLongerExplicitlyApproved = true;
+          message.append(
+              String.format(
+                  "By removing the %s vote the following files are no longer explicitly code-owner"
+                      + " approved by %s:\n",
+                  newVote.label(), user.getName()));
+        } else {
+          message.append(
+              String.format(
+                  "By removing the %s vote the following files are no longer code-owner approved"
+                      + " by %s:\n",
+                  newVote.label(), user.getName()));
+        }
+      } else {
+        if (hasImplicitApprovalByUser) {
+          noLongerExplicitlyApproved = true;
+          message.append(
+              String.format(
+                  "By voting %s the following files are no longer explicitly code-owner approved by"
+                      + " %s:\n",
+                  newVote, user.getName()));
+        } else {
+          message.append(
+              String.format(
+                  "By voting %s the following files are no longer code-owner approved by %s:\n",
+                  newVote, user.getName()));
+        }
+      }
+    } else if (isCodeOwnerApprovalUpOrDowngraded(requiredApproval, oldApprovals, newVote)) {
+      if (hasImplicitApprovalByUser) {
+        message.append(
+            String.format(
+                "By voting %s the following files are still explicitly code-owner approved by"
+                    + " %s:\n",
+                newVote, user.getName()));
+      } else {
+        message.append(
+            String.format(
+                "By voting %s the following files are still code-owner approved by %s:\n",
+                newVote, user.getName()));
+      }
+    } else {
+      return Optional.empty();
+    }
+
+    if (ownedPaths.size() <= maxPathsInChangeMessage) {
+      appendPaths(message, ownedPaths.stream());
+    } else {
+      // -1 so that we never show "(1 more files)"
+      int limit = maxPathsInChangeMessage - 1;
+      appendPaths(message, ownedPaths.stream().limit(limit));
+      message.append(String.format("(%s more files)\n", ownedPaths.size() - limit));
+    }
+
+    if (hasImplicitApprovalByUser && noLongerExplicitlyApproved) {
+      message.append(
+          String.format(
+              "\nThe listed files are still implicitly approved by %s.\n", user.getName()));
+    }
+
+    return Optional.of(message.toString());
+  }
+
+  private void appendPaths(StringBuilder message, Stream<Path> pathsToAppend) {
+    pathsToAppend.forEach(path -> message.append(String.format("* %s\n", JgitPath.of(path).get())));
+  }
+
+  private boolean isCodeOwnerApprovalNewlyApplied(
+      RequiredApproval requiredApproval, Map<String, Short> oldApprovals, LabelVote newVote) {
+    String labelName = requiredApproval.labelType().getName();
+    return oldApprovals.get(labelName) < requiredApproval.value()
+        && newVote.value() >= requiredApproval.value();
+  }
+
+  private boolean isCodeOwnerApprovalRemoved(
+      RequiredApproval requiredApproval, Map<String, Short> oldApprovals, LabelVote newVote) {
+    String labelName = requiredApproval.labelType().getName();
+    return oldApprovals.get(labelName) >= requiredApproval.value()
+        && newVote.value() < requiredApproval.value();
+  }
+
+  private boolean isCodeOwnerApprovalUpOrDowngraded(
+      RequiredApproval requiredApproval, Map<String, Short> oldApprovals, LabelVote newVote) {
+    String labelName = requiredApproval.labelType().getName();
+    return oldApprovals.get(labelName) >= requiredApproval.value()
+        && newVote.value() >= requiredApproval.value();
+  }
+
+  private LabelVote getNewVote(RequiredApproval requiredApproval, Map<String, Short> approvals) {
+    String labelName = requiredApproval.labelType().getName();
+    checkState(
+        approvals.containsKey(labelName),
+        "expected that approval on label %s exists (approvals = %s)",
+        labelName,
+        approvals);
+    return LabelVote.create(labelName, approvals.get(labelName));
+  }
+
+  private ImmutableList<Path> getOwnedPaths(ChangeNotes changeNotes, Account.Id accountId) {
+    try {
+      return codeOwnerApprovalCheck
+          .getFileStatusesForAccount(changeNotes, accountId)
+          .flatMap(
+              fileCodeOwnerStatus ->
+                  Stream.of(
+                          fileCodeOwnerStatus.newPathStatus(), fileCodeOwnerStatus.oldPathStatus())
+                      .filter(Optional::isPresent)
+                      .map(Optional::get))
+          .filter(PathCodeOwnerStatus -> PathCodeOwnerStatus.status() == CodeOwnerStatus.APPROVED)
+          .map(PathCodeOwnerStatus::path)
+          .sorted(comparing(Path::toString))
+          .collect(toImmutableList());
+    } catch (IOException | ResourceConflictException | PatchListNotAvailableException e) {
+      logger.atSevere().withCause(e).log(
+          "Failed to compute owned paths of change %s for account %s",
+          changeNotes.getChangeId(), accountId.get());
+      return ImmutableList.of();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/FallbackCodeOwners.java b/java/com/google/gerrit/plugins/codeowners/backend/FallbackCodeOwners.java
new file mode 100644
index 0000000..5ad4079
--- /dev/null
+++ b/java/com/google/gerrit/plugins/codeowners/backend/FallbackCodeOwners.java
@@ -0,0 +1,36 @@
+// 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.
+
+package com.google.gerrit.plugins.codeowners.backend;
+
+/** Defines who owns paths for which no code owners are defined. */
+public enum FallbackCodeOwners {
+  /**
+   * Paths for which no code owners are defined are owned by no one. This means changes that touch
+   * these files can only be submitted with a code owner override.
+   */
+  NONE,
+
+  /**
+   * Paths for which no code owners are defined are owned by all users. This means changes to these
+   * paths can be approved by anyone. If implicit approvals are enabled, these files are always
+   * automatically approved.
+   *
+   * <p>The {@code ALL_USERS} option should only be used with care as it means that any path that is
+   * not covered by the code owner config files is automatically opened up to everyone and mistakes
+   * with configuring code owners can easily happen. This is why this option is intended to be only
+   * used if requiring code owner approvals should not be enforced.
+   */
+  ALL_USERS;
+}
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/FindOwnersGlobMatcher.java b/java/com/google/gerrit/plugins/codeowners/backend/FindOwnersGlobMatcher.java
new file mode 100644
index 0000000..2db7b6d
--- /dev/null
+++ b/java/com/google/gerrit/plugins/codeowners/backend/FindOwnersGlobMatcher.java
@@ -0,0 +1,80 @@
+// 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.
+
+package com.google.gerrit.plugins.codeowners.backend;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.flogger.FluentLogger;
+import java.nio.file.Path;
+
+/**
+ * Glob matcher that is compatible with how globs are interpreted by the {@code find-owners} plugin.
+ *
+ * <p>This matcher has the same behaviour as the {@link GlobMatcher} except that:
+ *
+ * <ul>
+ *   <li>'*': matches any string, including slashes (same as '**')
+ * </ul>
+ */
+public class FindOwnersGlobMatcher implements PathExpressionMatcher {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  /** Singleton instance. */
+  public static FindOwnersGlobMatcher INSTANCE = new FindOwnersGlobMatcher();
+
+  /** Private constructor to prevent creation of further instances. */
+  private FindOwnersGlobMatcher() {}
+
+  @Override
+  public boolean matches(String glob, Path relativePath) {
+    String adaptedGlob = replaceSingleStarWithDoubleStar(glob);
+    logger.atFine().log("adapted glob = %s", adaptedGlob);
+    return GlobMatcher.INSTANCE.matches(adaptedGlob, relativePath);
+  }
+
+  /**
+   * Replaces any single '*' in the given glob with '**'. Non-single '*'s, like '**' or '***', stay
+   * unchanged.
+   *
+   * @param glob glob in which any single '*' should be replaced by '**'
+   */
+  @VisibleForTesting
+  String replaceSingleStarWithDoubleStar(String glob) {
+    StringBuilder adaptedGlob = new StringBuilder();
+    Character previousChar = null;
+    boolean maybeSingleStar = false;
+    for (char nextCharacter : glob.toCharArray()) {
+      if (maybeSingleStar && nextCharacter != '*') {
+        // the previous character was a '*' that was not preceded by '*' (maybeSingleStar == true),
+        // since the next character is not '*', we are now sure that the previous character was a
+        // single '*' which should be replaced by '**',
+        // to do this append another '*'
+        adaptedGlob.append('*');
+      }
+      adaptedGlob.append(nextCharacter);
+
+      // the current character may be a single '*' if it's not preceded by '*'
+      maybeSingleStar =
+          nextCharacter == '*' && (previousChar == null || previousChar.charValue() != '*');
+      previousChar = nextCharacter;
+    }
+
+    if (maybeSingleStar) {
+      // the last character was a '*' that was not preceded by '*'
+      adaptedGlob.append('*');
+    }
+
+    return adaptedGlob.toString();
+  }
+}
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/PathCodeOwners.java b/java/com/google/gerrit/plugins/codeowners/backend/PathCodeOwners.java
index c8a9b82..ae14883 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/PathCodeOwners.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/PathCodeOwners.java
@@ -20,7 +20,7 @@
 import static java.util.stream.Collectors.toSet;
 
 import com.google.common.annotations.VisibleForTesting;
-import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ImmutableList;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Project;
@@ -36,6 +36,7 @@
 import java.util.ArrayDeque;
 import java.util.HashMap;
 import java.util.HashSet;
+import java.util.List;
 import java.util.Map;
 import java.util.Optional;
 import java.util.Queue;
@@ -123,7 +124,7 @@
   private final Path path;
   private final PathExpressionMatcher pathExpressionMatcher;
 
-  private CodeOwnerConfig resolvedCodeOwnerConfig;
+  private PathCodeOwnersResult pathCodeOwnersResult;
 
   private PathCodeOwners(
       ProjectCache projectCache,
@@ -146,32 +147,6 @@
   }
 
   /**
-   * Gets the code owners from the code owner config that apply to the path.
-   *
-   * <p>Code owners from inherited code owner configs are not considered.
-   *
-   * @return the code owners of the path
-   */
-  public ImmutableSet<CodeOwnerReference> get() {
-    logger.atFine().log("computing path code owners for %s from %s", path, codeOwnerConfig.key());
-    ImmutableSet<CodeOwnerReference> pathCodeOwners =
-        resolveCodeOwnerConfig().codeOwnerSets().stream()
-            .flatMap(codeOwnerSet -> codeOwnerSet.codeOwners().stream())
-            .collect(toImmutableSet());
-    logger.atFine().log("pathCodeOwners = %s", pathCodeOwners);
-    return pathCodeOwners;
-  }
-
-  /**
-   * Whether parent code owners should be ignored for the path.
-   *
-   * @return whether parent code owners should be ignored for the path
-   */
-  public boolean ignoreParentCodeOwners() {
-    return resolveCodeOwnerConfig().ignoreParentCodeOwners();
-  }
-
-  /**
    * Resolves the {@link #codeOwnerConfig}.
    *
    * <p>Resolving means that:
@@ -207,9 +182,9 @@
    *
    * @return the resolved code owner config
    */
-  private CodeOwnerConfig resolveCodeOwnerConfig() {
-    if (this.resolvedCodeOwnerConfig != null) {
-      return this.resolvedCodeOwnerConfig;
+  public PathCodeOwnersResult resolveCodeOwnerConfig() {
+    if (this.pathCodeOwnersResult != null) {
+      return this.pathCodeOwnersResult;
     }
 
     try (TraceTimer traceTimer =
@@ -233,7 +208,8 @@
       getMatchingPerFileCodeOwnerSets(codeOwnerConfig)
           .forEach(resolvedCodeOwnerConfigBuilder::addCodeOwnerSet);
 
-      resolveImports(codeOwnerConfig, resolvedCodeOwnerConfigBuilder);
+      List<UnresolvedImport> unresolvedImports =
+          resolveImports(codeOwnerConfig, resolvedCodeOwnerConfigBuilder);
 
       CodeOwnerConfig resolvedCodeOwnerConfig = resolvedCodeOwnerConfigBuilder.build();
 
@@ -255,15 +231,24 @@
                 .build();
       }
 
-      this.resolvedCodeOwnerConfig = resolvedCodeOwnerConfig;
-      logger.atFine().log("resolved code owner config = %s", resolvedCodeOwnerConfig);
-      return this.resolvedCodeOwnerConfig;
+      this.pathCodeOwnersResult =
+          PathCodeOwnersResult.create(path, resolvedCodeOwnerConfig, unresolvedImports);
+      logger.atFine().log("path code owners result = %s", pathCodeOwnersResult);
+      return this.pathCodeOwnersResult;
     }
   }
 
-  private void resolveImports(
+  /**
+   * Resolve the imports of the given code owner config.
+   *
+   * @param importingCodeOwnerConfig the code owner config for which imports should be resolved
+   * @param resolvedCodeOwnerConfigBuilder the builder for the resolved code owner config
+   * @return list of unresolved imports, empty list if all imports were successfully resolved
+   */
+  private List<UnresolvedImport> resolveImports(
       CodeOwnerConfig importingCodeOwnerConfig,
       CodeOwnerConfig.Builder resolvedCodeOwnerConfigBuilder) {
+    ImmutableList.Builder<UnresolvedImport> unresolvedImports = ImmutableList.builder();
     try (TraceTimer traceTimer =
         TraceContext.newTimer(
             "Resolve code owner config imports",
@@ -307,21 +292,24 @@
           Optional<ProjectState> projectState =
               projectCache.get(keyOfImportedCodeOwnerConfig.project());
           if (!projectState.isPresent()) {
-            logger.atWarning().log(
-                "cannot resolve code owner config %s that is imported by code owner config %s:"
-                    + " project %s not found",
-                keyOfImportedCodeOwnerConfig,
-                importingCodeOwnerConfig.key(),
-                keyOfImportedCodeOwnerConfig.project().get());
+            unresolvedImports.add(
+                UnresolvedImport.create(
+                    codeOwnerConfig.key(),
+                    keyOfImportedCodeOwnerConfig,
+                    codeOwnerConfigReference,
+                    String.format(
+                        "project %s not found", keyOfImportedCodeOwnerConfig.project().get())));
             continue;
           }
           if (!projectState.get().statePermitsRead()) {
-            logger.atWarning().log(
-                "cannot resolve code owner config %s that is imported by code owner config %s:"
-                    + " state of project %s doesn't permit read",
-                keyOfImportedCodeOwnerConfig,
-                importingCodeOwnerConfig.key(),
-                keyOfImportedCodeOwnerConfig.project().get());
+            unresolvedImports.add(
+                UnresolvedImport.create(
+                    codeOwnerConfig.key(),
+                    keyOfImportedCodeOwnerConfig,
+                    codeOwnerConfigReference,
+                    String.format(
+                        "state of project %s doesn't permit read",
+                        keyOfImportedCodeOwnerConfig.project().get())));
             continue;
           }
 
@@ -337,12 +325,14 @@
                   : codeOwners.getFromCurrentRevision(keyOfImportedCodeOwnerConfig);
 
           if (!mayBeImportedCodeOwnerConfig.isPresent()) {
-            logger.atWarning().log(
-                "cannot resolve code owner config %s that is imported by code owner config %s"
-                    + " (revision = %s)",
-                keyOfImportedCodeOwnerConfig,
-                importingCodeOwnerConfig.key(),
-                revision.map(ObjectId::name).orElse("current"));
+            unresolvedImports.add(
+                UnresolvedImport.create(
+                    codeOwnerConfig.key(),
+                    keyOfImportedCodeOwnerConfig,
+                    codeOwnerConfigReference,
+                    String.format(
+                        "code owner config does not exist (revision = %s)",
+                        revision.map(ObjectId::name).orElse("current"))));
             continue;
           }
 
@@ -404,6 +394,7 @@
         }
       }
     }
+    return unresolvedImports.build();
   }
 
   public static CodeOwnerConfig.Key createKeyForImportedCodeOwnerConfig(
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/PathCodeOwnersResult.java b/java/com/google/gerrit/plugins/codeowners/backend/PathCodeOwnersResult.java
new file mode 100644
index 0000000..934d730
--- /dev/null
+++ b/java/com/google/gerrit/plugins/codeowners/backend/PathCodeOwnersResult.java
@@ -0,0 +1,88 @@
+// 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.
+
+package com.google.gerrit.plugins.codeowners.backend;
+
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.base.MoreObjects;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.flogger.FluentLogger;
+import java.nio.file.Path;
+import java.util.List;
+
+/** The result of resolving path code owners via {@link PathCodeOwners}. */
+@AutoValue
+public abstract class PathCodeOwnersResult {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  /** Gets the path for which the code owner config was resolved. */
+  abstract Path path();
+
+  /** Gets the resolved code owner config. */
+  abstract CodeOwnerConfig codeOwnerConfig();
+
+  /** Gets a list of unresolved imports. */
+  public abstract ImmutableList<UnresolvedImport> unresolvedImports();
+
+  /** Whether there are unresolved imports. */
+  public boolean hasUnresolvedImports() {
+    return !unresolvedImports().isEmpty();
+  }
+
+  /**
+   * Gets the code owners from the code owner config that apply to the path.
+   *
+   * <p>Code owners from inherited code owner configs are not considered.
+   *
+   * @return the code owners of the path
+   */
+  public ImmutableSet<CodeOwnerReference> getPathCodeOwners() {
+    logger.atFine().log(
+        "computing path code owners for %s from %s", path(), codeOwnerConfig().key());
+    ImmutableSet<CodeOwnerReference> pathCodeOwners =
+        codeOwnerConfig().codeOwnerSets().stream()
+            .flatMap(codeOwnerSet -> codeOwnerSet.codeOwners().stream())
+            .collect(toImmutableSet());
+    logger.atFine().log("pathCodeOwners = %s", pathCodeOwners);
+    return pathCodeOwners;
+  }
+
+  /**
+   * Whether parent code owners should be ignored for the path.
+   *
+   * @return whether parent code owners should be ignored for the path
+   */
+  public boolean ignoreParentCodeOwners() {
+    return codeOwnerConfig().ignoreParentCodeOwners();
+  }
+
+  @Override
+  public String toString() {
+    return MoreObjects.toStringHelper(this)
+        .add("path", path())
+        .add("codeOwnerConfig", codeOwnerConfig())
+        .add("unresolvedImports", unresolvedImports())
+        .toString();
+  }
+
+  /** Creates a {@link PathCodeOwnersResult} instance. */
+  public static PathCodeOwnersResult create(
+      Path path, CodeOwnerConfig codeOwnerConfig, List<UnresolvedImport> unresolvedImports) {
+    return new AutoValue_PathCodeOwnersResult(
+        path, codeOwnerConfig, ImmutableList.copyOf(unresolvedImports));
+  }
+}
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/UnresolvedImport.java b/java/com/google/gerrit/plugins/codeowners/backend/UnresolvedImport.java
new file mode 100644
index 0000000..f2491b7
--- /dev/null
+++ b/java/com/google/gerrit/plugins/codeowners/backend/UnresolvedImport.java
@@ -0,0 +1,75 @@
+// 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.
+
+package com.google.gerrit.plugins.codeowners.backend;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.base.MoreObjects;
+import com.google.gerrit.plugins.codeowners.config.CodeOwnersPluginConfiguration;
+
+/** Information about an unresolved import. */
+@AutoValue
+public abstract class UnresolvedImport {
+  /** Key of the importing code owner config. */
+  public abstract CodeOwnerConfig.Key keyOfImportingCodeOwnerConfig();
+
+  /** Key of the imported code owner config. */
+  public abstract CodeOwnerConfig.Key keyOfImportedCodeOwnerConfig();
+
+  /** The code owner config reference that was attempted to be resolved. */
+  public abstract CodeOwnerConfigReference codeOwnerConfigReference();
+
+  /** Message explaining why the code owner config reference couldn't be resolved. */
+  public abstract String message();
+
+  /** Returns a user-readable string representation of this unresolved import. */
+  public String format(CodeOwnersPluginConfiguration codeOwnersPluginConfiguration) {
+    return String.format(
+        "The import of %s:%s:%s in %s:%s:%s cannot be resolved: %s",
+        keyOfImportedCodeOwnerConfig().project(),
+        keyOfImportedCodeOwnerConfig().shortBranchName(),
+        codeOwnersPluginConfiguration
+            .getBackend(keyOfImportedCodeOwnerConfig().branchNameKey())
+            .getFilePath(keyOfImportedCodeOwnerConfig()),
+        keyOfImportingCodeOwnerConfig().project(),
+        keyOfImportingCodeOwnerConfig().shortBranchName(),
+        codeOwnersPluginConfiguration
+            .getBackend(keyOfImportingCodeOwnerConfig().branchNameKey())
+            .getFilePath(keyOfImportingCodeOwnerConfig()),
+        message());
+  }
+
+  @Override
+  public String toString() {
+    return MoreObjects.toStringHelper(this)
+        .add("keyOfImportingCodeOwnerConfig", keyOfImportingCodeOwnerConfig())
+        .add("keyOfImportedCodeOwnerConfig", keyOfImportedCodeOwnerConfig())
+        .add("codeOwnerConfigReference", codeOwnerConfigReference())
+        .add("message", message())
+        .toString();
+  }
+
+  /** Creates a {@link UnresolvedImport} instance. */
+  static UnresolvedImport create(
+      CodeOwnerConfig.Key keyOfImportingCodeOwnerConfig,
+      CodeOwnerConfig.Key keyOfImportedCodeOwnerConfig,
+      CodeOwnerConfigReference codeOwnerConfigReference,
+      String message) {
+    return new AutoValue_UnresolvedImport(
+        keyOfImportingCodeOwnerConfig,
+        keyOfImportedCodeOwnerConfig,
+        codeOwnerConfigReference,
+        message);
+  }
+}
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/findowners/FindOwnersBackend.java b/java/com/google/gerrit/plugins/codeowners/backend/findowners/FindOwnersBackend.java
index 0aa9ca8..15b4385 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/findowners/FindOwnersBackend.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/findowners/FindOwnersBackend.java
@@ -17,7 +17,7 @@
 import com.google.common.annotations.VisibleForTesting;
 import com.google.gerrit.plugins.codeowners.backend.AbstractFileBasedCodeOwnerBackend;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfig;
-import com.google.gerrit.plugins.codeowners.backend.GlobMatcher;
+import com.google.gerrit.plugins.codeowners.backend.FindOwnersGlobMatcher;
 import com.google.gerrit.plugins.codeowners.backend.PathExpressionMatcher;
 import com.google.gerrit.plugins.codeowners.config.CodeOwnersPluginConfiguration;
 import com.google.gerrit.server.GerritPersonIdent;
@@ -61,6 +61,12 @@
 
   @Override
   public Optional<PathExpressionMatcher> getPathExpressionMatcher() {
-    return Optional.of(GlobMatcher.INSTANCE);
+    return Optional.of(FindOwnersGlobMatcher.INSTANCE);
+  }
+
+  @Override
+  public String replaceEmail(String codeOwnerConfigFileContent, String oldEmail, String newEmail) {
+    return FindOwnersCodeOwnerConfigParser.replaceEmail(
+        codeOwnerConfigFileContent, oldEmail, newEmail);
   }
 }
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/findowners/FindOwnersCodeOwnerConfigParser.java b/java/com/google/gerrit/plugins/codeowners/backend/findowners/FindOwnersCodeOwnerConfigParser.java
index 7e8fff8..7a4261a 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/findowners/FindOwnersCodeOwnerConfigParser.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/findowners/FindOwnersCodeOwnerConfigParser.java
@@ -20,6 +20,7 @@
 import static java.util.stream.Collectors.joining;
 
 import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Joiner;
 import com.google.common.base.Splitter;
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
@@ -49,14 +50,6 @@
  * <p>The syntax is described at in the {@code find-owners} plugin documentation at:
  * https://gerrit.googlesource.com/plugins/find-owners/+/master/src/main/resources/Documentation/syntax.md
  *
- * <p><strong>Note:</strong> Currently this class only supports a subset of the syntax. Only the
- * following syntax elements are supported:
- *
- * <ul>
- *   <li>comment: a line can be a comment (comments must start with '#')
- *   <li>code owner emails: a line can be the email of a code owner
- * </ul>
- *
  * <p>Comment lines are silently ignored.
  *
  * <p>Invalid lines cause the parsing to fail and trigger a {@link CodeOwnerConfigParseException}.
@@ -77,6 +70,12 @@
   // Artifical owner token for "set noparent" when used in per-file.
   private static final String TOK_SET_NOPARENT = "set noparent";
 
+  /**
+   * Any Unicode linebreak sequence, is equivalent to {@code
+   * \u000D\u000A|[\u000A\u000B\u000C\u000D\u0085\u2028\u2029]}.
+   */
+  private static final String LINEBREAK_MATCHER = "\\R";
+
   @Override
   public CodeOwnerConfig parse(
       ObjectId revision, CodeOwnerConfig.Key codeOwnerConfigKey, String codeOwnerConfigAsString)
@@ -98,6 +97,35 @@
     return Formatter.formatAsString(requireNonNull(codeOwnerConfig, "codeOwnerConfig"));
   }
 
+  public static String replaceEmail(
+      String codeOwnerConfigFileContent, String oldEmail, String newEmail) {
+    requireNonNull(codeOwnerConfigFileContent, "codeOwnerConfigFileContent");
+    requireNonNull(oldEmail, "oldEmail");
+    requireNonNull(newEmail, "newEmail");
+
+    String charsThatCanAppearBeforeOrAfterEmail = "[\\s=,#]";
+    Pattern pattern =
+        Pattern.compile(
+            "(^|.*"
+                + charsThatCanAppearBeforeOrAfterEmail
+                + "+)"
+                + "("
+                + Pattern.quote(oldEmail)
+                + ")"
+                + "($|"
+                + charsThatCanAppearBeforeOrAfterEmail
+                + "+.*)");
+
+    List<String> updatedLines = new ArrayList<>();
+    for (String line : Splitter.onPattern(LINEBREAK_MATCHER).split(codeOwnerConfigFileContent)) {
+      while (pattern.matcher(line).matches()) {
+        line = pattern.matcher(line).replaceFirst("$1" + newEmail + "$3");
+      }
+      updatedLines.add(line);
+    }
+    return Joiner.on("\n").join(updatedLines);
+  }
+
   private static class Parser implements ValidationError.Sink {
     private static final String COMMA = "[\\s]*,[\\s]*";
 
@@ -158,7 +186,7 @@
       CodeOwnerSet.Builder globalCodeOwnerSetBuilder = CodeOwnerSet.builder();
       List<CodeOwnerSet> perFileCodeOwnerSet = new ArrayList<>();
 
-      for (String line : Splitter.onPattern("\\R").split(codeOwnerConfigAsString)) {
+      for (String line : Splitter.onPattern(LINEBREAK_MATCHER).split(codeOwnerConfigAsString)) {
         parseLine(codeOwnerConfigBuilder, globalCodeOwnerSetBuilder, perFileCodeOwnerSet, line);
       }
 
diff --git a/java/com/google/gerrit/plugins/codeowners/config/AbstractRequiredApprovalConfig.java b/java/com/google/gerrit/plugins/codeowners/config/AbstractRequiredApprovalConfig.java
index dcf50e7..260d9d9 100644
--- a/java/com/google/gerrit/plugins/codeowners/config/AbstractRequiredApprovalConfig.java
+++ b/java/com/google/gerrit/plugins/codeowners/config/AbstractRequiredApprovalConfig.java
@@ -17,13 +17,13 @@
 import static com.google.gerrit.plugins.codeowners.config.CodeOwnersPluginConfiguration.SECTION_CODE_OWNERS;
 import static java.util.Objects.requireNonNull;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.extensions.annotations.PluginName;
 import com.google.gerrit.server.config.PluginConfigFactory;
 import com.google.gerrit.server.git.validators.CommitValidationMessage;
 import com.google.gerrit.server.git.validators.ValidationMessage;
 import com.google.gerrit.server.project.ProjectLevelConfig;
 import com.google.gerrit.server.project.ProjectState;
-import java.util.Optional;
 import org.eclipse.jgit.lib.Config;
 
 /**
@@ -50,45 +50,60 @@
 
   protected abstract String getConfigKey();
 
-  Optional<RequiredApproval> getForProject(ProjectState projectState, Config pluginConfig) {
+  /**
+   * Reads the required approvals for the specified project from the given plugin config with
+   * fallback to {@code gerrit.config}.
+   *
+   * @param projectState state of the project for which the required approvals should be read
+   * @param pluginConfig the plugin config from which the required approvals should be read
+   * @return the required approvals, an empty list if none was configured
+   */
+  ImmutableList<RequiredApproval> get(ProjectState projectState, Config pluginConfig) {
     requireNonNull(projectState, "projectState");
     requireNonNull(pluginConfig, "pluginConfig");
-    String requiredApproval = pluginConfig.getString(SECTION_CODE_OWNERS, null, getConfigKey());
-    if (requiredApproval == null) {
-      return Optional.empty();
+
+    ImmutableList.Builder<RequiredApproval> requiredApprovalList = ImmutableList.builder();
+    String[] requiredApprovals =
+        pluginConfig.getStringList(SECTION_CODE_OWNERS, /* subsection= */ null, getConfigKey());
+    if (requiredApprovals.length > 0) {
+      for (String requiredApproval : requiredApprovals) {
+        try {
+          requiredApprovalList.add(RequiredApproval.parse(projectState, requiredApproval));
+        } catch (IllegalStateException | IllegalArgumentException e) {
+          throw new InvalidPluginConfigurationException(
+              pluginName,
+              String.format(
+                  "Required approval '%s' that is configured in %s.config"
+                      + " (parameter %s.%s) is invalid: %s",
+                  requiredApproval,
+                  pluginName,
+                  SECTION_CODE_OWNERS,
+                  getConfigKey(),
+                  e.getMessage()));
+        }
+      }
+      return requiredApprovalList.build();
     }
 
-    try {
-      return Optional.of(RequiredApproval.parse(projectState, requiredApproval));
-    } catch (IllegalStateException | IllegalArgumentException e) {
-      throw new InvalidPluginConfigurationException(
-          pluginName,
-          String.format(
-              "Required approval '%s' that is configured in %s.config"
-                  + " (parameter %s.%s) is invalid: %s",
-              requiredApproval, pluginName, SECTION_CODE_OWNERS, getConfigKey(), e.getMessage()));
-    }
-  }
-
-  Optional<RequiredApproval> getFromGlobalPluginConfig(ProjectState projectState) {
-    requireNonNull(projectState, "projectState");
-
-    String requiredApproval =
-        pluginConfigFactory.getFromGerritConfig(pluginName).getString(getConfigKey());
-    if (requiredApproval == null) {
-      return Optional.empty();
+    requiredApprovals =
+        pluginConfigFactory.getFromGerritConfig(pluginName).getStringList(getConfigKey());
+    if (requiredApprovals.length > 0) {
+      for (String requiredApproval : requiredApprovals) {
+        try {
+          requiredApprovalList.add(RequiredApproval.parse(projectState, requiredApproval));
+        } catch (IllegalStateException | IllegalArgumentException e) {
+          throw new InvalidPluginConfigurationException(
+              pluginName,
+              String.format(
+                  "Required approval '%s' that is configured in gerrit.config"
+                      + " (parameter plugin.%s.%s) is invalid: %s",
+                  requiredApproval, pluginName, getConfigKey(), e.getMessage()));
+        }
+      }
+      return requiredApprovalList.build();
     }
 
-    try {
-      return Optional.of(RequiredApproval.parse(projectState, requiredApproval));
-    } catch (IllegalStateException | IllegalArgumentException e) {
-      throw new InvalidPluginConfigurationException(
-          pluginName,
-          String.format(
-              "Required approval '%s' that is configured in gerrit.config"
-                  + " (parameter plugin.%s.%s) is invalid: %s",
-              requiredApproval, pluginName, getConfigKey(), e.getMessage()));
-    }
+    return ImmutableList.of();
   }
 
   /**
@@ -99,19 +114,22 @@
    * @return list of validation messages for validation errors, empty list if there are no
    *     validation errors
    */
-  Optional<CommitValidationMessage> validateProjectLevelConfig(
+  ImmutableList<CommitValidationMessage> validateProjectLevelConfig(
       ProjectState projectState, String fileName, ProjectLevelConfig.Bare projectLevelConfig) {
     requireNonNull(projectState, "projectState");
     requireNonNull(fileName, "fileName");
     requireNonNull(projectLevelConfig, "projectLevelConfig");
 
-    String requiredApproval =
-        projectLevelConfig.getConfig().getString(SECTION_CODE_OWNERS, null, getConfigKey());
-    if (requiredApproval != null) {
+    String[] requiredApprovals =
+        projectLevelConfig
+            .getConfig()
+            .getStringList(SECTION_CODE_OWNERS, /* subsection= */ null, getConfigKey());
+    ImmutableList.Builder<CommitValidationMessage> validationMessages = ImmutableList.builder();
+    for (String requiredApproval : requiredApprovals) {
       try {
         RequiredApproval.parse(projectState, requiredApproval);
       } catch (IllegalArgumentException | IllegalStateException e) {
-        return Optional.of(
+        validationMessages.add(
             new CommitValidationMessage(
                 String.format(
                     "Required approval '%s' that is configured in %s (parameter %s.%s) is invalid: %s",
@@ -123,6 +141,6 @@
                 ValidationMessage.Type.ERROR));
       }
     }
-    return Optional.empty();
+    return validationMessages.build();
   }
 }
diff --git a/java/com/google/gerrit/plugins/codeowners/config/CodeOwnersPluginConfigValidator.java b/java/com/google/gerrit/plugins/codeowners/config/CodeOwnersPluginConfigValidator.java
index dba7bb2..6a76360 100644
--- a/java/com/google/gerrit/plugins/codeowners/config/CodeOwnersPluginConfigValidator.java
+++ b/java/com/google/gerrit/plugins/codeowners/config/CodeOwnersPluginConfigValidator.java
@@ -14,8 +14,6 @@
 
 package com.google.gerrit.plugins.codeowners.config;
 
-import static com.google.gerrit.server.project.ProjectCache.illegalState;
-
 import com.google.common.collect.ImmutableList;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Project;
@@ -30,7 +28,7 @@
 import com.google.gerrit.server.git.validators.CommitValidationMessage;
 import com.google.gerrit.server.git.validators.ValidationMessage;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
-import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.ProjectLevelConfig;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.inject.Inject;
@@ -41,6 +39,7 @@
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
 
 /** Validates modifications to the {@code code-owners.config} file in {@code refs/meta/config}. */
 @Singleton
@@ -48,8 +47,9 @@
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   private final String pluginName;
-  private final ProjectCache projectCache;
   private final GitRepositoryManager repoManager;
+  private final ProjectConfig.Factory projectConfigFactory;
+  private final ProjectState.Factory projectStateFactory;
   private final ChangedFiles changedFiles;
   private final GeneralConfig generalConfig;
   private final StatusConfig statusConfig;
@@ -60,8 +60,9 @@
   @Inject
   CodeOwnersPluginConfigValidator(
       @PluginName String pluginName,
-      ProjectCache projectCache,
       GitRepositoryManager repoManager,
+      ProjectConfig.Factory projectConfigFactory,
+      ProjectState.Factory projectStateFactory,
       ChangedFiles changedFiles,
       GeneralConfig generalConfig,
       StatusConfig statusConfig,
@@ -69,8 +70,9 @@
       RequiredApprovalConfig requiredApprovalConfig,
       OverrideApprovalConfig overrideApprovalConfig) {
     this.pluginName = pluginName;
-    this.projectCache = projectCache;
     this.repoManager = repoManager;
+    this.projectConfigFactory = projectConfigFactory;
+    this.projectStateFactory = projectStateFactory;
     this.changedFiles = changedFiles;
     this.generalConfig = generalConfig;
     this.statusConfig = statusConfig;
@@ -93,20 +95,29 @@
         return ImmutableList.of();
       }
 
-      ProjectState projectState = projectCache.get(project).orElseThrow(illegalState(project));
+      ProjectState projectState = getProjectState(project, receiveEvent.commit);
       ProjectLevelConfig.Bare cfg = loadConfig(project, fileName, receiveEvent.commit);
       validateConfig(projectState, fileName, cfg);
       return ImmutableList.of();
-    } catch (IOException | PatchListNotAvailableException e) {
+    } catch (IOException | ConfigInvalidException | PatchListNotAvailableException e) {
       String errorMessage =
           String.format(
               "failed to validate file %s for revision %s in ref %s of project %s",
               fileName, receiveEvent.commit.getName(), RefNames.REFS_CONFIG, project);
-      logger.atSevere().log(errorMessage);
+      logger.atSevere().withCause(e).log(errorMessage);
       throw new CommitValidationException(errorMessage, e);
     }
   }
 
+  private ProjectState getProjectState(Project.NameKey projectName, RevCommit commit)
+      throws IOException, ConfigInvalidException {
+    try (Repository repo = repoManager.openRepository(projectName)) {
+      ProjectConfig projectConfig = projectConfigFactory.create(projectName);
+      projectConfig.load(repo, commit);
+      return projectStateFactory.create(projectConfig.getCacheable());
+    }
+  }
+
   /**
    * Whether the given file was changed in the given revision.
    *
@@ -158,12 +169,10 @@
     validationMessages.addAll(backendConfig.validateProjectLevelConfig(fileName, cfg));
     validationMessages.addAll(generalConfig.validateProjectLevelConfig(fileName, cfg));
     validationMessages.addAll(statusConfig.validateProjectLevelConfig(fileName, cfg));
-    requiredApprovalConfig
-        .validateProjectLevelConfig(projectState, fileName, cfg)
-        .ifPresent(validationMessages::add);
-    overrideApprovalConfig
-        .validateProjectLevelConfig(projectState, fileName, cfg)
-        .ifPresent(validationMessages::add);
+    validationMessages.addAll(
+        requiredApprovalConfig.validateProjectLevelConfig(projectState, fileName, cfg));
+    validationMessages.addAll(
+        overrideApprovalConfig.validateProjectLevelConfig(projectState, fileName, cfg));
     if (!validationMessages.isEmpty()) {
       throw new CommitValidationException(
           exceptionMessage(fileName, cfg.getRevision()), validationMessages);
diff --git a/java/com/google/gerrit/plugins/codeowners/config/CodeOwnersPluginConfiguration.java b/java/com/google/gerrit/plugins/codeowners/config/CodeOwnersPluginConfiguration.java
index a881e1c..f55642b 100644
--- a/java/com/google/gerrit/plugins/codeowners/config/CodeOwnersPluginConfiguration.java
+++ b/java/com/google/gerrit/plugins/codeowners/config/CodeOwnersPluginConfiguration.java
@@ -18,9 +18,12 @@
 import static java.util.Objects.requireNonNull;
 
 import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.annotations.PluginName;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
@@ -28,12 +31,15 @@
 import com.google.gerrit.plugins.codeowners.api.MergeCommitStrategy;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerBackend;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerReference;
+import com.google.gerrit.plugins.codeowners.backend.FallbackCodeOwners;
 import com.google.gerrit.server.config.PluginConfigFactory;
 import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
+import java.util.HashMap;
+import java.util.Map;
 import java.util.Optional;
 import org.eclipse.jgit.lib.Config;
 
@@ -124,6 +130,20 @@
   }
 
   /**
+   * Whether code owner configs should be validated when a change is submitted.
+   *
+   * @param project the project for it should be checked whether code owner configs should be
+   *     validated when a change is submitted
+   * @return whether code owner configs should be validated when a change is submitted
+   */
+  public CodeOwnerConfigValidationPolicy getCodeOwnerConfigValidationPolicyForSubmit(
+      Project.NameKey project) {
+    requireNonNull(project, "project");
+    return generalConfig.getCodeOwnerConfigValidationPolicyForSubmit(
+        project, getPluginConfig(project));
+  }
+
+  /**
    * Gets the merge commit strategy for the given project.
    *
    * @param project the project for which the merge commit strategy should be retrieved
@@ -135,6 +155,28 @@
   }
 
   /**
+   * Gets the fallback code owners for the given project.
+   *
+   * @param project the project for which the fallback code owners should be retrieved
+   * @return the fallback code owners for the given project
+   */
+  public FallbackCodeOwners getFallbackCodeOwners(Project.NameKey project) {
+    requireNonNull(project, "project");
+    return generalConfig.getFallbackCodeOwners(project, getPluginConfig(project));
+  }
+
+  /**
+   * Gets the max paths in change messages for the given project.
+   *
+   * @param project the project for which the fallback code owners should be retrieved
+   * @return the fallback code owners for the given project
+   */
+  public int getMaxPathsInChangeMessages(Project.NameKey project) {
+    requireNonNull(project, "project");
+    return generalConfig.getMaxPathsInChangeMessages(project, getPluginConfig(project));
+  }
+
+  /**
    * Checks whether an implicit code owner approval from the last uploader is assumed.
    *
    * @param project the project for it should be checked whether implict approvals are enabled
@@ -142,6 +184,14 @@
    */
   public boolean areImplicitApprovalsEnabled(Project.NameKey project) {
     requireNonNull(project, "project");
+    LabelType requiredLabel = getRequiredApproval(project).labelType();
+    if (requiredLabel.isIgnoreSelfApproval()) {
+      logger.atFine().log(
+          "ignoring implicit approval configuration on project %s since the label of the required"
+              + " approval (%s) is configured to ignore self approvals",
+          project, requiredLabel);
+      return false;
+    }
     return generalConfig.getEnableImplicitApprovals(getPluginConfig(project));
   }
 
@@ -322,16 +372,22 @@
    *   <li>hard-coded default required approval
    * </ul>
    *
-   * <p>The first required code owner approval that exists counts and the evaluation is stopped.
+   * <p>The first required code owner approval configuration that exists counts and the evaluation
+   * is stopped.
+   *
+   * <p>If the code owner configuration contains multiple required approvals values, the last value
+   * is used.
    *
    * @param project project for which the required approval should be returned
    * @return the required code owner approval that should be used for the given project
    */
   public RequiredApproval getRequiredApproval(Project.NameKey project) {
-    Optional<RequiredApproval> configuredRequiredApprovalConfig =
+    ImmutableList<RequiredApproval> configuredRequiredApprovalConfig =
         getConfiguredRequiredApproval(requiredApprovalConfig, project);
-    if (configuredRequiredApprovalConfig.isPresent()) {
-      return configuredRequiredApprovalConfig.get();
+    if (!configuredRequiredApprovalConfig.isEmpty()) {
+      // There can be only one required approval. If multiple ones are configured just use the last
+      // one, this is also what Config#getString(String, String, String) does.
+      return Iterables.getLast(configuredRequiredApprovalConfig);
     }
 
     // fall back to hard-coded default required approval
@@ -340,8 +396,11 @@
   }
 
   /**
-   * Returns the approval that is required to override the code owners submit check for a change of
-   * the given project.
+   * Returns the approvals that are required to override the code owners submit check for a change
+   * of the given project.
+   *
+   * <p>If multiple approvals are returned, any of them is sufficient to override the code owners
+   * submit check.
    *
    * <p>Callers must ensure that the project of the specified branch exists. If the project doesn't
    * exist the call fails with {@link IllegalStateException}.
@@ -353,20 +412,16 @@
    *   <li>globally configured override approval
    * </ul>
    *
-   * <p>The first override approval that exists counts and the evaluation is stopped.
+   * <p>The first override approval configuration that exists counts and the evaluation is stopped.
    *
    * @param project project for which the override approval should be returned
-   * @return the override approval that should be used for the given project, {@link
-   *     Optional#empty()} if no override approval is configured, in this case the override
-   *     functionality is disabled
+   * @return the override approvals that should be used for the given project, an empty set if no
+   *     override approval is configured, in this case the override functionality is disabled
    */
-  public Optional<RequiredApproval> getOverrideApproval(Project.NameKey project) {
+  public ImmutableSet<RequiredApproval> getOverrideApproval(Project.NameKey project) {
     try {
-      Optional<RequiredApproval> configuredOverrideApprovalConfig =
-          getConfiguredRequiredApproval(overrideApprovalConfig, project);
-      if (configuredOverrideApprovalConfig.isPresent()) {
-        return configuredOverrideApprovalConfig;
-      }
+      return filterOutDuplicateRequiredApprovals(
+          getConfiguredRequiredApproval(overrideApprovalConfig, project));
     } catch (InvalidPluginConfigurationException e) {
       logger.atWarning().withCause(e).log(
           "Ignoring invalid override approval configuration for project %s."
@@ -374,37 +429,49 @@
           project.get());
     }
 
-    return Optional.empty();
+    return ImmutableSet.of();
   }
 
   /**
-   * Gets the required approval that is configured for the given project.
+   * Filters out duplicate required approvals from the input list.
    *
-   * @param requiredApprovalConfig the config from which the required approval should be read
-   * @param project the project for which the configured required approval should be returned
-   * @return the required approval that is configured for the given project, {@link
-   *     Optional#empty()} if no required approval is configured
+   * <p>The following entries are considered as duplicate:
+   *
+   * <ul>
+   *   <li>exact identical required approvals (e.g. "Code-Review+2" and "Code-Review+2")
+   *   <li>required approvals with the same label name and a higher value (e.g. "Code-Review+2" is
+   *       not needed if "Code-Review+1" is already contained, since "Code-Review+1" covers all
+   *       "Code-Review" approvals >= 1)
+   * </ul>
    */
-  private Optional<RequiredApproval> getConfiguredRequiredApproval(
+  private ImmutableSet<RequiredApproval> filterOutDuplicateRequiredApprovals(
+      ImmutableList<RequiredApproval> requiredApprovals) {
+    Map<String, RequiredApproval> requiredApprovalsByLabel = new HashMap<>();
+    for (RequiredApproval requiredApproval : requiredApprovals) {
+      String labelName = requiredApproval.labelType().getName();
+      RequiredApproval otherRequiredApproval = requiredApprovalsByLabel.get(labelName);
+      if (otherRequiredApproval != null
+          && otherRequiredApproval.value() <= requiredApproval.value()) {
+        continue;
+      }
+      requiredApprovalsByLabel.put(labelName, requiredApproval);
+    }
+    return ImmutableSet.copyOf(requiredApprovalsByLabel.values());
+  }
+
+  /**
+   * Gets the required approvals that are configured for the given project.
+   *
+   * @param requiredApprovalConfig the config from which the required approvals should be read
+   * @param project the project for which the configured required approvals should be returned
+   * @return the required approvals that is configured for the given project, an empty list if no
+   *     required approvals are configured
+   */
+  private ImmutableList<RequiredApproval> getConfiguredRequiredApproval(
       AbstractRequiredApprovalConfig requiredApprovalConfig, Project.NameKey project) {
     Config pluginConfig = getPluginConfig(project);
-
     ProjectState projectState = projectCache.get(project).orElseThrow(illegalState(project));
-
-    // check if a project specific required approval is configured
-    Optional<RequiredApproval> requiredApproval =
-        requiredApprovalConfig.getForProject(projectState, pluginConfig);
-    if (requiredApproval.isPresent()) {
-      return requiredApproval;
-    }
-
-    // check if a required approval is globally configured
-    requiredApproval = requiredApprovalConfig.getFromGlobalPluginConfig(projectState);
-    if (requiredApproval.isPresent()) {
-      return requiredApproval;
-    }
-
-    return Optional.empty();
+    return requiredApprovalConfig.get(projectState, pluginConfig);
   }
 
   /**
@@ -423,10 +490,7 @@
     try {
       return pluginConfigFactory
           .getFromGerritConfig(pluginName)
-          .getBoolean(
-              KEY_ENABLE_EXPERIMENTAL_REST_ENDPOINTS,
-              /** defaultValue = */
-              false);
+          .getBoolean(KEY_ENABLE_EXPERIMENTAL_REST_ENDPOINTS, /* defaultValue= */ false);
     } catch (IllegalArgumentException e) {
       logger.atWarning().withCause(e).log(
           "Value '%s' in gerrit.config (parameter plugin.%s.%s) is invalid.",
diff --git a/java/com/google/gerrit/plugins/codeowners/config/GeneralConfig.java b/java/com/google/gerrit/plugins/codeowners/config/GeneralConfig.java
index bd137ac..cafe8e7 100644
--- a/java/com/google/gerrit/plugins/codeowners/config/GeneralConfig.java
+++ b/java/com/google/gerrit/plugins/codeowners/config/GeneralConfig.java
@@ -28,6 +28,7 @@
 import com.google.gerrit.plugins.codeowners.api.CodeOwnerConfigValidationPolicy;
 import com.google.gerrit.plugins.codeowners.api.MergeCommitStrategy;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerReference;
+import com.google.gerrit.plugins.codeowners.backend.FallbackCodeOwners;
 import com.google.gerrit.server.config.PluginConfig;
 import com.google.gerrit.server.config.PluginConfigFactory;
 import com.google.gerrit.server.git.validators.CommitValidationMessage;
@@ -59,11 +60,18 @@
   @VisibleForTesting public static final String KEY_ALLOWED_EMAIL_DOMAIN = "allowedEmailDomain";
   @VisibleForTesting public static final String KEY_FILE_EXTENSION = "fileExtension";
   @VisibleForTesting public static final String KEY_READ_ONLY = "readOnly";
+  @VisibleForTesting public static final String KEY_FALLBACK_CODE_OWNERS = "fallbackCodeOwners";
 
   @VisibleForTesting
   public static final String KEY_ENABLE_VALIDATION_ON_COMMIT_RECEIVED =
       "enableValidationOnCommitReceived";
 
+  @VisibleForTesting
+  public static final String KEY_ENABLE_VALIDATION_ON_SUBMIT = "enableValidationOnSubmit";
+
+  @VisibleForTesting
+  public static final String KEY_MAX_PATHS_IN_CHANGE_MESSAGES = "maxPathsInChangeMessages";
+
   @VisibleForTesting public static final String KEY_MERGE_COMMIT_STRATEGY = "mergeCommitStrategy";
   @VisibleForTesting public static final String KEY_GLOBAL_CODE_OWNER = "globalCodeOwner";
 
@@ -72,6 +80,8 @@
 
   @VisibleForTesting public static final String KEY_OVERRIDE_INFO_URL = "overrideInfoUrl";
 
+  @VisibleForTesting static final int DEFAULT_MAX_PATHS_IN_CHANGE_MESSAGES = 100;
+
   private final String pluginName;
   private final PluginConfig pluginConfigFromGerritConfig;
 
@@ -118,6 +128,47 @@
               ValidationMessage.Type.ERROR));
     }
 
+    try {
+      projectLevelConfig
+          .getConfig()
+          .getEnum(SECTION_CODE_OWNERS, null, KEY_FALLBACK_CODE_OWNERS, FallbackCodeOwners.NONE);
+    } catch (IllegalArgumentException e) {
+      validationMessages.add(
+          new CommitValidationMessage(
+              String.format(
+                  "The value for fallback code owners '%s' that is configured in %s (parameter %s.%s) is invalid.",
+                  projectLevelConfig
+                      .getConfig()
+                      .getString(SECTION_CODE_OWNERS, null, KEY_FALLBACK_CODE_OWNERS),
+                  fileName,
+                  SECTION_CODE_OWNERS,
+                  KEY_FALLBACK_CODE_OWNERS),
+              ValidationMessage.Type.ERROR));
+    }
+
+    try {
+      projectLevelConfig
+          .getConfig()
+          .getInt(
+              SECTION_CODE_OWNERS,
+              null,
+              KEY_MAX_PATHS_IN_CHANGE_MESSAGES,
+              DEFAULT_MAX_PATHS_IN_CHANGE_MESSAGES);
+    } catch (IllegalArgumentException e) {
+      validationMessages.add(
+          new CommitValidationMessage(
+              String.format(
+                  "The value for max paths in change messages '%s' that is configured in %s"
+                      + " (parameter %s.%s) is invalid.",
+                  projectLevelConfig
+                      .getConfig()
+                      .getString(SECTION_CODE_OWNERS, null, KEY_MAX_PATHS_IN_CHANGE_MESSAGES),
+                  fileName,
+                  SECTION_CODE_OWNERS,
+                  KEY_MAX_PATHS_IN_CHANGE_MESSAGES),
+              ValidationMessage.Type.ERROR));
+    }
+
     return ImmutableList.copyOf(validationMessages);
   }
 
@@ -174,29 +225,136 @@
   }
 
   /**
+   * Gets the fallback code owners that own paths that have no defined code owners.
+   *
+   * @param project the project for which the fallback code owners should be read
+   * @param pluginConfig the plugin config from which the fallback code owners should be read
+   * @return the fallback code owners that own paths that have no defined code owners
+   */
+  FallbackCodeOwners getFallbackCodeOwners(Project.NameKey project, Config pluginConfig) {
+    requireNonNull(project, "project");
+    requireNonNull(pluginConfig, "pluginConfig");
+
+    String fallbackCodeOwnersString =
+        pluginConfig.getString(SECTION_CODE_OWNERS, null, KEY_FALLBACK_CODE_OWNERS);
+    if (fallbackCodeOwnersString != null) {
+      try {
+        return pluginConfig.getEnum(
+            SECTION_CODE_OWNERS, null, KEY_FALLBACK_CODE_OWNERS, FallbackCodeOwners.NONE);
+      } catch (IllegalArgumentException e) {
+        logger.atWarning().log(
+            "Ignoring invalid value %s for fallback code owners in '%s.config' of project %s."
+                + " Falling back to global config.",
+            fallbackCodeOwnersString, pluginName, project.get());
+      }
+    }
+
+    try {
+      return pluginConfigFromGerritConfig.getEnum(
+          KEY_FALLBACK_CODE_OWNERS, FallbackCodeOwners.NONE);
+    } catch (IllegalArgumentException e) {
+      logger.atWarning().log(
+          "Ignoring invalid value %s for fallback code owners in gerrit.config (parameter"
+              + " plugin.%s.%s). Falling back to default value %s.",
+          pluginConfigFromGerritConfig.getString(KEY_FALLBACK_CODE_OWNERS),
+          pluginName,
+          KEY_FALLBACK_CODE_OWNERS,
+          FallbackCodeOwners.NONE);
+      return FallbackCodeOwners.NONE;
+    }
+  }
+
+  /**
+   * Gets the maximum number of paths that should be incuded in change messages.
+   *
+   * @param project the project for which the maximum number of paths in change messages should be
+   *     read
+   * @param pluginConfig the plugin config from which the maximum number of paths in change messages
+   *     should be read
+   * @return the maximum number of paths in change messages
+   */
+  int getMaxPathsInChangeMessages(Project.NameKey project, Config pluginConfig) {
+    requireNonNull(project, "project");
+    requireNonNull(pluginConfig, "pluginConfig");
+
+    String maxPathInChangeMessagesString =
+        pluginConfig.getString(SECTION_CODE_OWNERS, null, KEY_MAX_PATHS_IN_CHANGE_MESSAGES);
+    if (maxPathInChangeMessagesString != null) {
+      try {
+        return pluginConfig.getInt(
+            SECTION_CODE_OWNERS,
+            null,
+            KEY_MAX_PATHS_IN_CHANGE_MESSAGES,
+            DEFAULT_MAX_PATHS_IN_CHANGE_MESSAGES);
+      } catch (IllegalArgumentException e) {
+        logger.atWarning().log(
+            "Ignoring invalid value %s for max paths in change messages in '%s.config' of"
+                + " project %s. Falling back to global config.",
+            maxPathInChangeMessagesString, pluginName, project.get());
+      }
+    }
+
+    try {
+      return pluginConfigFromGerritConfig.getInt(
+          KEY_MAX_PATHS_IN_CHANGE_MESSAGES, DEFAULT_MAX_PATHS_IN_CHANGE_MESSAGES);
+    } catch (IllegalArgumentException e) {
+      logger.atWarning().log(
+          "Ignoring invalid value %s for max paths in change messages in gerrit.config (parameter"
+              + " plugin.%s.%s). Falling back to default value %s.",
+          pluginConfigFromGerritConfig.getString(KEY_MAX_PATHS_IN_CHANGE_MESSAGES),
+          pluginName,
+          KEY_MAX_PATHS_IN_CHANGE_MESSAGES,
+          DEFAULT_MAX_PATHS_IN_CHANGE_MESSAGES);
+      return DEFAULT_MAX_PATHS_IN_CHANGE_MESSAGES;
+    }
+  }
+
+  /**
    * Gets the enable validation on commit received configuration from the given plugin config with
    * fallback to {@code gerrit.config} and default to {@code true}.
    *
    * <p>The enable validation on commit received controls whether code owner config files should be
    * validated when a commit is received.
    *
-   * @param pluginConfig the plugin config from which the read-only configuration should be read.
+   * @param pluginConfig the plugin config from which the enable validation on commit received
+   *     configuration should be read.
    * @return whether code owner config files should be validated when a commit is received
    */
   CodeOwnerConfigValidationPolicy getCodeOwnerConfigValidationPolicyForCommitReceived(
       Project.NameKey project, Config pluginConfig) {
+    return getCodeOwnerConfigValidationPolicy(
+        KEY_ENABLE_VALIDATION_ON_COMMIT_RECEIVED, project, pluginConfig);
+  }
+
+  /**
+   * Gets the enable validation on submit configuration from the given plugin config with fallback
+   * to {@code gerrit.config} and default to {@code true}.
+   *
+   * <p>The enable validation on submit controls whether code owner config files should be validated
+   * when a change is submitted.
+   *
+   * @param pluginConfig the plugin config from which the enable validation on submit configuration
+   *     should be read.
+   * @return whether code owner config files should be validated when a change is submitted
+   */
+  CodeOwnerConfigValidationPolicy getCodeOwnerConfigValidationPolicyForSubmit(
+      Project.NameKey project, Config pluginConfig) {
+    return getCodeOwnerConfigValidationPolicy(
+        KEY_ENABLE_VALIDATION_ON_SUBMIT, project, pluginConfig);
+  }
+
+  private CodeOwnerConfigValidationPolicy getCodeOwnerConfigValidationPolicy(
+      String key, Project.NameKey project, Config pluginConfig) {
+    requireNonNull(key, "key");
     requireNonNull(project, "project");
     requireNonNull(pluginConfig, "pluginConfig");
 
     String codeOwnerConfigValidationPolicyString =
-        pluginConfig.getString(SECTION_CODE_OWNERS, null, KEY_ENABLE_VALIDATION_ON_COMMIT_RECEIVED);
+        pluginConfig.getString(SECTION_CODE_OWNERS, null, key);
     if (codeOwnerConfigValidationPolicyString != null) {
       try {
         return pluginConfig.getEnum(
-            SECTION_CODE_OWNERS,
-            null,
-            KEY_ENABLE_VALIDATION_ON_COMMIT_RECEIVED,
-            CodeOwnerConfigValidationPolicy.TRUE);
+            SECTION_CODE_OWNERS, null, key, CodeOwnerConfigValidationPolicy.TRUE);
       } catch (IllegalArgumentException e) {
         logger.atWarning().log(
             "Ignoring invalid value %s for the code owner config validation policy in '%s.config'"
@@ -206,15 +364,14 @@
     }
 
     try {
-      return pluginConfigFromGerritConfig.getEnum(
-          KEY_ENABLE_VALIDATION_ON_COMMIT_RECEIVED, CodeOwnerConfigValidationPolicy.TRUE);
+      return pluginConfigFromGerritConfig.getEnum(key, CodeOwnerConfigValidationPolicy.TRUE);
     } catch (IllegalArgumentException e) {
       logger.atWarning().log(
           "Ignoring invalid value %s for the code owner config validation policy in gerrit.config"
               + " (parameter plugin.%s.%s). Falling back to default value %s.",
-          pluginConfigFromGerritConfig.getString(KEY_ENABLE_VALIDATION_ON_COMMIT_RECEIVED),
+          pluginConfigFromGerritConfig.getString(key),
           pluginName,
-          KEY_ENABLE_VALIDATION_ON_COMMIT_RECEIVED,
+          key,
           CodeOwnerConfigValidationPolicy.TRUE);
       return CodeOwnerConfigValidationPolicy.TRUE;
     }
diff --git a/java/com/google/gerrit/plugins/codeowners/restapi/AbstractGetCodeOwnersForPath.java b/java/com/google/gerrit/plugins/codeowners/restapi/AbstractGetCodeOwnersForPath.java
index 854ffd7..c77cb1d 100644
--- a/java/com/google/gerrit/plugins/codeowners/restapi/AbstractGetCodeOwnersForPath.java
+++ b/java/com/google/gerrit/plugins/codeowners/restapi/AbstractGetCodeOwnersForPath.java
@@ -31,6 +31,7 @@
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.plugins.codeowners.api.CodeOwnerInfo;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwner;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfigHierarchy;
@@ -43,7 +44,6 @@
 import com.google.gerrit.server.account.AccountDirectory.FillOptions;
 import com.google.gerrit.server.account.AccountLoader;
 import com.google.gerrit.server.account.Accounts;
-import com.google.gerrit.server.account.ServiceUserClassifier;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -55,6 +55,8 @@
 import java.util.EnumSet;
 import java.util.HashSet;
 import java.util.List;
+import java.util.Optional;
+import java.util.Random;
 import java.util.Set;
 import java.util.stream.Stream;
 import org.kohsuke.args4j.Option;
@@ -63,7 +65,8 @@
  * Abstract base class for REST endpoints that get the code owners for an arbitrary path in a branch
  * or a revision of a change.
  */
-public abstract class AbstractGetCodeOwnersForPath {
+public abstract class AbstractGetCodeOwnersForPath<R extends AbstractPathResource>
+    implements RestReadView<R> {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   @VisibleForTesting public static final int DEFAULT_LIMIT = 10;
@@ -75,12 +78,12 @@
   private final CodeOwnersPluginConfiguration codeOwnersPluginConfiguration;
   private final CodeOwnerConfigHierarchy codeOwnerConfigHierarchy;
   private final Provider<CodeOwnerResolver> codeOwnerResolver;
-  private final ServiceUserClassifier serviceUserClassifier;
   private final CodeOwnerJson.Factory codeOwnerJsonFactory;
   private final EnumSet<ListAccountsOption> options;
   private final Set<String> hexOptions;
 
   private int limit = DEFAULT_LIMIT;
+  private Optional<Long> seed = Optional.empty();
 
   @Option(
       name = "-o",
@@ -106,6 +109,13 @@
     this.limit = limit;
   }
 
+  @Option(
+      name = "--seed",
+      usage = "seed that should be used to shuffle code owners that have the same score")
+  public void setSeed(long seed) {
+    this.seed = Optional.of(seed);
+  }
+
   protected AbstractGetCodeOwnersForPath(
       AccountVisibility accountVisibility,
       Accounts accounts,
@@ -114,7 +124,6 @@
       CodeOwnersPluginConfiguration codeOwnersPluginConfiguration,
       CodeOwnerConfigHierarchy codeOwnerConfigHierarchy,
       Provider<CodeOwnerResolver> codeOwnerResolver,
-      ServiceUserClassifier serviceUserClassifier,
       CodeOwnerJson.Factory codeOwnerJsonFactory) {
     this.accountVisibility = accountVisibility;
     this.accounts = accounts;
@@ -123,13 +132,12 @@
     this.codeOwnersPluginConfiguration = codeOwnersPluginConfiguration;
     this.codeOwnerConfigHierarchy = codeOwnerConfigHierarchy;
     this.codeOwnerResolver = codeOwnerResolver;
-    this.serviceUserClassifier = serviceUserClassifier;
     this.codeOwnerJsonFactory = codeOwnerJsonFactory;
     this.options = EnumSet.noneOf(ListAccountsOption.class);
     this.hexOptions = new HashSet<>();
   }
 
-  protected Response<List<CodeOwnerInfo>> applyImpl(AbstractPathResource rsrc)
+  protected Response<List<CodeOwnerInfo>> applyImpl(R rsrc)
       throws AuthException, BadRequestException, PermissionBackendException {
     parseHexOptions();
     validateLimit();
@@ -227,9 +235,23 @@
    *       normally doesn't make sense since they will not react to review requests.
    * </ul>
    */
-  private ImmutableSet<CodeOwner> filterCodeOwners(
-      AbstractPathResource rsrc, ImmutableSet<CodeOwner> codeOwners) {
-    return codeOwners.stream()
+  private ImmutableSet<CodeOwner> filterCodeOwners(R rsrc, ImmutableSet<CodeOwner> codeOwners) {
+    return filterCodeOwners(rsrc, getVisibleCodeOwners(rsrc, codeOwners)).collect(toImmutableSet());
+  }
+
+  /**
+   * To be overridden by subclasses to filter out additional code owners.
+   *
+   * @param rsrc resource on which the request is being performed
+   * @param codeOwners stream of code owners that should be filtered
+   * @return the filtered stream of code owners
+   */
+  protected Stream<CodeOwner> filterCodeOwners(R rsrc, Stream<CodeOwner> codeOwners) {
+    return codeOwners;
+  }
+
+  private Stream<CodeOwner> getVisibleCodeOwners(R rsrc, ImmutableSet<CodeOwner> allCodeOwners) {
+    return allCodeOwners.stream()
         .filter(
             codeOwner -> {
               if (isVisibleTo(rsrc, codeOwner)) {
@@ -239,21 +261,11 @@
                   "Filtering out %s because this code owner cannot see the branch %s",
                   codeOwner, rsrc.getBranch().branch());
               return false;
-            })
-        .filter(
-            codeOwner -> {
-              if (!isServiceUser(codeOwner)) {
-                return true;
-              }
-              logger.atFine().log(
-                  "Filtering out %s because this code owner is a service user", codeOwner);
-              return false;
-            })
-        .collect(toImmutableSet());
+            });
   }
 
   /** Whether the given resource is visible to the given code owner. */
-  private boolean isVisibleTo(AbstractPathResource rsrc, CodeOwner codeOwner) {
+  private boolean isVisibleTo(R rsrc, CodeOwner codeOwner) {
     // We always check for the visibility of the branch.
     // This is also correct for the GetCodeOwnersForPathInChange subclass where branch is the
     // destination branch of the change. For changes the intention of the visibility check is to
@@ -271,11 +283,6 @@
         .testOrFalse(RefPermission.READ);
   }
 
-  /** Whether the given code owner is a service user. */
-  private boolean isServiceUser(CodeOwner codeOwner) {
-    return serviceUserClassifier.isServiceUser(codeOwner.accountId());
-  }
-
   private void parseHexOptions() throws BadRequestException {
     for (String hexOption : hexOptions) {
       try {
@@ -311,7 +318,9 @@
 
   private ImmutableList<CodeOwner> sortAndLimit(
       CodeOwnerScoring distanceScoring, ImmutableSet<CodeOwner> codeOwners) {
-    return sortCodeOwners(distanceScoring, codeOwners).limit(limit).collect(toImmutableList());
+    return sortCodeOwners(seed, distanceScoring, codeOwners)
+        .limit(limit)
+        .collect(toImmutableList());
   }
 
   /**
@@ -321,24 +330,27 @@
    *
    * <p>The order of code owners with the same distance score is random.
    *
+   * @param seed seed that should be used to randomize the order
    * @param distanceScoring the distance scorings for the code owners
    * @param codeOwners the code owners that should be sorted
    * @return the sorted code owners
    */
   private static Stream<CodeOwner> sortCodeOwners(
-      CodeOwnerScoring distanceScoring, ImmutableSet<CodeOwner> codeOwners) {
-    return randomizeOrder(codeOwners).sorted(distanceScoring.comparingByScoring());
+      Optional<Long> seed, CodeOwnerScoring distanceScoring, ImmutableSet<CodeOwner> codeOwners) {
+    return randomizeOrder(seed, codeOwners).sorted(distanceScoring.comparingByScoring());
   }
 
   /**
    * Returns the entries from the given set in a random order.
    *
+   * @param seed seed that should be used to randomize the order
    * @param set the set for which the entries should be returned in a random order
    * @return the entries from the given set in a random order
    */
-  private static <T> Stream<T> randomizeOrder(Set<T> set) {
+  private static <T> Stream<T> randomizeOrder(Optional<Long> seed, Set<T> set) {
     List<T> randomlyOrderedCodeOwners = new ArrayList<>(set);
-    Collections.shuffle(randomlyOrderedCodeOwners);
+    Collections.shuffle(
+        randomlyOrderedCodeOwners, seed.isPresent() ? new Random(seed.get()) : new Random());
     return randomlyOrderedCodeOwners.stream();
   }
 
@@ -349,8 +361,7 @@
    * <p>Must be only used to complete the suggestion list when it is found that the path is owned by
    * all user.
    */
-  private void fillUpWithRandomUsers(
-      AbstractPathResource rsrc, Set<CodeOwner> codeOwners, int limit) {
+  private void fillUpWithRandomUsers(R rsrc, Set<CodeOwner> codeOwners, int limit) {
     if (codeOwners.size() >= limit) {
       // limit is already reach, we don't need to add further suggestions
       return;
@@ -410,6 +421,6 @@
    * <p>No visibility check is performed.
    */
   private Stream<Account.Id> getRandomUsers(int limit) throws IOException {
-    return randomizeOrder(accounts.allIds()).limit(limit);
+    return randomizeOrder(seed, accounts.allIds()).limit(limit);
   }
 }
diff --git a/java/com/google/gerrit/plugins/codeowners/restapi/CodeOwnerProjectConfigJson.java b/java/com/google/gerrit/plugins/codeowners/restapi/CodeOwnerProjectConfigJson.java
index 65d93e8..bf3a8ad 100644
--- a/java/com/google/gerrit/plugins/codeowners/restapi/CodeOwnerProjectConfigJson.java
+++ b/java/com/google/gerrit/plugins/codeowners/restapi/CodeOwnerProjectConfigJson.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.collect.ImmutableMap.toImmutableMap;
+import static java.util.Comparator.comparing;
 import static java.util.Objects.requireNonNull;
 
 import com.google.common.annotations.VisibleForTesting;
@@ -116,6 +117,8 @@
         codeOwnersPluginConfiguration.areImplicitApprovalsEnabled(projectName) ? true : null;
     generalInfo.overrideInfoUrl =
         codeOwnersPluginConfiguration.getOverrideInfoUrl(projectName).orElse(null);
+    generalInfo.fallbackCodeOwners =
+        codeOwnersPluginConfiguration.getFallbackCodeOwners(projectName);
     return generalInfo;
   }
 
@@ -157,12 +160,15 @@
     return formatRequiredApproval(codeOwnersPluginConfiguration.getRequiredApproval(projectName));
   }
 
+  @VisibleForTesting
   @Nullable
-  private RequiredApprovalInfo formatOverrideApprovalInfo(Project.NameKey projectName) {
-    return codeOwnersPluginConfiguration
-        .getOverrideApproval(projectName)
-        .map(CodeOwnerProjectConfigJson::formatRequiredApproval)
-        .orElse(null);
+  ImmutableList<RequiredApprovalInfo> formatOverrideApprovalInfo(Project.NameKey projectName) {
+    ImmutableList<RequiredApprovalInfo> overrideApprovalInfos =
+        codeOwnersPluginConfiguration.getOverrideApproval(projectName).stream()
+            .sorted(comparing(requiredApproval -> requiredApproval.toString()))
+            .map(CodeOwnerProjectConfigJson::formatRequiredApproval)
+            .collect(toImmutableList());
+    return overrideApprovalInfos.isEmpty() ? null : overrideApprovalInfos;
   }
 
   @VisibleForTesting
diff --git a/java/com/google/gerrit/plugins/codeowners/restapi/CodeOwnersInChangeCollection.java b/java/com/google/gerrit/plugins/codeowners/restapi/CodeOwnersInChangeCollection.java
index e4a9455..ad0b1ad 100644
--- a/java/com/google/gerrit/plugins/codeowners/restapi/CodeOwnersInChangeCollection.java
+++ b/java/com/google/gerrit/plugins/codeowners/restapi/CodeOwnersInChangeCollection.java
@@ -142,8 +142,15 @@
       return new PathResource(revisionResource, branchRevision, parsePath(pathId));
     }
 
+    private final RevisionResource revisionResource;
+
     private PathResource(RevisionResource revisionResource, ObjectId branchRevision, Path path) {
       super(revisionResource, branchRevision, path);
+      this.revisionResource = revisionResource;
+    }
+
+    public RevisionResource getRevisionResource() {
+      return revisionResource;
     }
   }
 }
diff --git a/java/com/google/gerrit/plugins/codeowners/restapi/GetCodeOwnerConfigFiles.java b/java/com/google/gerrit/plugins/codeowners/restapi/GetCodeOwnerConfigFiles.java
index 0ebf942..9ec7118 100644
--- a/java/com/google/gerrit/plugins/codeowners/restapi/GetCodeOwnerConfigFiles.java
+++ b/java/com/google/gerrit/plugins/codeowners/restapi/GetCodeOwnerConfigFiles.java
@@ -20,6 +20,7 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerBackend;
@@ -48,7 +49,16 @@
   private final CodeOwnersPluginConfiguration codeOwnersPluginConfiguration;
   private final CodeOwnerConfigScanner.Factory codeOwnerConfigScannerFactory;
 
+  private boolean includeNonParsableFiles;
   private String email;
+  private String pathGlob;
+
+  @Option(
+      name = "--include-non-parsable-files",
+      usage = "includes non-parseable code owner config files in the response")
+  public void setIncludeNonParsableFiles(boolean includeNonParsableFiles) {
+    this.includeNonParsableFiles = includeNonParsableFiles;
+  }
 
   @Option(
       name = "--email",
@@ -58,6 +68,15 @@
     this.email = email;
   }
 
+  @Option(
+      name = "--path",
+      usage =
+          "limits the returned code owner config files to those that have a path matching"
+              + " this glob")
+  public void setPath(@Nullable String pathGlob) {
+    this.pathGlob = pathGlob;
+  }
+
   @Inject
   public GetCodeOwnerConfigFiles(
       CodeOwnersPluginConfiguration codeOwnersPluginConfiguration,
@@ -67,7 +86,9 @@
   }
 
   @Override
-  public Response<List<String>> apply(BranchResource resource) {
+  public Response<List<String>> apply(BranchResource resource) throws BadRequestException {
+    validateOptions();
+
     CodeOwnerBackend codeOwnerBackend =
         codeOwnersPluginConfiguration.getBackend(resource.getBranchKey());
     ImmutableList.Builder<Path> codeOwnerConfigs = ImmutableList.builder();
@@ -93,7 +114,12 @@
               }
               return true;
             },
-            CodeOwnerConfigScanner.ignoreInvalidCodeOwnerConfigFiles());
+            includeNonParsableFiles
+                ? (codeOwnerConfigFilePath, configInvalidException) -> {
+                  codeOwnerConfigs.add(codeOwnerConfigFilePath);
+                }
+                : CodeOwnerConfigScanner.ignoreInvalidCodeOwnerConfigFiles(),
+            pathGlob);
     return Response.ok(
         codeOwnerConfigs.build().stream().map(Path::toString).collect(toImmutableList()));
   }
@@ -120,4 +146,11 @@
     }
     return containsEmail;
   }
+
+  private void validateOptions() throws BadRequestException {
+    if (email != null && includeNonParsableFiles) {
+      throw new BadRequestException(
+          "the options 'email' and 'include-non-parsable-files' are mutually exclusive");
+    }
+  }
 }
diff --git a/java/com/google/gerrit/plugins/codeowners/restapi/GetCodeOwnersForPathInBranch.java b/java/com/google/gerrit/plugins/codeowners/restapi/GetCodeOwnersForPathInBranch.java
index c2c22df..3fab537 100644
--- a/java/com/google/gerrit/plugins/codeowners/restapi/GetCodeOwnersForPathInBranch.java
+++ b/java/com/google/gerrit/plugins/codeowners/restapi/GetCodeOwnersForPathInBranch.java
@@ -22,14 +22,12 @@
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.plugins.codeowners.api.CodeOwnerInfo;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfigHierarchy;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerResolver;
 import com.google.gerrit.plugins.codeowners.config.CodeOwnersPluginConfiguration;
 import com.google.gerrit.server.account.AccountControl;
 import com.google.gerrit.server.account.Accounts;
-import com.google.gerrit.server.account.ServiceUserClassifier;
 import com.google.gerrit.server.change.IncludedInResolver;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.permissions.PermissionBackend;
@@ -56,8 +54,8 @@
  *
  * <p>The path may or may not exist in the branch.
  */
-public class GetCodeOwnersForPathInBranch extends AbstractGetCodeOwnersForPath
-    implements RestReadView<CodeOwnersInBranchCollection.PathResource> {
+public class GetCodeOwnersForPathInBranch
+    extends AbstractGetCodeOwnersForPath<CodeOwnersInBranchCollection.PathResource> {
   private final GitRepositoryManager repoManager;
   private String revision;
 
@@ -80,7 +78,6 @@
       CodeOwnersPluginConfiguration codeOwnersPluginConfiguration,
       CodeOwnerConfigHierarchy codeOwnerConfigHierarchy,
       Provider<CodeOwnerResolver> codeOwnerResolver,
-      ServiceUserClassifier serviceUserClassifier,
       CodeOwnerJson.Factory codeOwnerJsonFactory,
       GitRepositoryManager repoManager) {
     super(
@@ -91,7 +88,6 @@
         codeOwnersPluginConfiguration,
         codeOwnerConfigHierarchy,
         codeOwnerResolver,
-        serviceUserClassifier,
         codeOwnerJsonFactory);
     this.repoManager = repoManager;
   }
diff --git a/java/com/google/gerrit/plugins/codeowners/restapi/GetCodeOwnersForPathInChange.java b/java/com/google/gerrit/plugins/codeowners/restapi/GetCodeOwnersForPathInChange.java
index 3483614..c0465f3 100644
--- a/java/com/google/gerrit/plugins/codeowners/restapi/GetCodeOwnersForPathInChange.java
+++ b/java/com/google/gerrit/plugins/codeowners/restapi/GetCodeOwnersForPathInChange.java
@@ -14,11 +14,12 @@
 
 package com.google.gerrit.plugins.codeowners.restapi;
 
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.extensions.common.AccountVisibility;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.plugins.codeowners.api.CodeOwnerInfo;
+import com.google.gerrit.plugins.codeowners.backend.CodeOwner;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfigHierarchy;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerResolver;
 import com.google.gerrit.plugins.codeowners.config.CodeOwnersPluginConfiguration;
@@ -30,6 +31,8 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import java.util.List;
+import java.util.function.Predicate;
+import java.util.stream.Stream;
 
 /**
  * REST endpoint that gets the code owners for an arbitrary path in a revision of a change.
@@ -39,8 +42,12 @@
  *
  * <p>The path may or may not exist in the revision of the change.
  */
-public class GetCodeOwnersForPathInChange extends AbstractGetCodeOwnersForPath
-    implements RestReadView<CodeOwnersInChangeCollection.PathResource> {
+public class GetCodeOwnersForPathInChange
+    extends AbstractGetCodeOwnersForPath<CodeOwnersInChangeCollection.PathResource> {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private final ServiceUserClassifier serviceUserClassifier;
+
   @Inject
   GetCodeOwnersForPathInChange(
       AccountVisibility accountVisibility,
@@ -60,8 +67,8 @@
         codeOwnersPluginConfiguration,
         codeOwnerConfigHierarchy,
         codeOwnerResolver,
-        serviceUserClassifier,
         codeOwnerJsonFactory);
+    this.serviceUserClassifier = serviceUserClassifier;
   }
 
   @Override
@@ -69,4 +76,41 @@
       throws RestApiException, PermissionBackendException {
     return super.applyImpl(rsrc);
   }
+
+  @Override
+  protected Stream<CodeOwner> filterCodeOwners(
+      CodeOwnersInChangeCollection.PathResource rsrc, Stream<CodeOwner> codeOwners) {
+    return codeOwners.filter(filterOutChangeOwner(rsrc)).filter(filterOutServiceUsers());
+  }
+
+  private Predicate<CodeOwner> filterOutChangeOwner(
+      CodeOwnersInChangeCollection.PathResource rsrc) {
+    return codeOwner -> {
+      if (!codeOwner.accountId().equals(rsrc.getRevisionResource().getChange().getOwner())) {
+        // Returning true from the Predicate here means that the code owner should be kept.
+        return true;
+      }
+      logger.atFine().log(
+          "Filtering out %s because this code owner is the change owner", codeOwner);
+      // Returning false from the Predicate here means that the code owner should be filtered out.
+      return false;
+    };
+  }
+
+  private Predicate<CodeOwner> filterOutServiceUsers() {
+    return codeOwner -> {
+      if (!isServiceUser(codeOwner)) {
+        // Returning true from the Predicate here means that the code owner should be kept.
+        return true;
+      }
+      logger.atFine().log("Filtering out %s because this code owner is a service user", codeOwner);
+      // Returning false from the Predicate here means that the code owner should be filtered out.
+      return false;
+    };
+  }
+
+  /** Whether the given code owner is a service user. */
+  private boolean isServiceUser(CodeOwner codeOwner) {
+    return serviceUserClassifier.isServiceUser(codeOwner.accountId());
+  }
 }
diff --git a/java/com/google/gerrit/plugins/codeowners/restapi/RenameEmail.java b/java/com/google/gerrit/plugins/codeowners/restapi/RenameEmail.java
new file mode 100644
index 0000000..0ed505b
--- /dev/null
+++ b/java/com/google/gerrit/plugins/codeowners/restapi/RenameEmail.java
@@ -0,0 +1,194 @@
+// 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.
+
+package com.google.gerrit.plugins.codeowners.restapi;
+
+import static java.util.Objects.requireNonNull;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Strings;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.extensions.restapi.NotImplementedException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.plugins.codeowners.api.RenameEmailInput;
+import com.google.gerrit.plugins.codeowners.api.RenameEmailResultInfo;
+import com.google.gerrit.plugins.codeowners.backend.CodeOwner;
+import com.google.gerrit.plugins.codeowners.backend.CodeOwnerBackend;
+import com.google.gerrit.plugins.codeowners.backend.CodeOwnerBackendId;
+import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfigFileUpdateScanner;
+import com.google.gerrit.plugins.codeowners.backend.CodeOwnerReference;
+import com.google.gerrit.plugins.codeowners.backend.CodeOwnerResolver;
+import com.google.gerrit.plugins.codeowners.config.CodeOwnersPluginConfiguration;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.git.CommitUtil;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.ProjectPermission;
+import com.google.gerrit.server.permissions.RefPermission;
+import com.google.gerrit.server.project.BranchResource;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.Optional;
+import org.eclipse.jgit.revwalk.RevCommit;
+
+@Singleton
+public class RenameEmail implements RestModifyView<BranchResource, RenameEmailInput> {
+  @VisibleForTesting
+  public static final String DEFAULT_COMMIT_MESSAGE = "Rename email in code owner config files";
+
+  private final Provider<CurrentUser> currentUser;
+  private final PermissionBackend permissionBackend;
+  private final CodeOwnersPluginConfiguration codeOwnersPluginConfiguration;
+  private final CodeOwnerResolver codeOwnerResolver;
+  private final CodeOwnerConfigFileUpdateScanner codeOwnerConfigFileUpdateScanner;
+
+  @Inject
+  public RenameEmail(
+      Provider<CurrentUser> currentUser,
+      PermissionBackend permissionBackend,
+      CodeOwnersPluginConfiguration codeOwnersPluginConfiguration,
+      CodeOwnerResolver codeOwnerResolver,
+      CodeOwnerConfigFileUpdateScanner codeOwnerConfigFileUpdateScanner) {
+    this.currentUser = currentUser;
+    this.permissionBackend = permissionBackend;
+    this.codeOwnersPluginConfiguration = codeOwnersPluginConfiguration;
+    this.codeOwnerResolver = codeOwnerResolver;
+    this.codeOwnerConfigFileUpdateScanner = codeOwnerConfigFileUpdateScanner;
+  }
+
+  @Override
+  public Response<RenameEmailResultInfo> apply(
+      BranchResource branchResource, RenameEmailInput input)
+      throws AuthException, BadRequestException, ResourceConflictException,
+          MethodNotAllowedException, UnprocessableEntityException, PermissionBackendException,
+          IOException {
+    if (!currentUser.get().isIdentifiedUser()) {
+      throw new AuthException("Authentication required");
+    }
+
+    // caller needs to be project owner or have direct push permissions for the branch
+    if (!permissionBackend
+        .currentUser()
+        .project(branchResource.getNameKey())
+        .test(ProjectPermission.WRITE_CONFIG)) {
+      permissionBackend
+          .currentUser()
+          .ref(branchResource.getBranchKey())
+          .check(RefPermission.UPDATE);
+    }
+
+    validateInput(input);
+
+    CodeOwnerBackend codeOwnerBackend =
+        codeOwnersPluginConfiguration.getBackend(branchResource.getBranchKey());
+
+    Account.Id accountOwningOldEmail = resolveEmail(input.oldEmail);
+    Account.Id accountOwningNewEmail = resolveEmail(input.newEmail);
+    if (!accountOwningOldEmail.equals(accountOwningNewEmail)) {
+      throw new BadRequestException(
+          String.format(
+              "emails must belong to the same account"
+                  + " (old email %s is owned by account %d, new email %s is owned by account %d)",
+              input.oldEmail,
+              accountOwningOldEmail.get(),
+              input.newEmail,
+              accountOwningNewEmail.get()));
+    }
+
+    String inputMessage = Strings.nullToEmpty(input.message).trim();
+    String commitMessage = !inputMessage.isEmpty() ? inputMessage : DEFAULT_COMMIT_MESSAGE;
+
+    try {
+      Optional<RevCommit> commitId =
+          codeOwnerConfigFileUpdateScanner.update(
+              branchResource.getBranchKey(),
+              commitMessage,
+              (codeOwnerConfigFilePath, codeOwnerConfigFileContent) ->
+                  renameEmailInCodeOwnerConfig(
+                      codeOwnerBackend,
+                      codeOwnerConfigFileContent,
+                      input.oldEmail,
+                      input.newEmail));
+
+      RenameEmailResultInfo result = new RenameEmailResultInfo();
+      if (commitId.isPresent()) {
+        result.commit = CommitUtil.toCommitInfo(commitId.get());
+      }
+      return Response.ok(result);
+    } catch (NotImplementedException e) {
+      throw new MethodNotAllowedException(
+          String.format(
+              "rename email not supported by %s backend",
+              CodeOwnerBackendId.getBackendId(codeOwnerBackend.getClass())),
+          e);
+    }
+  }
+
+  private void validateInput(RenameEmailInput input) throws BadRequestException {
+    if (input.oldEmail == null) {
+      throw new BadRequestException("old email is required");
+    }
+    if (input.newEmail == null) {
+      throw new BadRequestException("new email is required");
+    }
+    if (input.oldEmail.equals(input.newEmail)) {
+      throw new BadRequestException("old and new email must differ");
+    }
+  }
+
+  private Account.Id resolveEmail(String email) throws UnprocessableEntityException {
+    requireNonNull(email, "email");
+
+    Optional<CodeOwner> codeOwner = codeOwnerResolver.resolve(CodeOwnerReference.create(email));
+    if (!codeOwner.isPresent()) {
+      throw new UnprocessableEntityException(String.format("cannot resolve email %s", email));
+    }
+    return codeOwner.get().accountId();
+  }
+
+  /**
+   * Renames an email in the given code owner config.
+   *
+   * @param codeOwnerBackend the code owner backend that is being used
+   * @param codeOwnerConfigFileContent the content of the code owner config file
+   * @param oldEmail the old email that should be replaced by the new email
+   * @param newEmail the new email that should replace the old email
+   * @return the updated code owner config file content if an update was performed, {@link
+   *     Optional#empty()} if no update was done
+   */
+  private Optional<String> renameEmailInCodeOwnerConfig(
+      CodeOwnerBackend codeOwnerBackend,
+      String codeOwnerConfigFileContent,
+      String oldEmail,
+      String newEmail) {
+    requireNonNull(codeOwnerConfigFileContent, "codeOwnerConfigFileContent");
+    requireNonNull(oldEmail, "oldEmail");
+    requireNonNull(newEmail, "newEmail");
+
+    String updatedCodeOwnerConfigFileContent =
+        codeOwnerBackend.replaceEmail(codeOwnerConfigFileContent, oldEmail, newEmail);
+    if (codeOwnerConfigFileContent.equals(updatedCodeOwnerConfigFileContent)) {
+      return Optional.empty();
+    }
+    return Optional.of(updatedCodeOwnerConfigFileContent);
+  }
+}
diff --git a/java/com/google/gerrit/plugins/codeowners/restapi/RestApiModule.java b/java/com/google/gerrit/plugins/codeowners/restapi/RestApiModule.java
index 38fe259..03d8858 100644
--- a/java/com/google/gerrit/plugins/codeowners/restapi/RestApiModule.java
+++ b/java/com/google/gerrit/plugins/codeowners/restapi/RestApiModule.java
@@ -31,6 +31,7 @@
         .to(GetCodeOwnerConfigForPathInBranch.class);
     get(BRANCH_KIND, "code_owners.config_files").to(GetCodeOwnerConfigFiles.class);
     get(BRANCH_KIND, "code_owners.branch_config").to(GetCodeOwnerBranchConfig.class);
+    post(BRANCH_KIND, "code_owners.rename").to(RenameEmail.class);
 
     factory(CodeOwnerJson.Factory.class);
     DynamicMap.mapOf(binder(), CodeOwnersInBranchCollection.PathResource.PATH_KIND);
diff --git a/java/com/google/gerrit/plugins/codeowners/testing/RequiredApprovalSubject.java b/java/com/google/gerrit/plugins/codeowners/testing/RequiredApprovalSubject.java
new file mode 100644
index 0000000..68673df
--- /dev/null
+++ b/java/com/google/gerrit/plugins/codeowners/testing/RequiredApprovalSubject.java
@@ -0,0 +1,104 @@
+// 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.
+
+package com.google.gerrit.plugins.codeowners.testing;
+
+import static com.google.common.truth.Truth.assertAbout;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.truth.FailureMetadata;
+import com.google.common.truth.IntegerSubject;
+import com.google.common.truth.StringSubject;
+import com.google.common.truth.Subject;
+import com.google.gerrit.plugins.codeowners.config.RequiredApproval;
+import com.google.gerrit.truth.ListSubject;
+import com.google.gerrit.truth.OptionalSubject;
+import java.util.Optional;
+
+/** {@link Subject} for doing assertions on {@link RequiredApproval}s. */
+public class RequiredApprovalSubject extends Subject {
+  /**
+   * Starts a fluent chain to do assertions on a {@link RequiredApproval}.
+   *
+   * @param requiredApproval the required approval on which assertions should be done
+   * @return the created {@link RequiredApprovalSubject}
+   */
+  public static RequiredApprovalSubject assertThat(RequiredApproval requiredApproval) {
+    return assertAbout(requiredApprovals()).that(requiredApproval);
+  }
+
+  /**
+   * Starts a fluent chain to do assertions on an {@link Optional} {@link RequiredApproval}.
+   *
+   * @param requiredApproval optional required approval on which assertions should be done
+   * @return the created {@link OptionalSubject}
+   */
+  public static OptionalSubject<RequiredApprovalSubject, RequiredApproval> assertThat(
+      Optional<RequiredApproval> requiredApproval) {
+    return OptionalSubject.assertThat(requiredApproval, requiredApprovals());
+  }
+
+  /**
+   * Starts a fluent chain to do assertions on a list of {@link RequiredApproval}s.
+   *
+   * @param requiredApprovals list of required approvals on which assertions should be done
+   * @return the created {@link ListSubject}
+   */
+  public static ListSubject<RequiredApprovalSubject, RequiredApproval> assertThat(
+      ImmutableList<RequiredApproval> requiredApprovals) {
+    return ListSubject.assertThat(requiredApprovals, requiredApprovals());
+  }
+
+  /**
+   * Starts a fluent chain to do assertions on a set of {@link RequiredApproval}s.
+   *
+   * @param requiredApprovals set of required approvals on which assertions should be done
+   * @return the created {@link ListSubject}
+   */
+  public static ListSubject<RequiredApprovalSubject, RequiredApproval> assertThat(
+      ImmutableSet<RequiredApproval> requiredApprovals) {
+    return ListSubject.assertThat(requiredApprovals.asList(), requiredApprovals());
+  }
+
+  /**
+   * Creates a subject factory for mapping {@link RequiredApproval}s to {@link
+   * RequiredApprovalSubject}s.
+   */
+  private static Subject.Factory<RequiredApprovalSubject, RequiredApproval> requiredApprovals() {
+    return RequiredApprovalSubject::new;
+  }
+
+  private final RequiredApproval requiredApproval;
+
+  private RequiredApprovalSubject(FailureMetadata metadata, RequiredApproval requiredApproval) {
+    super(metadata, requiredApproval);
+    this.requiredApproval = requiredApproval;
+  }
+
+  /** Returns a subject for the label name. */
+  public StringSubject hasLabelNameThat() {
+    return check("labelName()").that(requiredApproval().labelType().getName());
+  }
+
+  /** Returns a subject for the value. */
+  public IntegerSubject hasValueThat() {
+    return check("value()").that((int) requiredApproval().value());
+  }
+
+  private RequiredApproval requiredApproval() {
+    isNotNull();
+    return requiredApproval;
+  }
+}
diff --git a/java/com/google/gerrit/plugins/codeowners/validation/CodeOwnerConfigValidator.java b/java/com/google/gerrit/plugins/codeowners/validation/CodeOwnerConfigValidator.java
index 85cfca4..e659580 100644
--- a/java/com/google/gerrit/plugins/codeowners/validation/CodeOwnerConfigValidator.java
+++ b/java/com/google/gerrit/plugins/codeowners/validation/CodeOwnerConfigValidator.java
@@ -237,16 +237,44 @@
                 .username(caller.getLoggableName())
                 .patchSetId(patchSetId.get())
                 .build())) {
-      ChangeNotes changeNotes =
-          changeNotesFactory.create(projectState.getNameKey(), commit.change().getId());
-      PatchSet patchSet = patchSetUtil.get(changeNotes, patchSetId);
-      IdentifiedUser patchSetUploader = userFactory.create(patchSet.uploader());
-      Optional<ValidationResult> validationResult =
-          validateCodeOwnerConfig(
-              branchNameKey, repository.getConfig(), revWalk, commit, patchSetUploader);
+      CodeOwnerConfigValidationPolicy codeOwnerConfigValidationPolicy =
+          codeOwnersPluginConfiguration.getCodeOwnerConfigValidationPolicyForSubmit(
+              branchNameKey.project());
+      logger.atFine().log("codeOwnerConfigValidationPolicy = %s", codeOwnerConfigValidationPolicy);
+      Optional<ValidationResult> validationResult;
+      if (!codeOwnerConfigValidationPolicy.runValidation()) {
+        validationResult =
+            Optional.of(
+                ValidationResult.create(
+                    "skipping validation of code owner config files",
+                    new CommitValidationMessage(
+                        "code owners config validation is disabled", ValidationMessage.Type.HINT)));
+      } else {
+        try {
+          ChangeNotes changeNotes =
+              changeNotesFactory.create(projectState.getNameKey(), commit.change().getId());
+          PatchSet patchSet = patchSetUtil.get(changeNotes, patchSetId);
+          IdentifiedUser patchSetUploader = userFactory.create(patchSet.uploader());
+          validationResult =
+              validateCodeOwnerConfig(
+                  branchNameKey, repository.getConfig(), revWalk, commit, patchSetUploader);
+        } catch (RuntimeException e) {
+          if (!codeOwnerConfigValidationPolicy.isDryRun()) {
+            throw e;
+          }
+
+          // The validation was executed as dry-run and failures during the validation should not
+          // cause an error. Hence we swallow the exception here.
+          logger.atFine().withCause(e).log(
+              "ignoring failure during validation of code owner config files in revision %s"
+                  + " (project = %s, branch = %s) because the validation was performed as dry-run",
+              commit.name(), branchNameKey.project(), branchNameKey.branch());
+          validationResult = Optional.empty();
+        }
+      }
       if (validationResult.isPresent()) {
         logger.atFine().log("validation result = %s", validationResult.get());
-        validationResult.get().processForOnPreMerge();
+        validationResult.get().processForOnPreMerge(codeOwnerConfigValidationPolicy.isDryRun());
       }
     }
   }
@@ -748,6 +776,7 @@
                         validateCodeOwnerConfigReference(
                             codeOwnerConfigFilePath,
                             codeOwnerConfig.key(),
+                            codeOwnerConfig.revision(),
                             CodeOwnerConfigImportType.GLOBAL,
                             codeOwnerConfigReference)),
             codeOwnerConfig.codeOwnerSets().stream()
@@ -757,6 +786,7 @@
                         validateCodeOwnerConfigReference(
                             codeOwnerConfigFilePath,
                             codeOwnerConfig.key(),
+                            codeOwnerConfig.revision(),
                             CodeOwnerConfigImportType.PER_FILE,
                             codeOwnerConfigReference)))
         .filter(Optional::isPresent)
@@ -769,6 +799,8 @@
    * @param codeOwnerConfigFilePath the path of the code owner config file which contains the code
    *     owner config reference
    * @param keyOfImportingCodeOwnerConfig key of the importing code owner config
+   * @param codeOwnerConfigRevision the commit from which the code owner config which contains the
+   *     code owner config reference was loaded
    * @param importType the type of the import
    * @param codeOwnerConfigReference the code owner config reference that should be validated.
    * @return a validation message describing the issue with the code owner config reference, {@link
@@ -777,6 +809,7 @@
   private Optional<CommitValidationMessage> validateCodeOwnerConfigReference(
       Path codeOwnerConfigFilePath,
       CodeOwnerConfig.Key keyOfImportingCodeOwnerConfig,
+      ObjectId codeOwnerConfigRevision,
       CodeOwnerConfigImportType importType,
       CodeOwnerConfigReference codeOwnerConfigReference) {
     CodeOwnerConfig.Key keyOfImportedCodeOwnerConfig =
@@ -804,7 +837,9 @@
               projectState.get().getProject().getState().name()));
     }
 
-    Optional<ObjectId> revision = getRevision(keyOfImportedCodeOwnerConfig);
+    Optional<ObjectId> revision =
+        getRevision(
+            keyOfImportingCodeOwnerConfig, codeOwnerConfigRevision, keyOfImportedCodeOwnerConfig);
     if (!revision.isPresent() || !isBranchReadable(keyOfImportedCodeOwnerConfig)) {
       // we intentionally use the same error message for non-existing and non-readable branches so
       // that uploaders cannot probe for the existence of branches (e.g. deduce from the error
@@ -888,7 +923,18 @@
     }
   }
 
-  private Optional<ObjectId> getRevision(CodeOwnerConfig.Key keyOfImportedCodeOwnerConfig) {
+  private Optional<ObjectId> getRevision(
+      CodeOwnerConfig.Key keyOfImportingCodeOwnerConfig,
+      ObjectId codeOwnerConfigRevision,
+      CodeOwnerConfig.Key keyOfImportedCodeOwnerConfig) {
+    if (keyOfImportingCodeOwnerConfig
+        .branchNameKey()
+        .equals(keyOfImportedCodeOwnerConfig.branchNameKey())) {
+      // load the imported code owner config from the same revision from which the importing code
+      // owner config was loaded
+      return Optional.of(codeOwnerConfigRevision);
+    }
+
     try (Repository repo = repoManager.openRepository(keyOfImportedCodeOwnerConfig.project())) {
       return Optional.ofNullable(repo.exactRef(keyOfImportedCodeOwnerConfig.ref()))
           .map(Ref::getObjectId);
@@ -959,8 +1005,8 @@
      * <p>If there are no errors the validation messages are logged on fine level so that they show
      * up in a trace. Returning the message to the user without failing the submit is not possible.
      */
-    void processForOnPreMerge() throws MergeValidationException {
-      if (hasError()) {
+    void processForOnPreMerge(boolean dryRun) throws MergeValidationException {
+      if (!dryRun && hasError()) {
         throw new MergeValidationException(getMessage(validationMessages()));
       }
 
diff --git a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/AbstractGetCodeOwnersForPathIT.java b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/AbstractGetCodeOwnersForPathIT.java
index b9032cb..4dbac76 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/AbstractGetCodeOwnersForPathIT.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/AbstractGetCodeOwnersForPathIT.java
@@ -22,6 +22,8 @@
 import static com.google.gerrit.plugins.codeowners.testing.CodeOwnerInfoSubject.hasAccountName;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static java.util.stream.Collectors.toList;
+import static org.junit.Assert.fail;
 
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.TestAccount;
@@ -31,6 +33,7 @@
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.RefNames;
@@ -47,6 +50,7 @@
 import com.google.gerrit.plugins.codeowners.restapi.GetCodeOwnersForPathInBranch;
 import com.google.inject.Inject;
 import java.util.List;
+import java.util.Random;
 import org.junit.Before;
 import org.junit.Test;
 
@@ -410,39 +414,6 @@
   }
 
   @Test
-  public void codeOwnersThatAreServiceUsersAreFilteredOut() throws Exception {
-    TestAccount serviceUser =
-        accountCreator.create("serviceUser", "service.user@example.com", "Service User", null);
-
-    // Create a code owner config with 2 code owners.
-    codeOwnerConfigOperations
-        .newCodeOwnerConfig()
-        .project(project)
-        .branch("master")
-        .folderPath("/")
-        .addCodeOwnerEmail(admin.email())
-        .addCodeOwnerEmail(serviceUser.email())
-        .create();
-
-    // Check that both code owners are suggested.
-    assertThat(queryCodeOwners("/foo/bar/baz.md"))
-        .comparingElementsUsing(hasAccountId())
-        .containsExactly(admin.id(), serviceUser.id());
-
-    // Make 'serviceUser' a service user.
-    groupOperations
-        .group(groupCache.get(AccountGroup.nameKey("Service Users")).get().getGroupUUID())
-        .forUpdate()
-        .addMember(serviceUser.id())
-        .update();
-
-    // Expect that 'serviceUser' is filtered out now.
-    assertThat(queryCodeOwners("/foo/bar/baz.md"))
-        .comparingElementsUsing(hasAccountId())
-        .containsExactly(admin.id());
-  }
-
-  @Test
   public void codeOwnersThatCannotSeeTheBranchAreFilteredOut() throws Exception {
     // Create a code owner config with 2 code owners.
     codeOwnerConfigOperations
@@ -509,6 +480,8 @@
   @Test
   public void getCodeOwnersOrderNotDefinedIfCodeOwnersHaveTheSameScoring() throws Exception {
     TestAccount user2 = accountCreator.user2();
+    TestAccount user3 = accountCreator.create("user3", "user3@example.com", "User3", null);
+    TestAccount user4 = accountCreator.create("user4", "user4@example.com", "User4", null);
 
     codeOwnerConfigOperations
         .newCodeOwnerConfig()
@@ -517,6 +490,8 @@
         .folderPath("/")
         .addCodeOwnerEmail(admin.email())
         .addCodeOwnerEmail(user2.email())
+        .addCodeOwnerEmail(user3.email())
+        .addCodeOwnerEmail(user4.email())
         .create();
 
     codeOwnerConfigOperations
@@ -530,12 +505,33 @@
     List<CodeOwnerInfo> codeOwnerInfos = queryCodeOwners("/foo/bar.md");
     assertThat(codeOwnerInfos)
         .comparingElementsUsing(hasAccountId())
-        .containsExactly(admin.id(), user.id(), user2.id());
+        .containsExactly(admin.id(), user.id(), user2.id(), user3.id(), user4.id());
 
     // The first code owner in the result should be user as user has the best distance score.
-    // The other 2 code owners come in a random order, but verifying this in a test is hard, hence
-    // there is no assertion for this.
     assertThatList(codeOwnerInfos).element(0).hasAccountIdThat().isEqualTo(user.id());
+
+    // The order of the other code owners is random since they have the same score.
+    // Check that the order of the code owners with the same score is different for further requests
+    // at least once.
+    List<Account.Id> accountIdsInRetrievedOrder1 =
+        codeOwnerInfos.stream().map(info -> Account.id(info.account._accountId)).collect(toList());
+    boolean foundOtherOrder = false;
+    for (int i = 0; i < 10; i++) {
+      codeOwnerInfos = queryCodeOwners("/foo/bar.md");
+      List<Account.Id> accountIdsInRetrievedOrder2 =
+          codeOwnerInfos.stream()
+              .map(info -> Account.id(info.account._accountId))
+              .collect(toList());
+      if (!accountIdsInRetrievedOrder1.equals(accountIdsInRetrievedOrder2)) {
+        foundOtherOrder = true;
+        break;
+      }
+    }
+    if (!foundOtherOrder) {
+      fail(
+          String.format(
+              "expected different order, but order was always %s", accountIdsInRetrievedOrder1));
+    }
   }
 
   @Test
@@ -714,19 +710,6 @@
   }
 
   @Test
-  @GerritConfig(name = "plugin.code-owners.globalCodeOwner", value = "service.user@example.com")
-  public void globalCodeOwnersThatAreServiceUsersAreFilteredOut() throws Exception {
-    TestAccount serviceUser =
-        accountCreator.create("serviceUser", "service.user@example.com", "Service User", null);
-    groupOperations
-        .group(groupCache.get(AccountGroup.nameKey("Service Users")).get().getGroupUUID())
-        .forUpdate()
-        .addMember(serviceUser.id())
-        .update();
-    assertThat(queryCodeOwners("/foo/bar/baz.md")).isEmpty();
-  }
-
-  @Test
   public void getDefaultCodeOwners() throws Exception {
     // Create default code owner config file in refs/meta/config.
     codeOwnerConfigOperations
@@ -1031,4 +1014,73 @@
         .comparingElementsUsing(hasAccountId())
         .containsExactly(admin.id(), user.id(), user2.id());
   }
+
+  @Test
+  public void getCodeOwnersProvidingASeedMakesSortOrderStableAcrocssRequests() throws Exception {
+    TestAccount user2 = accountCreator.user2();
+
+    // create some code owner configs
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/")
+        .addCodeOwnerEmail(admin.email())
+        .addCodeOwnerEmail(user.email())
+        .addCodeOwnerEmail(user2.email())
+        .create();
+
+    long seed = (new Random()).nextLong();
+
+    List<CodeOwnerInfo> codeOwnerInfos =
+        queryCodeOwners(getCodeOwnersApi().query().withSeed(seed), "/foo/bar/baz.md");
+    // all code owners have the same score, hence their order is random
+    assertThatList(codeOwnerInfos)
+        .comparingElementsUsing(hasAccountId())
+        .containsExactly(admin.id(), user.id(), user2.id());
+
+    // Check that the order for further requests that use the same seed is the same.
+    List<Account.Id> expectedAccountIds =
+        codeOwnerInfos.stream().map(info -> Account.id(info.account._accountId)).collect(toList());
+    for (int i = 0; i < 10; i++) {
+      assertThatList(queryCodeOwners(getCodeOwnersApi().query().withSeed(seed), "/foo/bar/baz.md"))
+          .comparingElementsUsing(hasAccountId())
+          .containsExactlyElementsIn(expectedAccountIds)
+          .inOrder();
+    }
+  }
+
+  @Test
+  public void getCodeOwnersProvidingASeedMakesSortOrderStableAcrossRequests_allUsersAreCodeOwners()
+      throws Exception {
+    TestAccount user2 = accountCreator.user2();
+
+    // create some code owner configs
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/")
+        .addCodeOwnerEmail("*")
+        .create();
+
+    long seed = (new Random()).nextLong();
+
+    List<CodeOwnerInfo> codeOwnerInfos =
+        queryCodeOwners(getCodeOwnersApi().query().withSeed(seed), "/foo/bar/baz.md");
+    // all code owners have the same score, hence their order is random
+    assertThatList(codeOwnerInfos)
+        .comparingElementsUsing(hasAccountId())
+        .containsExactly(admin.id(), user.id(), user2.id());
+
+    // Check that the order for further requests that use the same seed is the same.
+    List<Account.Id> expectedAccountIds =
+        codeOwnerInfos.stream().map(info -> Account.id(info.account._accountId)).collect(toList());
+    for (int i = 0; i < 10; i++) {
+      assertThatList(queryCodeOwners(getCodeOwnersApi().query().withSeed(seed), "/foo/bar/baz.md"))
+          .comparingElementsUsing(hasAccountId())
+          .containsExactlyElementsIn(expectedAccountIds)
+          .inOrder();
+    }
+  }
 }
diff --git a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/BUILD b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/BUILD
index 4821121..5fb0fdf 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/BUILD
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/BUILD
@@ -5,12 +5,9 @@
     default_visibility = ["//plugins/code-owners:visibility"],
 )
 
-acceptance_tests(
-    srcs = glob(
-        ["*IT.java"],
-        exclude = ["Abstract*.java"],
-    ),
-    group = "acceptance_api",
+[acceptance_tests(
+    srcs = [f],
+    group = f[:f.index(".")],
     deps = [
         "testbases",
         "//plugins/code-owners:code-owners__plugin",
@@ -18,7 +15,10 @@
         "//plugins/code-owners/java/com/google/gerrit/plugins/codeowners/acceptance/testsuite",
         "//plugins/code-owners/java/com/google/gerrit/plugins/codeowners/testing",
     ],
-)
+) for f in glob(
+    ["*IT.java"],
+    exclude = ["Abstract*.java"],
+)]
 
 java_library(
     name = "testbases",
diff --git a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CheckCodeOwnerConfigFilesIT.java b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CheckCodeOwnerConfigFilesIT.java
index e6870c2..9a31ca1 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CheckCodeOwnerConfigFilesIT.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CheckCodeOwnerConfigFilesIT.java
@@ -35,7 +35,6 @@
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.plugins.codeowners.JgitPath;
 import com.google.gerrit.plugins.codeowners.acceptance.AbstractCodeOwnersIT;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerBackend;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfig;
@@ -608,16 +607,6 @@
     throw new IllegalStateException("unknown code owner backend: " + backend.getClass().getName());
   }
 
-  private void createNonParseableCodeOwnerConfig(String path) throws Exception {
-    disableCodeOwnersForProject(project);
-    String changeId =
-        createChange("Add invalid code owners file", JgitPath.of(path).get(), "INVALID")
-            .getChangeId();
-    approve(changeId);
-    gApi.changes().id(changeId).current().submit();
-    enableCodeOwnersForProject(project);
-  }
-
   private String getParsingErrorMessage(
       ImmutableMap<Class<? extends CodeOwnerBackend>, String> messagesByBackend) {
     CodeOwnerBackend codeOwnerBackend = backendConfig.getDefaultBackend();
diff --git a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CodeOwnerConfigValidatorIT.java b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CodeOwnerConfigValidatorIT.java
index bc42987..2e5e73b 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CodeOwnerConfigValidatorIT.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CodeOwnerConfigValidatorIT.java
@@ -22,6 +22,7 @@
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.acceptance.GitUtil;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.config.GerritConfig;
@@ -166,6 +167,12 @@
             .addCodeOwnerEmail(user.email())
             .create();
 
+    // Fetch the commit that created the imported code owner config into the local repository so
+    // that the commit that creates the importing code owner config becomes a successor of this
+    // commit.
+    GitUtil.fetch(testRepo, "refs/*:refs/*");
+    testRepo.reset(projectOperations.project(project).getHead("master"));
+
     CodeOwnerConfigReference codeOwnerConfigReference =
         CodeOwnerConfigReference.create(
             CodeOwnerConfigImportMode.GLOBAL_CODE_OWNER_SETS_ONLY,
@@ -282,6 +289,47 @@
   }
 
   @Test
+  public void canUploadConfigWithoutIssues_withImportOfConfigThatIsAddedInSameCommit()
+      throws Exception {
+    // imports are not supported for the proto backend
+    assume().that(backendConfig.getDefaultBackend()).isNotInstanceOf(ProtoBackend.class);
+
+    CodeOwnerConfig.Key codeOwnerConfigKey = createCodeOwnerConfigKey("/");
+    CodeOwnerConfig.Key keyOfImportedCodeOwnerConfig = createCodeOwnerConfigKey("/foo/");
+
+    CodeOwnerConfigReference codeOwnerConfigReference =
+        CodeOwnerConfigReference.create(
+            CodeOwnerConfigImportMode.GLOBAL_CODE_OWNER_SETS_ONLY,
+            codeOwnerConfigOperations.codeOwnerConfig(keyOfImportedCodeOwnerConfig).getFilePath());
+
+    // Create a code owner config with import and without issues.
+    PushOneCommit.Result r =
+        createChange(
+            "Add code owners",
+            ImmutableMap.of(
+                codeOwnerConfigOperations.codeOwnerConfig(codeOwnerConfigKey).getJGitFilePath(),
+                format(
+                    CodeOwnerConfig.builder(codeOwnerConfigKey, TEST_REVISION)
+                        .addImport(codeOwnerConfigReference)
+                        .addCodeOwnerSet(
+                            CodeOwnerSet.builder()
+                                .addCodeOwnerEmail(admin.email())
+                                .addPathExpression("foo")
+                                .addImport(codeOwnerConfigReference)
+                                .build())
+                        .build()),
+                codeOwnerConfigOperations
+                    .codeOwnerConfig(keyOfImportedCodeOwnerConfig)
+                    .getJGitFilePath(),
+                format(
+                    CodeOwnerConfig.builder(keyOfImportedCodeOwnerConfig, TEST_REVISION)
+                        .addCodeOwnerSet(
+                            CodeOwnerSet.builder().addCodeOwnerEmail(user.email()).build())
+                        .build())));
+    assertOkWithHints(r, "code owner config files validated, no issues found");
+  }
+
+  @Test
   @GerritConfig(name = "plugin.code-owners.backend", value = "non-existing-backend")
   public void canUploadNonParseableConfigIfCodeOwnersPluginConfigurationIsInvalid()
       throws Exception {
@@ -1428,6 +1476,81 @@
     gApi.changes().create(changeInput);
   }
 
+  @Test
+  @GerritConfig(name = "plugin.code-owners.enableValidationOnSubmit", value = "false")
+  public void canSubmitNonParseableConfigIfValidationIsDisabled() throws Exception {
+    testCanSubmitNonParseableConfig();
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.enableValidationOnSubmit", value = "dry_run")
+  public void canSubmitNonParseableConfigIfValidationIsDoneAsDryRun() throws Exception {
+    testCanSubmitNonParseableConfig();
+  }
+
+  private void testCanSubmitNonParseableConfig() throws Exception {
+    CodeOwnerConfig.Key codeOwnerConfigKey = createCodeOwnerConfigKey("/");
+
+    // disable the code owners functionality so that we can upload a non-parseable code owner config
+    // that we then try to submit
+    disableCodeOwnersForProject(project);
+
+    PushOneCommit.Result r =
+        createChange(
+            "Add code owners",
+            codeOwnerConfigOperations.codeOwnerConfig(codeOwnerConfigKey).getJGitFilePath(),
+            "INVALID");
+    r.assertOkStatus();
+
+    // re-enable the code owners functionality for the project
+    enableCodeOwnersForProject(project);
+
+    // submit the change
+    approve(r.getChangeId());
+    gApi.changes().id(r.getChangeId()).current().submit();
+    assertThat(gApi.changes().id(r.getChangeId()).get().status).isEqualTo(ChangeStatus.MERGED);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.enableValidationOnSubmit", value = "false")
+  public void canSubmitConfigWithIssuesIfValidationIsDisabled() throws Exception {
+    testCanSubmitConfigWithIssues();
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.enableValidationOnSubmit", value = "dry_run")
+  public void canSubmitConfigWithIssuesIfValidationIsDoneAsDryRun() throws Exception {
+    testCanSubmitConfigWithIssues();
+  }
+
+  private void testCanSubmitConfigWithIssues() throws Exception {
+    CodeOwnerConfig.Key codeOwnerConfigKey = createCodeOwnerConfigKey("/");
+
+    // disable the code owners functionality so that we can upload a code owner config with issues
+    // that we then try to submit
+    disableCodeOwnersForProject(project);
+
+    // upload a code owner config that has issues (non-resolvable code owners)
+    String unknownEmail1 = "non-existing-email@example.com";
+    PushOneCommit.Result r =
+        createChange(
+            "Add code owners",
+            codeOwnerConfigOperations.codeOwnerConfig(codeOwnerConfigKey).getJGitFilePath(),
+            format(
+                CodeOwnerConfig.builder(codeOwnerConfigKey, TEST_REVISION)
+                    .addCodeOwnerSet(CodeOwnerSet.createWithoutPathExpressions(unknownEmail1))
+                    .build()));
+    r.assertOkStatus();
+
+    // re-enable the code owners functionality for the project
+    enableCodeOwnersForProject(project);
+
+    // submit the change
+    approve(r.getChangeId());
+    gApi.changes().id(r.getChangeId()).current().submit();
+    assertThat(gApi.changes().id(r.getChangeId()).get().status).isEqualTo(ChangeStatus.MERGED);
+  }
+
   private CodeOwnerConfig createCodeOwnerConfigWithImport(
       CodeOwnerConfig.Key keyOfImportingCodeOwnerConfig,
       CodeOwnerConfigImportType importType,
diff --git a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CodeOwnersOnPostReviewIT.java b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CodeOwnersOnPostReviewIT.java
new file mode 100644
index 0000000..d899268
--- /dev/null
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CodeOwnersOnPostReviewIT.java
@@ -0,0 +1,589 @@
+// 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.
+
+package com.google.gerrit.plugins.codeowners.acceptance.api;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Iterables;
+import com.google.gerrit.acceptance.config.GerritConfig;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.common.ChangeMessageInfo;
+import com.google.gerrit.extensions.common.LabelDefinitionInput;
+import com.google.gerrit.plugins.codeowners.acceptance.AbstractCodeOwnersIT;
+import java.util.Collection;
+import org.junit.Test;
+
+/**
+ * Acceptance test for {@code com.google.gerrit.plugins.codeowners.backend.CodeOwnersOnPostReview}.
+ */
+public class CodeOwnersOnPostReviewIT extends AbstractCodeOwnersIT {
+  @Test
+  @GerritConfig(name = "plugin.code-owners.disabled", value = "true")
+  public void changeMessageNotExtendedIfCodeOwnersFuctionalityIsDisabled() throws Exception {
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/foo/")
+        .addCodeOwnerEmail(admin.email())
+        .create();
+
+    String path = "foo/bar.baz";
+    String changeId = createChange("Test Change", path, "file content").getChangeId();
+
+    recommend(changeId);
+
+    Collection<ChangeMessageInfo> messages = gApi.changes().id(changeId).get().messages;
+    assertThat(Iterables.getLast(messages).message).isEqualTo("Patch Set 1: Code-Review+1");
+  }
+
+  @Test
+  public void changeMessageListsNewlyApprovedPaths() throws Exception {
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/foo/")
+        .addCodeOwnerEmail(admin.email())
+        .create();
+
+    String path = "foo/bar.baz";
+    String changeId = createChange("Test Change", path, "file content").getChangeId();
+
+    recommend(changeId);
+
+    Collection<ChangeMessageInfo> messages = gApi.changes().id(changeId).get().messages;
+    assertThat(Iterables.getLast(messages).message)
+        .isEqualTo(
+            String.format(
+                "Patch Set 1: Code-Review+1\n\n"
+                    + "By voting Code-Review+1 the following files are now code-owner approved by"
+                    + " %s:\n"
+                    + "* %s\n",
+                admin.fullName(), path));
+  }
+
+  @Test
+  public void changeMessageListsPathsThatAreStillApproved() throws Exception {
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/foo/")
+        .addCodeOwnerEmail(admin.email())
+        .create();
+
+    String path = "foo/bar.baz";
+    String changeId = createChange("Test Change", path, "file content").getChangeId();
+
+    recommend(changeId);
+
+    // Upgrade the approval from Code-Review+1 to Code-Review+2
+    approve(changeId);
+
+    Collection<ChangeMessageInfo> messages = gApi.changes().id(changeId).get().messages;
+    assertThat(Iterables.getLast(messages).message)
+        .isEqualTo(
+            String.format(
+                "Patch Set 1: Code-Review+2\n\n"
+                    + "By voting Code-Review+2 the following files are still code-owner approved by"
+                    + " %s:\n"
+                    + "* %s\n",
+                admin.fullName(), path));
+  }
+
+  @Test
+  public void changeMessageListsPathsThatAreNoLongerApproved_voteRemoved() throws Exception {
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/foo/")
+        .addCodeOwnerEmail(admin.email())
+        .create();
+
+    String path = "foo/bar.baz";
+    String changeId = createChange("Test Change", path, "file content").getChangeId();
+
+    recommend(changeId);
+
+    // Remove the approval.
+    gApi.changes().id(changeId).current().review(new ReviewInput().label("Code-Review", 0));
+
+    Collection<ChangeMessageInfo> messages = gApi.changes().id(changeId).get().messages;
+    assertThat(Iterables.getLast(messages).message)
+        .isEqualTo(
+            String.format(
+                "Patch Set 1: -Code-Review\n\n"
+                    + "By removing the Code-Review vote the following files are no longer"
+                    + " code-owner approved by %s:\n"
+                    + "* %s\n",
+                admin.fullName(), path));
+  }
+
+  @Test
+  public void changeMessageListsPathsThatAreNoLongerApproved_voteChangedToNegativeValue()
+      throws Exception {
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/foo/")
+        .addCodeOwnerEmail(admin.email())
+        .create();
+
+    String path = "foo/bar.baz";
+    String changeId = createChange("Test Change", path, "file content").getChangeId();
+
+    recommend(changeId);
+
+    // Vote with a negative value.
+    gApi.changes().id(changeId).current().review(new ReviewInput().label("Code-Review", -1));
+
+    Collection<ChangeMessageInfo> messages = gApi.changes().id(changeId).get().messages;
+    assertThat(Iterables.getLast(messages).message)
+        .isEqualTo(
+            String.format(
+                "Patch Set 1: Code-Review-1\n\n"
+                    + "By voting Code-Review-1 the following files are no longer code-owner"
+                    + " approved by %s:\n"
+                    + "* %s\n",
+                admin.fullName(), path));
+  }
+
+  @Test
+  public void changeMessageListsOnlyApprovedPaths() throws Exception {
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/foo/")
+        .addCodeOwnerEmail(admin.email())
+        .create();
+
+    String path1 = "foo/bar.baz";
+    String path2 = "foo/baz.bar";
+    String changeId =
+        createChange(
+                "Test Change",
+                ImmutableMap.of(
+                    path1,
+                    "file content",
+                    path2,
+                    "file content",
+                    "bar/foo.baz",
+                    "file content",
+                    "bar/baz.foo",
+                    "file content"))
+            .getChangeId();
+
+    recommend(changeId);
+
+    Collection<ChangeMessageInfo> messages = gApi.changes().id(changeId).get().messages;
+    assertThat(Iterables.getLast(messages).message)
+        .isEqualTo(
+            String.format(
+                "Patch Set 1: Code-Review+1\n\n"
+                    + "By voting Code-Review+1 the following files are now code-owner approved by"
+                    + " %s:\n"
+                    + "* %s\n"
+                    + "* %s\n",
+                admin.fullName(), path1, path2));
+  }
+
+  @Test
+  public void changeMessageListsOnlyApprovedPaths_fileRenamed() throws Exception {
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/foo/")
+        .addCodeOwnerEmail(admin.email())
+        .create();
+
+    // createChangeWithFileRename creates a change with 2 patch sets
+    String oldPath = "foo/bar.baz";
+    String newPath = "bar/baz.bar";
+    String changeId = createChangeWithFileRename(oldPath, newPath);
+
+    recommend(changeId);
+
+    Collection<ChangeMessageInfo> messages = gApi.changes().id(changeId).get().messages;
+    assertThat(Iterables.getLast(messages).message)
+        .isEqualTo(
+            String.format(
+                "Patch Set 2: Code-Review+1\n\n"
+                    + "By voting Code-Review+1 the following files are now code-owner approved by"
+                    + " %s:\n"
+                    + "* %s\n",
+                admin.fullName(), oldPath));
+  }
+
+  @Test
+  public void changeMessageNotExtendedIfUserOwnsNoneOfTheFiles() throws Exception {
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/foo/")
+        .addCodeOwnerEmail(admin.email())
+        .create();
+
+    String changeId =
+        createChange(
+                "Test Change",
+                ImmutableMap.of("bar/foo.baz", "file content", "bar/baz.foo", "file content"))
+            .getChangeId();
+
+    recommend(changeId);
+
+    Collection<ChangeMessageInfo> messages = gApi.changes().id(changeId).get().messages;
+    assertThat(Iterables.getLast(messages).message).isEqualTo("Patch Set 1: Code-Review+1");
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.requiredApproval", value = "Owners-Approval+1")
+  public void changeMessageNotExtendedForNonCodeOwnerApproval() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("+1", "Owner Approval", " 0", "No Owner Approval");
+    gApi.projects().name(project.get()).label("Owners-Approval").create(input).get();
+
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/foo/")
+        .addCodeOwnerEmail(admin.email())
+        .create();
+
+    String changeId =
+        createChange(
+                "Test Change",
+                ImmutableMap.of("bar/foo.baz", "file content", "bar/baz.foo", "file content"))
+            .getChangeId();
+
+    recommend(changeId);
+
+    Collection<ChangeMessageInfo> messages = gApi.changes().id(changeId).get().messages;
+    assertThat(Iterables.getLast(messages).message).isEqualTo("Patch Set 1: Code-Review+1");
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.enableImplicitApprovals", value = "true")
+  public void changeMessageListsNewlyApprovedPathsIfTheyWereAlreadyImplicitlyApproved()
+      throws Exception {
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/foo/")
+        .addCodeOwnerEmail(admin.email())
+        .create();
+
+    String path = "foo/bar.baz";
+    String changeId = createChange("Test Change", path, "file content").getChangeId();
+
+    recommend(changeId);
+
+    Collection<ChangeMessageInfo> messages = gApi.changes().id(changeId).get().messages;
+    assertThat(Iterables.getLast(messages).message)
+        .isEqualTo(
+            String.format(
+                "Patch Set 1: Code-Review+1\n\n"
+                    + "By voting Code-Review+1 the following files are now explicitly code-owner"
+                    + " approved by %s:\n"
+                    + "* %s\n",
+                admin.fullName(), path));
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.enableImplicitApprovals", value = "true")
+  public void changeMessageListsPathsThatAreNoLongerExplicitlyApproved_voteRemoved()
+      throws Exception {
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/foo/")
+        .addCodeOwnerEmail(admin.email())
+        .create();
+
+    String path = "foo/bar.baz";
+    String changeId = createChange("Test Change", path, "file content").getChangeId();
+
+    recommend(changeId);
+
+    // Remove the approval.
+    gApi.changes().id(changeId).current().review(new ReviewInput().label("Code-Review", 0));
+
+    Collection<ChangeMessageInfo> messages = gApi.changes().id(changeId).get().messages;
+    assertThat(Iterables.getLast(messages).message)
+        .isEqualTo(
+            String.format(
+                "Patch Set 1: -Code-Review\n\n"
+                    + "By removing the Code-Review vote the following files are no longer"
+                    + " explicitly code-owner approved by %s:\n"
+                    + "* %s\n"
+                    + "\n"
+                    + "The listed files are still implicitly approved by %s.\n",
+                admin.fullName(), path, admin.fullName()));
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.enableImplicitApprovals", value = "true")
+  public void changeMessageListsPathsThatAreNoLongerExplicitlyApproved_voteChangedToNegativeValue()
+      throws Exception {
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/foo/")
+        .addCodeOwnerEmail(admin.email())
+        .create();
+
+    String path = "foo/bar.baz";
+    String changeId = createChange("Test Change", path, "file content").getChangeId();
+
+    recommend(changeId);
+
+    // Vote with a negative value.
+    gApi.changes().id(changeId).current().review(new ReviewInput().label("Code-Review", -1));
+
+    Collection<ChangeMessageInfo> messages = gApi.changes().id(changeId).get().messages;
+    assertThat(Iterables.getLast(messages).message)
+        .isEqualTo(
+            String.format(
+                "Patch Set 1: Code-Review-1\n\n"
+                    + "By voting Code-Review-1 the following files are no longer explicitly"
+                    + " code-owner approved by %s:\n"
+                    + "* %s\n"
+                    + "\n"
+                    + "The listed files are still implicitly approved by %s.\n",
+                admin.fullName(), path, admin.fullName()));
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.enableImplicitApprovals", value = "true")
+  public void changeMessageListsNewlyApprovedPaths_noImplicitApprovalButImplicitApprovalsEnabled()
+      throws Exception {
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/foo/")
+        .addCodeOwnerEmail(admin.email())
+        .create();
+
+    String path = "foo/bar.baz";
+    String changeId = createChange(user, "Test Change", path, "file content").getChangeId();
+
+    recommend(changeId);
+
+    Collection<ChangeMessageInfo> messages = gApi.changes().id(changeId).get().messages;
+    assertThat(Iterables.getLast(messages).message)
+        .isEqualTo(
+            String.format(
+                "Patch Set 1: Code-Review+1\n\n"
+                    + "By voting Code-Review+1 the following files are now code-owner approved by"
+                    + " %s:\n"
+                    + "* %s\n",
+                admin.fullName(), path));
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.enableImplicitApprovals", value = "true")
+  public void
+      changeMessageListsPathsThatAreNoLongerApproved_voteRemoved_noImplicitApprovalButImplicitApprovalsEnabled()
+          throws Exception {
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/foo/")
+        .addCodeOwnerEmail(admin.email())
+        .create();
+
+    String path = "foo/bar.baz";
+    String changeId = createChange(user, "Test Change", path, "file content").getChangeId();
+
+    recommend(changeId);
+
+    // Remove the approval.
+    gApi.changes().id(changeId).current().review(new ReviewInput().label("Code-Review", 0));
+
+    Collection<ChangeMessageInfo> messages = gApi.changes().id(changeId).get().messages;
+    assertThat(Iterables.getLast(messages).message)
+        .isEqualTo(
+            String.format(
+                "Patch Set 1: -Code-Review\n\n"
+                    + "By removing the Code-Review vote the following files are no longer"
+                    + " code-owner approved by %s:\n"
+                    + "* %s\n",
+                admin.fullName(), path));
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.enableImplicitApprovals", value = "true")
+  public void
+      changeMessageListsPathsThatAreNoLongerApproved_voteChangedToNegativeValue_noImplicitApprovalButImplicitApprovalsEnabled()
+          throws Exception {
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/foo/")
+        .addCodeOwnerEmail(admin.email())
+        .create();
+
+    String path = "foo/bar.baz";
+    String changeId = createChange(user, "Test Change", path, "file content").getChangeId();
+
+    recommend(changeId);
+
+    // Vote with a negative value.
+    gApi.changes().id(changeId).current().review(new ReviewInput().label("Code-Review", -1));
+
+    Collection<ChangeMessageInfo> messages = gApi.changes().id(changeId).get().messages;
+    assertThat(Iterables.getLast(messages).message)
+        .isEqualTo(
+            String.format(
+                "Patch Set 1: Code-Review-1\n\n"
+                    + "By voting Code-Review-1 the following files are no longer code-owner"
+                    + " approved by %s:\n"
+                    + "* %s\n",
+                admin.fullName(), path));
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.maxPathsInChangeMessages", value = "4")
+  public void pathsInChangeMessageAreLimited_limitNotReached() throws Exception {
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/")
+        .addCodeOwnerEmail(admin.email())
+        .create();
+
+    String path1 = "foo/bar.baz";
+    String path2 = "foo/baz.bar";
+    String path3 = "bar/foo.baz";
+    String path4 = "bar/baz.foo";
+    String changeId =
+        createChange(
+                "Test Change",
+                ImmutableMap.of(
+                    path1,
+                    "file content",
+                    path2,
+                    "file content",
+                    path3,
+                    "file content",
+                    path4,
+                    "file content"))
+            .getChangeId();
+
+    recommend(changeId);
+
+    Collection<ChangeMessageInfo> messages = gApi.changes().id(changeId).get().messages;
+    assertThat(Iterables.getLast(messages).message)
+        .isEqualTo(
+            String.format(
+                "Patch Set 1: Code-Review+1\n\n"
+                    + "By voting Code-Review+1 the following files are now code-owner approved by"
+                    + " %s:\n"
+                    + "* %s\n"
+                    + "* %s\n"
+                    + "* %s\n"
+                    + "* %s\n",
+                admin.fullName(), path4, path3, path1, path2));
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.maxPathsInChangeMessages", value = "3")
+  public void pathsInChangeMessageAreLimited_limitReached() throws Exception {
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/")
+        .addCodeOwnerEmail(admin.email())
+        .create();
+
+    String path1 = "foo/bar.baz";
+    String path2 = "foo/baz.bar";
+    String path3 = "bar/foo.baz";
+    String path4 = "bar/baz.foo";
+    String changeId =
+        createChange(
+                "Test Change",
+                ImmutableMap.of(
+                    path1,
+                    "file content",
+                    path2,
+                    "file content",
+                    path3,
+                    "file content",
+                    path4,
+                    "file content"))
+            .getChangeId();
+
+    recommend(changeId);
+
+    Collection<ChangeMessageInfo> messages = gApi.changes().id(changeId).get().messages;
+    assertThat(Iterables.getLast(messages).message)
+        .isEqualTo(
+            String.format(
+                "Patch Set 1: Code-Review+1\n\n"
+                    + "By voting Code-Review+1 the following files are now code-owner approved by"
+                    + " %s:\n"
+                    + "* %s\n"
+                    + "* %s\n"
+                    + "(2 more files)\n",
+                admin.fullName(), path4, path3));
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.maxPathsInChangeMessages", value = "0")
+  public void pathsInChangeMessagesDisabled() throws Exception {
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/")
+        .addCodeOwnerEmail(admin.email())
+        .create();
+
+    String changeId =
+        createChange(
+                "Test Change",
+                ImmutableMap.of(
+                    "foo/bar.baz",
+                    "file content",
+                    "foo/baz.bar",
+                    "file content",
+                    "bar/foo.baz",
+                    "file content",
+                    "bar/baz.foo",
+                    "file content"))
+            .getChangeId();
+
+    recommend(changeId);
+
+    Collection<ChangeMessageInfo> messages = gApi.changes().id(changeId).get().messages;
+    assertThat(Iterables.getLast(messages).message).isEqualTo("Patch Set 1: Code-Review+1");
+  }
+}
diff --git a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CodeOwnersPluginConfigValidatorIT.java b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CodeOwnersPluginConfigValidatorIT.java
index 94bd27d..e22062e 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CodeOwnersPluginConfigValidatorIT.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CodeOwnersPluginConfigValidatorIT.java
@@ -17,12 +17,16 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.acceptance.GitUtil.fetch;
 import static com.google.gerrit.acceptance.GitUtil.pushHead;
+import static com.google.gerrit.plugins.codeowners.testing.RequiredApprovalSubject.assertThat;
 
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.plugins.codeowners.acceptance.AbstractCodeOwnersIT;
 import com.google.gerrit.plugins.codeowners.api.MergeCommitStrategy;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerBackendId;
+import com.google.gerrit.plugins.codeowners.backend.FallbackCodeOwners;
 import com.google.gerrit.plugins.codeowners.backend.proto.ProtoBackend;
 import com.google.gerrit.plugins.codeowners.config.BackendConfig;
 import com.google.gerrit.plugins.codeowners.config.CodeOwnersPluginConfiguration;
@@ -31,11 +35,12 @@
 import com.google.gerrit.plugins.codeowners.config.RequiredApproval;
 import com.google.gerrit.plugins.codeowners.config.RequiredApprovalConfig;
 import com.google.gerrit.plugins.codeowners.config.StatusConfig;
-import java.util.Optional;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevObject;
 import org.eclipse.jgit.transport.PushResult;
 import org.eclipse.jgit.transport.RemoteRefUpdate.Status;
+import org.eclipse.jgit.util.RawParseUtils;
 import org.junit.Before;
 import org.junit.Test;
 
@@ -75,11 +80,9 @@
     Config cfg = new Config();
     cfg.setBoolean(
         CodeOwnersPluginConfiguration.SECTION_CODE_OWNERS,
-        /** subsection = */
-        null,
+        /* subsection= */ null,
         StatusConfig.KEY_DISABLED,
-        /** value = */
-        true);
+        /* value= */ true);
     setCodeOwnersConfig(cfg);
 
     PushResult r = pushRefsMetaConfig();
@@ -94,8 +97,7 @@
     Config cfg = new Config();
     cfg.setString(
         CodeOwnersPluginConfiguration.SECTION_CODE_OWNERS,
-        /** subsection = */
-        null,
+        /* subsection= */ null,
         StatusConfig.KEY_DISABLED_BRANCH,
         "refs/heads/master");
     setCodeOwnersConfig(cfg);
@@ -113,8 +115,7 @@
     Config cfg = new Config();
     cfg.setString(
         CodeOwnersPluginConfiguration.SECTION_CODE_OWNERS,
-        /** subsection = */
-        null,
+        /* subsection= */ null,
         StatusConfig.KEY_DISABLED,
         "INVALID");
     setCodeOwnersConfig(cfg);
@@ -135,8 +136,7 @@
     Config cfg = new Config();
     cfg.setString(
         CodeOwnersPluginConfiguration.SECTION_CODE_OWNERS,
-        /** subsection = */
-        null,
+        /* subsection= */ null,
         StatusConfig.KEY_DISABLED_BRANCH,
         "^refs/heads/[");
     setCodeOwnersConfig(cfg);
@@ -157,8 +157,7 @@
     Config cfg = new Config();
     cfg.setString(
         CodeOwnersPluginConfiguration.SECTION_CODE_OWNERS,
-        /** subsection = */
-        null,
+        /* subsection= */ null,
         BackendConfig.KEY_BACKEND,
         CodeOwnerBackendId.PROTO.getBackendId());
     setCodeOwnersConfig(cfg);
@@ -194,8 +193,7 @@
     Config cfg = new Config();
     cfg.setString(
         CodeOwnersPluginConfiguration.SECTION_CODE_OWNERS,
-        /** subsection = */
-        null,
+        /* subsection= */ null,
         BackendConfig.KEY_BACKEND,
         "INVALID");
     setCodeOwnersConfig(cfg);
@@ -237,8 +235,7 @@
     Config cfg = new Config();
     cfg.setString(
         CodeOwnersPluginConfiguration.SECTION_CODE_OWNERS,
-        /** subsection = */
-        null,
+        /* subsection= */ null,
         RequiredApprovalConfig.KEY_REQUIRED_APPROVAL,
         "Code-Review+2");
     setCodeOwnersConfig(cfg);
@@ -246,8 +243,8 @@
     PushResult r = pushRefsMetaConfig();
     assertThat(r.getRemoteUpdate(RefNames.REFS_CONFIG).getStatus()).isEqualTo(Status.OK);
     RequiredApproval requiredApproval = codeOwnersPluginConfiguration.getRequiredApproval(project);
-    assertThat(requiredApproval.labelType().getName()).isEqualTo("Code-Review");
-    assertThat(requiredApproval.value()).isEqualTo(2);
+    assertThat(requiredApproval).hasLabelNameThat().isEqualTo("Code-Review");
+    assertThat(requiredApproval).hasValueThat().isEqualTo(2);
   }
 
   @Test
@@ -257,8 +254,7 @@
     Config cfg = new Config();
     cfg.setString(
         CodeOwnersPluginConfiguration.SECTION_CODE_OWNERS,
-        /** subsection = */
-        null,
+        /* subsection= */ null,
         RequiredApprovalConfig.KEY_REQUIRED_APPROVAL,
         "INVALID");
     setCodeOwnersConfig(cfg);
@@ -276,24 +272,51 @@
   }
 
   @Test
+  public void allRequiredApprovalsAreValidated() throws Exception {
+    fetchRefsMetaConfig();
+
+    ImmutableList<String> invalidValues = ImmutableList.of("INVALID", "ALSO_INVALID");
+    Config cfg = new Config();
+    cfg.setStringList(
+        CodeOwnersPluginConfiguration.SECTION_CODE_OWNERS,
+        /* subsection= */ null,
+        RequiredApprovalConfig.KEY_REQUIRED_APPROVAL,
+        invalidValues);
+    setCodeOwnersConfig(cfg);
+
+    PushResult r = pushRefsMetaConfig();
+    assertThat(r.getRemoteUpdate(RefNames.REFS_CONFIG).getStatus())
+        .isEqualTo(Status.REJECTED_OTHER_REASON);
+    for (String invalidValue : invalidValues) {
+      assertThat(r.getMessages())
+          .contains(
+              String.format(
+                  "Required approval '%s' that is configured in code-owners.config (parameter"
+                      + " codeOwners.%s) is invalid: Invalid format, expected"
+                      + " '<label-name>+<label-value>'.",
+                  invalidValue, RequiredApprovalConfig.KEY_REQUIRED_APPROVAL));
+    }
+  }
+
+  @Test
   public void configureOverrideApproval() throws Exception {
     fetchRefsMetaConfig();
 
     Config cfg = new Config();
     cfg.setString(
         CodeOwnersPluginConfiguration.SECTION_CODE_OWNERS,
-        /** subsection = */
-        null,
+        /* subsection= */ null,
         OverrideApprovalConfig.KEY_OVERRIDE_APPROVAL,
         "Code-Review+2");
     setCodeOwnersConfig(cfg);
 
     PushResult r = pushRefsMetaConfig();
     assertThat(r.getRemoteUpdate(RefNames.REFS_CONFIG).getStatus()).isEqualTo(Status.OK);
-    Optional<RequiredApproval> overrideApproval =
+    ImmutableSet<RequiredApproval> overrideApproval =
         codeOwnersPluginConfiguration.getOverrideApproval(project);
-    assertThat(overrideApproval.get().labelType().getName()).isEqualTo("Code-Review");
-    assertThat(overrideApproval.get().value()).isEqualTo(2);
+    assertThat(overrideApproval).hasSize(1);
+    assertThat(overrideApproval).element(0).hasLabelNameThat().isEqualTo("Code-Review");
+    assertThat(overrideApproval).element(0).hasValueThat().isEqualTo(2);
   }
 
   @Test
@@ -303,8 +326,7 @@
     Config cfg = new Config();
     cfg.setString(
         CodeOwnersPluginConfiguration.SECTION_CODE_OWNERS,
-        /** subsection = */
-        null,
+        /* subsection= */ null,
         OverrideApprovalConfig.KEY_OVERRIDE_APPROVAL,
         "INVALID");
     setCodeOwnersConfig(cfg);
@@ -322,14 +344,86 @@
   }
 
   @Test
+  public void allOverrideApprovalsAreValidated() throws Exception {
+    fetchRefsMetaConfig();
+
+    ImmutableList<String> invalidValues = ImmutableList.of("INVALID", "ALSO_INVALID");
+    Config cfg = new Config();
+    cfg.setStringList(
+        CodeOwnersPluginConfiguration.SECTION_CODE_OWNERS,
+        /* subsection= */ null,
+        OverrideApprovalConfig.KEY_OVERRIDE_APPROVAL,
+        invalidValues);
+    setCodeOwnersConfig(cfg);
+
+    PushResult r = pushRefsMetaConfig();
+    assertThat(r.getRemoteUpdate(RefNames.REFS_CONFIG).getStatus())
+        .isEqualTo(Status.REJECTED_OTHER_REASON);
+    for (String invalidValue : invalidValues) {
+      assertThat(r.getMessages())
+          .contains(
+              String.format(
+                  "Required approval '%s' that is configured in code-owners.config (parameter"
+                      + " codeOwners.%s) is invalid: Invalid format, expected"
+                      + " '<label-name>+<label-value>'.",
+                  invalidValue, OverrideApprovalConfig.KEY_OVERRIDE_APPROVAL));
+    }
+  }
+
+  @Test
+  public void defineAndConfigureOverrideLabelInSameCommit() throws Exception {
+    fetchRefsMetaConfig();
+
+    RevCommit head = getHead(testRepo.getRepository(), RefNames.REFS_CONFIG);
+    RevObject blob = testRepo.get(head.getTree(), "project.config");
+    byte[] data = testRepo.getRepository().open(blob).getCachedBytes(Integer.MAX_VALUE);
+    String projectConfigText = RawParseUtils.decode(data);
+
+    Config projectConfig = new Config();
+    projectConfig.fromText(projectConfigText);
+    String labelName = "Owners-Override";
+    projectConfig.setString("label", labelName, "function", "NoOp");
+    projectConfig.setStringList(
+        "label", labelName, "value", ImmutableList.of("0 Not Override", "+1 Override"));
+
+    Config codeOwnersConfig = new Config();
+    codeOwnersConfig.setString(
+        CodeOwnersPluginConfiguration.SECTION_CODE_OWNERS,
+        /* subsection= */ null,
+        OverrideApprovalConfig.KEY_OVERRIDE_APPROVAL,
+        "Owners-Override+1");
+
+    RevCommit commit =
+        testRepo.update(
+            RefNames.REFS_CONFIG,
+            testRepo
+                .commit()
+                .parent(head)
+                .message("Add test code owner config")
+                .author(admin.newIdent())
+                .committer(admin.newIdent())
+                .add("code-owners.config", codeOwnersConfig.toText())
+                .add("project.config", projectConfig.toText()));
+
+    testRepo.reset(commit);
+
+    PushResult r = pushRefsMetaConfig();
+    assertThat(r.getRemoteUpdate(RefNames.REFS_CONFIG).getStatus()).isEqualTo(Status.OK);
+    ImmutableSet<RequiredApproval> overrideApproval =
+        codeOwnersPluginConfiguration.getOverrideApproval(project);
+    assertThat(overrideApproval).hasSize(1);
+    assertThat(overrideApproval).element(0).hasLabelNameThat().isEqualTo("Owners-Override");
+    assertThat(overrideApproval).element(0).hasValueThat().isEqualTo(1);
+  }
+
+  @Test
   public void configureMergeCommitStrategy() throws Exception {
     fetchRefsMetaConfig();
 
     Config cfg = new Config();
     cfg.setEnum(
         CodeOwnersPluginConfiguration.SECTION_CODE_OWNERS,
-        /** subsection = */
-        null,
+        /* subsection= */ null,
         GeneralConfig.KEY_MERGE_COMMIT_STRATEGY,
         MergeCommitStrategy.ALL_CHANGED_FILES);
     setCodeOwnersConfig(cfg);
@@ -347,8 +441,7 @@
     Config cfg = new Config();
     cfg.setString(
         CodeOwnersPluginConfiguration.SECTION_CODE_OWNERS,
-        /** subsection = */
-        null,
+        /* subsection= */ null,
         GeneralConfig.KEY_MERGE_COMMIT_STRATEGY,
         "INVALID");
     setCodeOwnersConfig(cfg);
@@ -362,6 +455,83 @@
                 + " (parameter codeOwners.mergeCommitStrategy) is invalid.");
   }
 
+  @Test
+  public void configureFallbackCodeOwners() throws Exception {
+    fetchRefsMetaConfig();
+
+    Config cfg = new Config();
+    cfg.setEnum(
+        CodeOwnersPluginConfiguration.SECTION_CODE_OWNERS,
+        /* subsection= */ null,
+        GeneralConfig.KEY_FALLBACK_CODE_OWNERS,
+        FallbackCodeOwners.ALL_USERS);
+    setCodeOwnersConfig(cfg);
+
+    PushResult r = pushRefsMetaConfig();
+    assertThat(r.getRemoteUpdate(RefNames.REFS_CONFIG).getStatus()).isEqualTo(Status.OK);
+    assertThat(codeOwnersPluginConfiguration.getFallbackCodeOwners(project))
+        .isEqualTo(FallbackCodeOwners.ALL_USERS);
+  }
+
+  @Test
+  public void cannotSetInvalidFallbackCodeOwners() throws Exception {
+    fetchRefsMetaConfig();
+
+    Config cfg = new Config();
+    cfg.setString(
+        CodeOwnersPluginConfiguration.SECTION_CODE_OWNERS,
+        /* subsection= */ null,
+        GeneralConfig.KEY_FALLBACK_CODE_OWNERS,
+        "INVALID");
+    setCodeOwnersConfig(cfg);
+
+    PushResult r = pushRefsMetaConfig();
+    assertThat(r.getRemoteUpdate(RefNames.REFS_CONFIG).getStatus())
+        .isEqualTo(Status.REJECTED_OTHER_REASON);
+    assertThat(r.getMessages())
+        .contains(
+            "The value for fallback code owners 'INVALID' that is configured in code-owners.config"
+                + " (parameter codeOwners.fallbackCodeOwners) is invalid.");
+  }
+
+  @Test
+  public void configureMaxPathsInChangeMessages() throws Exception {
+    fetchRefsMetaConfig();
+
+    Config cfg = new Config();
+    cfg.setInt(
+        CodeOwnersPluginConfiguration.SECTION_CODE_OWNERS,
+        /* subsection= */ null,
+        GeneralConfig.KEY_MAX_PATHS_IN_CHANGE_MESSAGES,
+        50);
+    setCodeOwnersConfig(cfg);
+
+    PushResult r = pushRefsMetaConfig();
+    assertThat(r.getRemoteUpdate(RefNames.REFS_CONFIG).getStatus()).isEqualTo(Status.OK);
+    assertThat(codeOwnersPluginConfiguration.getMaxPathsInChangeMessages(project)).isEqualTo(50);
+  }
+
+  @Test
+  public void cannotSetInvalidMaxPathsInChangeMessages() throws Exception {
+    fetchRefsMetaConfig();
+
+    Config cfg = new Config();
+    cfg.setString(
+        CodeOwnersPluginConfiguration.SECTION_CODE_OWNERS,
+        /* subsection= */ null,
+        GeneralConfig.KEY_MAX_PATHS_IN_CHANGE_MESSAGES,
+        "INVALID");
+    setCodeOwnersConfig(cfg);
+
+    PushResult r = pushRefsMetaConfig();
+    assertThat(r.getRemoteUpdate(RefNames.REFS_CONFIG).getStatus())
+        .isEqualTo(Status.REJECTED_OTHER_REASON);
+    assertThat(r.getMessages())
+        .contains(
+            "The value for max paths in change messages 'INVALID' that is configured in"
+                + " code-owners.config (parameter codeOwners.maxPathsInChangeMessages) is invalid.");
+  }
+
   private void fetchRefsMetaConfig() throws Exception {
     fetch(testRepo, RefNames.REFS_CONFIG + ":" + RefNames.REFS_CONFIG);
     testRepo.reset(RefNames.REFS_CONFIG);
diff --git a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/GetCodeOwnerBranchConfigIT.java b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/GetCodeOwnerBranchConfigIT.java
index 37d0d23..a838795 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/GetCodeOwnerBranchConfigIT.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/GetCodeOwnerBranchConfigIT.java
@@ -17,10 +17,10 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Project;
-import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.projects.ConfigInput;
 import com.google.gerrit.extensions.client.ProjectState;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
@@ -29,17 +29,12 @@
 import com.google.gerrit.plugins.codeowners.api.MergeCommitStrategy;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerBackend;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerBackendId;
+import com.google.gerrit.plugins.codeowners.backend.FallbackCodeOwners;
 import com.google.gerrit.plugins.codeowners.config.BackendConfig;
-import com.google.gerrit.plugins.codeowners.config.CodeOwnersPluginConfiguration;
 import com.google.gerrit.plugins.codeowners.config.GeneralConfig;
 import com.google.gerrit.plugins.codeowners.config.OverrideApprovalConfig;
 import com.google.gerrit.plugins.codeowners.config.RequiredApprovalConfig;
 import com.google.gerrit.plugins.codeowners.config.StatusConfig;
-import org.eclipse.jgit.junit.TestRepository;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
 import org.junit.Before;
 import org.junit.Test;
 
@@ -88,6 +83,8 @@
     assertThat(codeOwnerBranchConfigInfo.general.fileExtension).isNull();
     assertThat(codeOwnerBranchConfigInfo.general.mergeCommitStrategy)
         .isEqualTo(MergeCommitStrategy.ALL_CHANGED_FILES);
+    assertThat(codeOwnerBranchConfigInfo.general.fallbackCodeOwners)
+        .isEqualTo(FallbackCodeOwners.NONE);
     assertThat(codeOwnerBranchConfigInfo.general.implicitApprovals).isNull();
     assertThat(codeOwnerBranchConfigInfo.general.overrideInfoUrl).isNull();
     assertThat(codeOwnerBranchConfigInfo.disabled).isNull();
@@ -128,6 +125,15 @@
   }
 
   @Test
+  public void getConfigWithConfiguredFallbackCodeOwners() throws Exception {
+    configureFallbackCodeOwners(project, FallbackCodeOwners.ALL_USERS);
+    CodeOwnerBranchConfigInfo codeOwnerBranchConfigInfo =
+        projectCodeOwnersApiFactory.project(project).branch("master").getConfig();
+    assertThat(codeOwnerBranchConfigInfo.general.fallbackCodeOwners)
+        .isEqualTo(FallbackCodeOwners.ALL_USERS);
+  }
+
+  @Test
   public void getConfigForBranchOfDisabledProject() throws Exception {
     disableCodeOwnersForProject(project);
     CodeOwnerBranchConfigInfo codeOwnerBranchConfigInfo =
@@ -209,8 +215,27 @@
     configureOverrideApproval(project, "Code-Review+2");
     CodeOwnerBranchConfigInfo codeOwnerBranchConfigInfo =
         projectCodeOwnersApiFactory.project(project).branch("master").getConfig();
-    assertThat(codeOwnerBranchConfigInfo.overrideApproval.label).isEqualTo("Code-Review");
-    assertThat(codeOwnerBranchConfigInfo.overrideApproval.value).isEqualTo(2);
+    assertThat(codeOwnerBranchConfigInfo.overrideApproval).hasSize(1);
+    assertThat(codeOwnerBranchConfigInfo.overrideApproval.get(0).label).isEqualTo("Code-Review");
+    assertThat(codeOwnerBranchConfigInfo.overrideApproval.get(0).value).isEqualTo(2);
+  }
+
+  @Test
+  public void getConfigWithMultipleConfiguredOverrideApproval() throws Exception {
+    createOwnersOverrideLabel();
+    setCodeOwnersConfig(
+        project,
+        /* subsection= */ null,
+        OverrideApprovalConfig.KEY_OVERRIDE_APPROVAL,
+        ImmutableList.of("Owners-Override+1", "Code-Review+2"));
+    CodeOwnerBranchConfigInfo codeOwnerBranchConfigInfo =
+        projectCodeOwnersApiFactory.project(project).branch("master").getConfig();
+    assertThat(codeOwnerBranchConfigInfo.overrideApproval).hasSize(2);
+    assertThat(codeOwnerBranchConfigInfo.overrideApproval.get(0).label).isEqualTo("Code-Review");
+    assertThat(codeOwnerBranchConfigInfo.overrideApproval.get(0).value).isEqualTo(2);
+    assertThat(codeOwnerBranchConfigInfo.overrideApproval.get(1).label)
+        .isEqualTo("Owners-Override");
+    assertThat(codeOwnerBranchConfigInfo.overrideApproval.get(1).value).isEqualTo(1);
   }
 
   @Test
@@ -231,65 +256,70 @@
 
   private void configureFileExtension(Project.NameKey project, String fileExtension)
       throws Exception {
-    setConfig(project, null, GeneralConfig.KEY_FILE_EXTENSION, fileExtension);
+    setCodeOwnersConfig(
+        project, /* subsection= */ null, GeneralConfig.KEY_FILE_EXTENSION, fileExtension);
   }
 
   private void configureOverrideInfoUrl(Project.NameKey project, String overrideInfoUrl)
       throws Exception {
-    setConfig(project, null, GeneralConfig.KEY_OVERRIDE_INFO_URL, overrideInfoUrl);
+    setCodeOwnersConfig(
+        project, /* subsection= */ null, GeneralConfig.KEY_OVERRIDE_INFO_URL, overrideInfoUrl);
   }
 
   private void configureMergeCommitStrategy(
       Project.NameKey project, MergeCommitStrategy mergeCommitStrategy) throws Exception {
-    setConfig(project, null, GeneralConfig.KEY_MERGE_COMMIT_STRATEGY, mergeCommitStrategy.name());
+    setCodeOwnersConfig(
+        project,
+        /* subsection= */ null,
+        GeneralConfig.KEY_MERGE_COMMIT_STRATEGY,
+        mergeCommitStrategy.name());
+  }
+
+  private void configureFallbackCodeOwners(
+      Project.NameKey project, FallbackCodeOwners fallbackCodeOwners) throws Exception {
+    setCodeOwnersConfig(
+        project,
+        /* subsection= */ null,
+        GeneralConfig.KEY_FALLBACK_CODE_OWNERS,
+        fallbackCodeOwners.name());
   }
 
   private void configureDisabledBranch(Project.NameKey project, String disabledBranch)
       throws Exception {
-    setCodeOwnersConfig(project, null, StatusConfig.KEY_DISABLED_BRANCH, disabledBranch);
+    setCodeOwnersConfig(
+        project, /* subsection= */ null, StatusConfig.KEY_DISABLED_BRANCH, disabledBranch);
   }
 
   private void configureBackend(Project.NameKey project, String backendName) throws Exception {
-    configureBackend(project, null, backendName);
+    configureBackend(project, /* branch= */ null, backendName);
   }
 
   private void configureBackend(
       Project.NameKey project, @Nullable String branch, String backendName) throws Exception {
-    setConfig(project, branch, BackendConfig.KEY_BACKEND, backendName);
+    setCodeOwnersConfig(project, branch, BackendConfig.KEY_BACKEND, backendName);
   }
 
   private void configureRequiredApproval(Project.NameKey project, String requiredApproval)
       throws Exception {
-    setConfig(project, null, RequiredApprovalConfig.KEY_REQUIRED_APPROVAL, requiredApproval);
+    setCodeOwnersConfig(
+        project,
+        /* subsection= */ null,
+        RequiredApprovalConfig.KEY_REQUIRED_APPROVAL,
+        requiredApproval);
   }
 
   private void configureOverrideApproval(Project.NameKey project, String overrideApproval)
       throws Exception {
-    setConfig(project, null, OverrideApprovalConfig.KEY_OVERRIDE_APPROVAL, overrideApproval);
+    setCodeOwnersConfig(
+        project,
+        /* subsection= */ null,
+        OverrideApprovalConfig.KEY_OVERRIDE_APPROVAL,
+        overrideApproval);
   }
 
   private void configureImplicitApprovals(Project.NameKey project) throws Exception {
-    setConfig(project, null, GeneralConfig.KEY_ENABLE_IMPLICIT_APPROVALS, "true");
-  }
-
-  private void setConfig(Project.NameKey project, String subsection, String key, String value)
-      throws Exception {
-    Config codeOwnersConfig = new Config();
-    codeOwnersConfig.setString(
-        CodeOwnersPluginConfiguration.SECTION_CODE_OWNERS, subsection, key, value);
-    try (TestRepository<Repository> testRepo =
-        new TestRepository<>(repoManager.openRepository(project))) {
-      Ref ref = testRepo.getRepository().exactRef(RefNames.REFS_CONFIG);
-      RevCommit head = testRepo.getRevWalk().parseCommit(ref.getObjectId());
-      testRepo.update(
-          RefNames.REFS_CONFIG,
-          testRepo
-              .commit()
-              .parent(head)
-              .message("Configure code owner backend")
-              .add("code-owners.config", codeOwnersConfig.toText()));
-    }
-    projectCache.evict(project);
+    setCodeOwnersConfig(
+        project, /* subsection= */ null, GeneralConfig.KEY_ENABLE_IMPLICIT_APPROVALS, "true");
   }
 
   /** Returns the ID of a code owner backend that is not the given backend. */
diff --git a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/GetCodeOwnerConfigFilesIT.java b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/GetCodeOwnerConfigFilesIT.java
index 50b795b..c17e4e2 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/GetCodeOwnerConfigFilesIT.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/GetCodeOwnerConfigFilesIT.java
@@ -15,9 +15,11 @@
 package com.google.gerrit.plugins.codeowners.acceptance.api;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.plugins.codeowners.JgitPath;
 import com.google.gerrit.plugins.codeowners.acceptance.AbstractCodeOwnersIT;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerBackend;
@@ -204,7 +206,7 @@
 
   @Test
   public void getCodeOwnerConfigFilesIfInvalidCodeOwnerConfigFilesExist() throws Exception {
-    createInvalidCodeOwnerConfig(getCodeOwnerConfigFileName());
+    createNonParseableCodeOwnerConfig(getCodeOwnerConfigFileName());
 
     CodeOwnerConfig.Key codeOwnerConfigKey =
         codeOwnerConfigOperations
@@ -226,6 +228,50 @@
   }
 
   @Test
+  public void includeInvalidCodeOwnerConfigFiles() throws Exception {
+    String nameOfInvalidCodeOwnerConfigFile = getCodeOwnerConfigFileName();
+    createNonParseableCodeOwnerConfig(nameOfInvalidCodeOwnerConfigFile);
+
+    CodeOwnerConfig.Key codeOwnerConfigKey =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(project)
+            .branch("master")
+            .folderPath("/foo/")
+            .addCodeOwnerEmail(admin.email())
+            .create();
+
+    assertThat(
+            projectCodeOwnersApiFactory
+                .project(project)
+                .branch("master")
+                .codeOwnerConfigFiles()
+                .includeNonParsableFiles(true)
+                .paths())
+        .containsExactly(
+            JgitPath.of(nameOfInvalidCodeOwnerConfigFile).getAsAbsolutePath().toString(),
+            codeOwnerConfigOperations.codeOwnerConfig(codeOwnerConfigKey).getFilePath());
+  }
+
+  @Test
+  public void includeNonParsableFilesAndEmailOptionsAreMutuallyExclusive() throws Exception {
+    BadRequestException exception =
+        assertThrows(
+            BadRequestException.class,
+            () ->
+                projectCodeOwnersApiFactory
+                    .project(project)
+                    .branch("master")
+                    .codeOwnerConfigFiles()
+                    .includeNonParsableFiles(true)
+                    .withEmail(admin.email())
+                    .paths());
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo("the options 'email' and 'include-non-parsable-files' are mutually exclusive");
+  }
+
+  @Test
   public void filterByEmail() throws Exception {
     TestAccount user2 = accountCreator.user2();
     TestAccount user3 = accountCreator.create("user3", "user3@example.com", "User3", null);
@@ -327,6 +373,68 @@
         .isEmpty();
   }
 
+  @Test
+  public void getCodeOwnerConfigFilesWithPathGlob() throws Exception {
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/")
+        .addCodeOwnerEmail(user.email())
+        .create();
+
+    CodeOwnerConfig.Key codeOwnerConfigKey2 =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(project)
+            .branch("master")
+            .folderPath("/foo/")
+            .addCodeOwnerEmail(admin.email())
+            .create();
+
+    CodeOwnerConfig.Key codeOwnerConfigKey3 =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(project)
+            .branch("master")
+            .folderPath("/foo/bar/")
+            .addCodeOwnerEmail(user.email())
+            .create();
+
+    assertThat(
+            projectCodeOwnersApiFactory
+                .project(project)
+                .branch("master")
+                .codeOwnerConfigFiles()
+                .withPath("/foo/bar/" + getCodeOwnerConfigFileName())
+                .paths())
+        .containsExactly(
+            codeOwnerConfigOperations.codeOwnerConfig(codeOwnerConfigKey3).getFilePath());
+
+    assertThat(
+            projectCodeOwnersApiFactory
+                .project(project)
+                .branch("master")
+                .codeOwnerConfigFiles()
+                .withPath("/foo/bar/*")
+                .paths())
+        .containsExactly(
+            codeOwnerConfigOperations.codeOwnerConfig(codeOwnerConfigKey3).getFilePath())
+        .inOrder();
+
+    assertThat(
+            projectCodeOwnersApiFactory
+                .project(project)
+                .branch("master")
+                .codeOwnerConfigFiles()
+                .withPath("/foo/**")
+                .paths())
+        .containsExactly(
+            codeOwnerConfigOperations.codeOwnerConfig(codeOwnerConfigKey2).getFilePath(),
+            codeOwnerConfigOperations.codeOwnerConfig(codeOwnerConfigKey3).getFilePath())
+        .inOrder();
+  }
+
   private String getCodeOwnerConfigFileName() {
     CodeOwnerBackend backend = backendConfig.getDefaultBackend();
     if (backend instanceof FindOwnersBackend) {
@@ -336,14 +444,4 @@
     }
     throw new IllegalStateException("unknown code owner backend: " + backend.getClass().getName());
   }
-
-  private void createInvalidCodeOwnerConfig(String path) throws Exception {
-    disableCodeOwnersForProject(project);
-    String changeId =
-        createChange("Add invalid code owners file", JgitPath.of(path).get(), "INVALID")
-            .getChangeId();
-    approve(changeId);
-    gApi.changes().id(changeId).current().submit();
-    enableCodeOwnersForProject(project);
-  }
 }
diff --git a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/GetCodeOwnerProjectConfigIT.java b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/GetCodeOwnerProjectConfigIT.java
index e936dda..fe6c044 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/GetCodeOwnerProjectConfigIT.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/GetCodeOwnerProjectConfigIT.java
@@ -19,12 +19,12 @@
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
-import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.projects.ConfigInput;
 import com.google.gerrit.extensions.client.ProjectState;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
@@ -33,18 +33,13 @@
 import com.google.gerrit.plugins.codeowners.api.MergeCommitStrategy;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerBackend;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerBackendId;
+import com.google.gerrit.plugins.codeowners.backend.FallbackCodeOwners;
 import com.google.gerrit.plugins.codeowners.config.BackendConfig;
-import com.google.gerrit.plugins.codeowners.config.CodeOwnersPluginConfiguration;
 import com.google.gerrit.plugins.codeowners.config.GeneralConfig;
 import com.google.gerrit.plugins.codeowners.config.OverrideApprovalConfig;
 import com.google.gerrit.plugins.codeowners.config.RequiredApprovalConfig;
 import com.google.gerrit.plugins.codeowners.config.StatusConfig;
 import com.google.inject.Inject;
-import org.eclipse.jgit.junit.TestRepository;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
 import org.junit.Before;
 import org.junit.Test;
 
@@ -87,6 +82,8 @@
     assertThat(codeOwnerProjectConfigInfo.general.fileExtension).isNull();
     assertThat(codeOwnerProjectConfigInfo.general.mergeCommitStrategy)
         .isEqualTo(MergeCommitStrategy.ALL_CHANGED_FILES);
+    assertThat(codeOwnerProjectConfigInfo.general.fallbackCodeOwners)
+        .isEqualTo(FallbackCodeOwners.NONE);
     assertThat(codeOwnerProjectConfigInfo.general.implicitApprovals).isNull();
     assertThat(codeOwnerProjectConfigInfo.general.overrideInfoUrl).isNull();
     assertThat(codeOwnerProjectConfigInfo.status.disabled).isNull();
@@ -129,6 +126,15 @@
   }
 
   @Test
+  public void getConfigWithConfiguredFallbackCodeOwners() throws Exception {
+    configureFallbackCodeOwners(project, FallbackCodeOwners.ALL_USERS);
+    CodeOwnerProjectConfigInfo codeOwnerProjectConfigInfo =
+        projectCodeOwnersApiFactory.project(project).getConfig();
+    assertThat(codeOwnerProjectConfigInfo.general.fallbackCodeOwners)
+        .isEqualTo(FallbackCodeOwners.ALL_USERS);
+  }
+
+  @Test
   public void getConfigForDisabledProject() throws Exception {
     disableCodeOwnersForProject(project);
     CodeOwnerProjectConfigInfo codeOwnerProjectConfigInfo =
@@ -236,8 +242,27 @@
     configureOverrideApproval(project, "Code-Review+2");
     CodeOwnerProjectConfigInfo codeOwnerProjectConfigInfo =
         projectCodeOwnersApiFactory.project(project).getConfig();
-    assertThat(codeOwnerProjectConfigInfo.overrideApproval.label).isEqualTo("Code-Review");
-    assertThat(codeOwnerProjectConfigInfo.overrideApproval.value).isEqualTo(2);
+    assertThat(codeOwnerProjectConfigInfo.overrideApproval).hasSize(1);
+    assertThat(codeOwnerProjectConfigInfo.overrideApproval.get(0).label).isEqualTo("Code-Review");
+    assertThat(codeOwnerProjectConfigInfo.overrideApproval.get(0).value).isEqualTo(2);
+  }
+
+  @Test
+  public void getConfigWithMultipleConfiguredOverrideApproval() throws Exception {
+    createOwnersOverrideLabel();
+    setCodeOwnersConfig(
+        project,
+        /* subsection= */ null,
+        OverrideApprovalConfig.KEY_OVERRIDE_APPROVAL,
+        ImmutableList.of("Owners-Override+1", "Code-Review+2"));
+    CodeOwnerProjectConfigInfo codeOwnerProjectConfigInfo =
+        projectCodeOwnersApiFactory.project(project).getConfig();
+    assertThat(codeOwnerProjectConfigInfo.overrideApproval).hasSize(2);
+    assertThat(codeOwnerProjectConfigInfo.overrideApproval.get(0).label).isEqualTo("Code-Review");
+    assertThat(codeOwnerProjectConfigInfo.overrideApproval.get(0).value).isEqualTo(2);
+    assertThat(codeOwnerProjectConfigInfo.overrideApproval.get(1).label)
+        .isEqualTo("Owners-Override");
+    assertThat(codeOwnerProjectConfigInfo.overrideApproval.get(1).value).isEqualTo(1);
   }
 
   @Test
@@ -250,65 +275,70 @@
 
   private void configureFileExtension(Project.NameKey project, String fileExtension)
       throws Exception {
-    setConfig(project, null, GeneralConfig.KEY_FILE_EXTENSION, fileExtension);
+    setCodeOwnersConfig(
+        project, /* subsection= */ null, GeneralConfig.KEY_FILE_EXTENSION, fileExtension);
   }
 
   private void configureOverrideInfoUrl(Project.NameKey project, String overrideInfoUrl)
       throws Exception {
-    setConfig(project, null, GeneralConfig.KEY_OVERRIDE_INFO_URL, overrideInfoUrl);
+    setCodeOwnersConfig(
+        project, /* subsection= */ null, GeneralConfig.KEY_OVERRIDE_INFO_URL, overrideInfoUrl);
   }
 
   private void configureMergeCommitStrategy(
       Project.NameKey project, MergeCommitStrategy mergeCommitStrategy) throws Exception {
-    setConfig(project, null, GeneralConfig.KEY_MERGE_COMMIT_STRATEGY, mergeCommitStrategy.name());
+    setCodeOwnersConfig(
+        project,
+        /* subsection= */ null,
+        GeneralConfig.KEY_MERGE_COMMIT_STRATEGY,
+        mergeCommitStrategy.name());
+  }
+
+  private void configureFallbackCodeOwners(
+      Project.NameKey project, FallbackCodeOwners fallbackCodeOwners) throws Exception {
+    setCodeOwnersConfig(
+        project,
+        /* subsection= */ null,
+        GeneralConfig.KEY_FALLBACK_CODE_OWNERS,
+        fallbackCodeOwners.name());
   }
 
   private void configureDisabledBranch(Project.NameKey project, String disabledBranch)
       throws Exception {
-    setCodeOwnersConfig(project, null, StatusConfig.KEY_DISABLED_BRANCH, disabledBranch);
+    setCodeOwnersConfig(
+        project, /* subsection= */ null, StatusConfig.KEY_DISABLED_BRANCH, disabledBranch);
   }
 
   private void configureBackend(Project.NameKey project, String backendName) throws Exception {
-    configureBackend(project, null, backendName);
+    configureBackend(project, /* branch= */ null, backendName);
   }
 
   private void configureBackend(
       Project.NameKey project, @Nullable String branch, String backendName) throws Exception {
-    setConfig(project, branch, BackendConfig.KEY_BACKEND, backendName);
+    setCodeOwnersConfig(project, branch, BackendConfig.KEY_BACKEND, backendName);
   }
 
   private void configureRequiredApproval(Project.NameKey project, String requiredApproval)
       throws Exception {
-    setConfig(project, null, RequiredApprovalConfig.KEY_REQUIRED_APPROVAL, requiredApproval);
+    setCodeOwnersConfig(
+        project,
+        /* subsection= */ null,
+        RequiredApprovalConfig.KEY_REQUIRED_APPROVAL,
+        requiredApproval);
   }
 
   private void configureOverrideApproval(Project.NameKey project, String overrideApproval)
       throws Exception {
-    setConfig(project, null, OverrideApprovalConfig.KEY_OVERRIDE_APPROVAL, overrideApproval);
+    setCodeOwnersConfig(
+        project,
+        /* subsection= */ null,
+        OverrideApprovalConfig.KEY_OVERRIDE_APPROVAL,
+        overrideApproval);
   }
 
   private void configureImplicitApprovals(Project.NameKey project) throws Exception {
-    setConfig(project, null, GeneralConfig.KEY_ENABLE_IMPLICIT_APPROVALS, "true");
-  }
-
-  private void setConfig(Project.NameKey project, String subsection, String key, String value)
-      throws Exception {
-    Config codeOwnersConfig = new Config();
-    codeOwnersConfig.setString(
-        CodeOwnersPluginConfiguration.SECTION_CODE_OWNERS, subsection, key, value);
-    try (TestRepository<Repository> testRepo =
-        new TestRepository<>(repoManager.openRepository(project))) {
-      Ref ref = testRepo.getRepository().exactRef(RefNames.REFS_CONFIG);
-      RevCommit head = testRepo.getRevWalk().parseCommit(ref.getObjectId());
-      testRepo.update(
-          RefNames.REFS_CONFIG,
-          testRepo
-              .commit()
-              .parent(head)
-              .message("Configure code owner backend")
-              .add("code-owners.config", codeOwnersConfig.toText()));
-    }
-    projectCache.evict(project);
+    setCodeOwnersConfig(
+        project, /* subsection= */ null, GeneralConfig.KEY_ENABLE_IMPLICIT_APPROVALS, "true");
   }
 
   /** Returns the ID of a code owner backend that is not the given backend. */
diff --git a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/GetCodeOwnersForPathInBranchIT.java b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/GetCodeOwnersForPathInBranchIT.java
index db43d51..341beaf 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/GetCodeOwnersForPathInBranchIT.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/GetCodeOwnersForPathInBranchIT.java
@@ -18,7 +18,11 @@
 import static com.google.gerrit.plugins.codeowners.testing.CodeOwnerInfoSubject.hasAccountId;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.config.GerritConfig;
+import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.RestApiException;
@@ -42,6 +46,7 @@
  */
 public class GetCodeOwnersForPathInBranchIT extends AbstractGetCodeOwnersForPathIT {
   @Inject private ProjectOperations projectOperations;
+  @Inject private GroupOperations groupOperations;
 
   @Override
   protected CodeOwners getCodeOwnersApi() throws RestApiException {
@@ -121,4 +126,47 @@
                     getCodeOwnersApi().query().forRevision(revision.name()), "/foo/bar/baz.md"));
     assertThat(exception).hasMessageThat().isEqualTo("unknown revision");
   }
+
+  @Test
+  public void codeOwnersThatAreServiceUsersAreIncluded() throws Exception {
+    TestAccount serviceUser =
+        accountCreator.create("serviceUser", "service.user@example.com", "Service User", null);
+
+    // Make 'serviceUser' a service user.
+    groupOperations
+        .group(groupCache.get(AccountGroup.nameKey("Service Users")).get().getGroupUUID())
+        .forUpdate()
+        .addMember(serviceUser.id())
+        .update();
+
+    // Create a code owner config with 2 code owners.
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/")
+        .addCodeOwnerEmail(admin.email())
+        .addCodeOwnerEmail(serviceUser.email())
+        .create();
+
+    // Expect that 'serviceUser' is included.
+    assertThat(queryCodeOwners("/foo/bar/baz.md"))
+        .comparingElementsUsing(hasAccountId())
+        .containsExactly(admin.id(), serviceUser.id());
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.globalCodeOwner", value = "service.user@example.com")
+  public void globalCodeOwnersThatAreServiceUsersAreIncluded() throws Exception {
+    TestAccount serviceUser =
+        accountCreator.create("serviceUser", "service.user@example.com", "Service User", null);
+    groupOperations
+        .group(groupCache.get(AccountGroup.nameKey("Service Users")).get().getGroupUUID())
+        .forUpdate()
+        .addMember(serviceUser.id())
+        .update();
+    assertThat(queryCodeOwners("/foo/bar/baz.md"))
+        .comparingElementsUsing(hasAccountId())
+        .containsExactly(serviceUser.id());
+  }
 }
diff --git a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/GetCodeOwnersForPathInChangeIT.java b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/GetCodeOwnersForPathInChangeIT.java
index 428cc38..d5a840c 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/GetCodeOwnersForPathInChangeIT.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/GetCodeOwnersForPathInChangeIT.java
@@ -21,8 +21,12 @@
 import static java.util.stream.Collectors.toMap;
 
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.config.GerritConfig;
+import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
@@ -31,6 +35,8 @@
 import com.google.gerrit.plugins.codeowners.api.CodeOwners;
 import com.google.inject.Inject;
 import java.util.List;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.junit.Before;
 import org.junit.Test;
@@ -47,17 +53,23 @@
 public class GetCodeOwnersForPathInChangeIT extends AbstractGetCodeOwnersForPathIT {
   @Inject private RequestScopeOperations requestScopeOperations;
   @Inject private ProjectOperations projectOperations;
+  @Inject private GroupOperations groupOperations;
 
+  private TestAccount changeOwner;
   private String changeId;
 
   @Before
   public void createTestChange() throws Exception {
+    changeOwner =
+        accountCreator.create(
+            "changeOwner", "changeOwner@example.com", "ChangeOwner", /* displayName= */ null);
+    TestRepository<InMemoryRepository> testRepo = cloneProject(project, changeOwner);
     // Create a change that contains files for all paths that are used in the tests. This is
     // necessary since CodeOwnersInChangeCollection rejects requests for paths that are not present
     // in the change.
     PushOneCommit push =
         pushFactory.create(
-            admin.newIdent(),
+            changeOwner.newIdent(),
             testRepo,
             "test change",
             TEST_PATHS.stream()
@@ -91,7 +103,7 @@
         .project(project)
         .branch("master")
         .folderPath("/foo/bar/")
-        .addCodeOwnerEmail(admin.email())
+        .addCodeOwnerEmail(user.email())
         .create();
 
     String path = "/foo/bar/baz.txt";
@@ -99,17 +111,19 @@
 
     List<CodeOwnerInfo> codeOwnerInfos =
         codeOwnersApiFactory.change(changeId, "current").query().get(path);
-    assertThat(codeOwnerInfos).comparingElementsUsing(hasAccountId()).containsExactly(admin.id());
+    assertThat(codeOwnerInfos).comparingElementsUsing(hasAccountId()).containsExactly(user.id());
   }
 
   @Test
   public void getCodeOwnersForRenamedFile() throws Exception {
+    TestAccount user2 = accountCreator.user2();
+
     codeOwnerConfigOperations
         .newCodeOwnerConfig()
         .project(project)
         .branch("master")
         .folderPath("/foo/new/")
-        .addCodeOwnerEmail(admin.email())
+        .addCodeOwnerEmail(user.email())
         .create();
 
     codeOwnerConfigOperations
@@ -117,7 +131,7 @@
         .project(project)
         .branch("master")
         .folderPath("/foo/old/")
-        .addCodeOwnerEmail(user.email())
+        .addCodeOwnerEmail(user2.email())
         .create();
 
     String oldPath = "/foo/old/bar.txt";
@@ -128,13 +142,13 @@
         codeOwnersApiFactory.change(changeId, "current").query().get(newPath);
     assertThat(codeOwnerInfosNewPath)
         .comparingElementsUsing(hasAccountId())
-        .containsExactly(admin.id());
+        .containsExactly(user.id());
 
     List<CodeOwnerInfo> codeOwnerInfosOldPath =
         codeOwnersApiFactory.change(changeId, "current").query().get(oldPath);
     assertThat(codeOwnerInfosOldPath)
         .comparingElementsUsing(hasAccountId())
-        .containsExactly(user.id());
+        .containsExactly(user2.id());
   }
 
   @Test
@@ -169,9 +183,71 @@
 
     // Check that 'user' is anyway suggested as code owner for the file in the private change since
     // by adding 'user' as reviewer the change would get visible to 'user'.
-    requestScopeOperations.setApiUser(admin.id());
+    requestScopeOperations.setApiUser(changeOwner.id());
     List<CodeOwnerInfo> codeOwnerInfos =
         codeOwnersApiFactory.change(changeId, "current").query().get(TEST_PATHS.get(0));
     assertThat(codeOwnerInfos).comparingElementsUsing(hasAccountId()).containsExactly(user.id());
   }
+
+  @Test
+  public void codeOwnersThatAreServiceUsersAreFilteredOut() throws Exception {
+    TestAccount serviceUser =
+        accountCreator.create("serviceUser", "service.user@example.com", "Service User", null);
+
+    // Create a code owner config with 2 code owners.
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/")
+        .addCodeOwnerEmail(admin.email())
+        .addCodeOwnerEmail(serviceUser.email())
+        .create();
+
+    // Check that both code owners are suggested.
+    assertThat(queryCodeOwners("/foo/bar/baz.md"))
+        .comparingElementsUsing(hasAccountId())
+        .containsExactly(admin.id(), serviceUser.id());
+
+    // Make 'serviceUser' a service user.
+    groupOperations
+        .group(groupCache.get(AccountGroup.nameKey("Service Users")).get().getGroupUUID())
+        .forUpdate()
+        .addMember(serviceUser.id())
+        .update();
+
+    // Expect that 'serviceUser' is filtered out now.
+    assertThat(queryCodeOwners("/foo/bar/baz.md"))
+        .comparingElementsUsing(hasAccountId())
+        .containsExactly(admin.id());
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.globalCodeOwner", value = "service.user@example.com")
+  public void globalCodeOwnersThatAreServiceUsersAreFilteredOut() throws Exception {
+    TestAccount serviceUser =
+        accountCreator.create("serviceUser", "service.user@example.com", "Service User", null);
+    groupOperations
+        .group(groupCache.get(AccountGroup.nameKey("Service Users")).get().getGroupUUID())
+        .forUpdate()
+        .addMember(serviceUser.id())
+        .update();
+    assertThat(queryCodeOwners("/foo/bar/baz.md")).isEmpty();
+  }
+
+  @Test
+  public void changeOwnerIsFilteredOut() throws Exception {
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/")
+        .addCodeOwnerEmail(changeOwner.email())
+        .addCodeOwnerEmail(user.email())
+        .create();
+
+    assertThat(queryCodeOwners("/foo/bar/baz.md"))
+        .comparingElementsUsing(hasAccountId())
+        .containsExactly(user.id());
+  }
 }
diff --git a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/RenameEmailIT.java b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/RenameEmailIT.java
new file mode 100644
index 0000000..b9e7502
--- /dev/null
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/RenameEmailIT.java
@@ -0,0 +1,739 @@
+// 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.
+
+package com.google.gerrit.plugins.codeowners.acceptance.api;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowCapability;
+import static com.google.gerrit.plugins.codeowners.testing.CodeOwnerConfigSubject.assertThat;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.common.base.Splitter;
+import com.google.common.collect.Iterables;
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.plugins.codeowners.acceptance.AbstractCodeOwnersIT;
+import com.google.gerrit.plugins.codeowners.api.RenameEmailInput;
+import com.google.gerrit.plugins.codeowners.api.RenameEmailResultInfo;
+import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfig;
+import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfigFileUpdateScanner;
+import com.google.gerrit.plugins.codeowners.backend.proto.ProtoBackend;
+import com.google.gerrit.plugins.codeowners.config.BackendConfig;
+import com.google.gerrit.plugins.codeowners.restapi.RenameEmail;
+import com.google.inject.Inject;
+import java.util.Optional;
+import org.junit.Before;
+import org.junit.Test;
+
+/**
+ * Acceptance test for the {@link com.google.gerrit.plugins.codeowners.restapi.RenameEmail} REST
+ * endpoint.
+ *
+ * <p>Further tests for the {@link com.google.gerrit.plugins.codeowners.restapi.RenameEmail} REST
+ * endpoint that require using the REST API are implemented in {@link
+ * com.google.gerrit.plugins.codeowners.acceptance.restapi.RenameEmailRestIT}.
+ */
+public class RenameEmailIT extends AbstractCodeOwnersIT {
+  @Inject private AccountOperations accountOperations;
+  @Inject private ProjectOperations projectOperations;
+  @Inject private RequestScopeOperations requestScopeOperations;
+
+  private BackendConfig backendConfig;
+  private CodeOwnerConfigFileUpdateScanner codeOwnerConfigFileUpdateScanner;
+
+  @Before
+  public void setup() throws Exception {
+    backendConfig = plugin.getSysInjector().getInstance(BackendConfig.class);
+    codeOwnerConfigFileUpdateScanner =
+        plugin.getSysInjector().getInstance(CodeOwnerConfigFileUpdateScanner.class);
+  }
+
+  @Test
+  public void oldEmailIsRequired() throws Exception {
+    RenameEmailInput input = new RenameEmailInput();
+    input.newEmail = "new@example.com";
+    BadRequestException exception =
+        assertThrows(BadRequestException.class, () -> renameEmail(project, "master", input));
+    assertThat(exception).hasMessageThat().isEqualTo("old email is required");
+  }
+
+  @Test
+  public void newEmailIsRequired() throws Exception {
+    RenameEmailInput input = new RenameEmailInput();
+    input.oldEmail = "old@example.com";
+    BadRequestException exception =
+        assertThrows(BadRequestException.class, () -> renameEmail(project, "master", input));
+    assertThat(exception).hasMessageThat().isEqualTo("new email is required");
+  }
+
+  @Test
+  public void oldEmailNotResolvable() throws Exception {
+    RenameEmailInput input = new RenameEmailInput();
+    input.oldEmail = "unknown@example.com";
+    input.newEmail = admin.email();
+    UnprocessableEntityException exception =
+        assertThrows(
+            UnprocessableEntityException.class, () -> renameEmail(project, "master", input));
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo(String.format("cannot resolve email %s", input.oldEmail));
+  }
+
+  @Test
+  public void newEmailNotResolvable() throws Exception {
+    RenameEmailInput input = new RenameEmailInput();
+    input.oldEmail = admin.email();
+    input.newEmail = "unknown@example.com";
+    UnprocessableEntityException exception =
+        assertThrows(
+            UnprocessableEntityException.class, () -> renameEmail(project, "master", input));
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo(String.format("cannot resolve email %s", input.newEmail));
+  }
+
+  @Test
+  public void emailsMustBelongToSameAccount() throws Exception {
+    RenameEmailInput input = new RenameEmailInput();
+    input.oldEmail = admin.email();
+    input.newEmail = user.email();
+    BadRequestException exception =
+        assertThrows(BadRequestException.class, () -> renameEmail(project, "master", input));
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo(
+            String.format(
+                "emails must belong to the same account"
+                    + " (old email %s is owned by account %d, new email %s is owned by account %d)",
+                admin.email(), admin.id().get(), user.email(), user.id().get()));
+  }
+
+  @Test
+  public void oldAndNewEmailMustDiffer() throws Exception {
+    RenameEmailInput input = new RenameEmailInput();
+    input.oldEmail = admin.email();
+    input.newEmail = admin.email();
+    BadRequestException exception =
+        assertThrows(BadRequestException.class, () -> renameEmail(project, "master", input));
+    assertThat(exception).hasMessageThat().isEqualTo("old and new email must differ");
+  }
+
+  @Test
+  public void renameEmailRequiresDirectPushPermissionsForNonProjectOwner() throws Exception {
+    String secondaryEmail = "user-foo@example.com";
+    accountOperations.account(user.id()).forUpdate().addSecondaryEmail(secondaryEmail).update();
+
+    requestScopeOperations.setApiUser(user.id());
+    RenameEmailInput input = new RenameEmailInput();
+    input.oldEmail = user.email();
+    input.newEmail = secondaryEmail;
+    AuthException exception =
+        assertThrows(AuthException.class, () -> renameEmail(project, "master", input));
+    assertThat(exception).hasMessageThat().isEqualTo("not permitted: update on refs/heads/master");
+  }
+
+  @Test
+  public void renameEmail_noCodeOwnerConfig() throws Exception {
+    String secondaryEmail = "user-foo@example.com";
+    accountOperations.account(user.id()).forUpdate().addSecondaryEmail(secondaryEmail).update();
+
+    RenameEmailInput input = new RenameEmailInput();
+    input.oldEmail = user.email();
+    input.newEmail = secondaryEmail;
+    RenameEmailResultInfo result = renameEmail(project, "master", input);
+    assertThat(result.commit).isNull();
+  }
+
+  @Test
+  public void renameEmail_noUpdateIfEmailIsNotContainedInCodeOwnerConfigs() throws Exception {
+    // renaming email is not supported for the proto backend
+    assume().that(backendConfig.getDefaultBackend()).isNotInstanceOf(ProtoBackend.class);
+
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/")
+        .addCodeOwnerEmail(admin.email())
+        .create();
+
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/foo/")
+        .addCodeOwnerEmail(admin.email())
+        .create();
+
+    String secondaryEmail = "user-foo@example.com";
+    accountOperations.account(user.id()).forUpdate().addSecondaryEmail(secondaryEmail).update();
+
+    RenameEmailInput input = new RenameEmailInput();
+    input.oldEmail = user.email();
+    input.newEmail = secondaryEmail;
+    RenameEmailResultInfo result = renameEmail(project, "master", input);
+    assertThat(result.commit).isNull();
+  }
+
+  @Test
+  public void renameOwnEmailWithDirectPushPermission() throws Exception {
+    // renaming email is not supported for the proto backend
+    assume().that(backendConfig.getDefaultBackend()).isNotInstanceOf(ProtoBackend.class);
+
+    CodeOwnerConfig.Key codeOwnerConfigKey1 =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(project)
+            .branch("master")
+            .folderPath("/")
+            .addCodeOwnerEmail(admin.email())
+            .addCodeOwnerEmail(user.email())
+            .create();
+
+    CodeOwnerConfig.Key codeOwnerConfigKey2 =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(project)
+            .branch("master")
+            .folderPath("/foo/")
+            .addCodeOwnerEmail(user.email())
+            .create();
+
+    // grant all users direct push permissions
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.PUSH).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+
+    String secondaryEmail = "user-foo@example.com";
+    accountOperations.account(user.id()).forUpdate().addSecondaryEmail(secondaryEmail).update();
+
+    requestScopeOperations.setApiUser(user.id());
+    RenameEmailInput input = new RenameEmailInput();
+    input.oldEmail = user.email();
+    input.newEmail = secondaryEmail;
+    RenameEmailResultInfo result = renameEmail(project, "master", input);
+    assertThat(result.commit).isNotNull();
+
+    assertThat(codeOwnerConfigOperations.codeOwnerConfig(codeOwnerConfigKey1).get())
+        .hasCodeOwnerSetsThat()
+        .onlyElement()
+        .hasCodeOwnersEmailsThat()
+        .containsExactly(secondaryEmail, admin.email());
+    assertThat(codeOwnerConfigOperations.codeOwnerConfig(codeOwnerConfigKey2).get())
+        .hasCodeOwnerSetsThat()
+        .onlyElement()
+        .hasCodeOwnersEmailsThat()
+        .containsExactly(secondaryEmail);
+  }
+
+  @Test
+  public void renameOtherEmailWithDirectPushPermission() throws Exception {
+    // renaming email is not supported for the proto backend
+    assume().that(backendConfig.getDefaultBackend()).isNotInstanceOf(ProtoBackend.class);
+
+    CodeOwnerConfig.Key codeOwnerConfigKey1 =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(project)
+            .branch("master")
+            .folderPath("/")
+            .addCodeOwnerEmail(admin.email())
+            .addCodeOwnerEmail(user.email())
+            .create();
+
+    CodeOwnerConfig.Key codeOwnerConfigKey2 =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(project)
+            .branch("master")
+            .folderPath("/foo/")
+            .addCodeOwnerEmail(admin.email())
+            .create();
+
+    // grant all users direct push permissions
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.PUSH).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+
+    // Allow all users to see secondary emails.
+    projectOperations
+        .project(allProjects)
+        .forUpdate()
+        .add(allowCapability(GlobalCapability.MODIFY_ACCOUNT).group(REGISTERED_USERS))
+        .update();
+
+    String secondaryEmail = "admin-foo@example.com";
+    accountOperations.account(admin.id()).forUpdate().addSecondaryEmail(secondaryEmail).update();
+
+    requestScopeOperations.setApiUser(user.id());
+    RenameEmailInput input = new RenameEmailInput();
+    input.oldEmail = admin.email();
+    input.newEmail = secondaryEmail;
+    RenameEmailResultInfo result = renameEmail(project, "master", input);
+    assertThat(result.commit).isNotNull();
+
+    assertThat(codeOwnerConfigOperations.codeOwnerConfig(codeOwnerConfigKey1).get())
+        .hasCodeOwnerSetsThat()
+        .onlyElement()
+        .hasCodeOwnersEmailsThat()
+        .containsExactly(secondaryEmail, user.email());
+    assertThat(codeOwnerConfigOperations.codeOwnerConfig(codeOwnerConfigKey2).get())
+        .hasCodeOwnerSetsThat()
+        .onlyElement()
+        .hasCodeOwnersEmailsThat()
+        .containsExactly(secondaryEmail);
+  }
+
+  @Test
+  public void renameOwnEmailAsProjectOwner() throws Exception {
+    // renaming email is not supported for the proto backend
+    assume().that(backendConfig.getDefaultBackend()).isNotInstanceOf(ProtoBackend.class);
+
+    CodeOwnerConfig.Key codeOwnerConfigKey1 =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(project)
+            .branch("master")
+            .folderPath("/")
+            .addCodeOwnerEmail(user.email())
+            .addCodeOwnerEmail(admin.email())
+            .create();
+
+    CodeOwnerConfig.Key codeOwnerConfigKey2 =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(project)
+            .branch("master")
+            .folderPath("/foo/")
+            .addCodeOwnerEmail(admin.email())
+            .create();
+
+    String secondaryEmail = "admin-foo@example.com";
+    accountOperations.account(admin.id()).forUpdate().addSecondaryEmail(secondaryEmail).update();
+
+    RenameEmailInput input = new RenameEmailInput();
+    input.oldEmail = admin.email();
+    input.newEmail = secondaryEmail;
+    RenameEmailResultInfo result = renameEmail(project, "master", input);
+    assertThat(result.commit).isNotNull();
+
+    assertThat(codeOwnerConfigOperations.codeOwnerConfig(codeOwnerConfigKey1).get())
+        .hasCodeOwnerSetsThat()
+        .onlyElement()
+        .hasCodeOwnersEmailsThat()
+        .containsExactly(secondaryEmail, user.email());
+    assertThat(codeOwnerConfigOperations.codeOwnerConfig(codeOwnerConfigKey2).get())
+        .hasCodeOwnerSetsThat()
+        .onlyElement()
+        .hasCodeOwnersEmailsThat()
+        .containsExactly(secondaryEmail);
+  }
+
+  @Test
+  public void renameOtherEmailAsProjectOwner() throws Exception {
+    // renaming email is not supported for the proto backend
+    assume().that(backendConfig.getDefaultBackend()).isNotInstanceOf(ProtoBackend.class);
+
+    CodeOwnerConfig.Key codeOwnerConfigKey1 =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(project)
+            .branch("master")
+            .folderPath("/")
+            .addCodeOwnerEmail(user.email())
+            .addCodeOwnerEmail(admin.email())
+            .create();
+
+    CodeOwnerConfig.Key codeOwnerConfigKey2 =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(project)
+            .branch("master")
+            .folderPath("/foo/")
+            .addCodeOwnerEmail(user.email())
+            .create();
+
+    String secondaryEmail = "user-foo@example.com";
+    accountOperations.account(user.id()).forUpdate().addSecondaryEmail(secondaryEmail).update();
+
+    RenameEmailInput input = new RenameEmailInput();
+    input.oldEmail = user.email();
+    input.newEmail = secondaryEmail;
+    RenameEmailResultInfo result = renameEmail(project, "master", input);
+    assertThat(result.commit).isNotNull();
+
+    assertThat(codeOwnerConfigOperations.codeOwnerConfig(codeOwnerConfigKey1).get())
+        .hasCodeOwnerSetsThat()
+        .onlyElement()
+        .hasCodeOwnersEmailsThat()
+        .containsExactly(secondaryEmail, admin.email());
+    assertThat(codeOwnerConfigOperations.codeOwnerConfig(codeOwnerConfigKey2).get())
+        .hasCodeOwnerSetsThat()
+        .onlyElement()
+        .hasCodeOwnersEmailsThat()
+        .containsExactly(secondaryEmail);
+  }
+
+  @Test
+  public void renameEmail_callingUserBecomesCommitAuthor() throws Exception {
+    // renaming email is not supported for the proto backend
+    assume().that(backendConfig.getDefaultBackend()).isNotInstanceOf(ProtoBackend.class);
+
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/")
+        .addCodeOwnerEmail(user.email())
+        .addCodeOwnerEmail(admin.email())
+        .create();
+
+    String secondaryEmail = "user-foo@example.com";
+    accountOperations.account(user.id()).forUpdate().addSecondaryEmail(secondaryEmail).update();
+
+    RenameEmailInput input = new RenameEmailInput();
+    input.oldEmail = user.email();
+    input.newEmail = secondaryEmail;
+    RenameEmailResultInfo result = renameEmail(project, "master", input);
+    assertThat(result.commit).isNotNull();
+    assertThat(result.commit.author.email).isEqualTo(admin.email());
+  }
+
+  @Test
+  public void renameEmailWithDefaultCommitMessage() throws Exception {
+    // renaming email is not supported for the proto backend
+    assume().that(backendConfig.getDefaultBackend()).isNotInstanceOf(ProtoBackend.class);
+
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/")
+        .addCodeOwnerEmail(user.email())
+        .addCodeOwnerEmail(admin.email())
+        .create();
+
+    String secondaryEmail = "user-foo@example.com";
+    accountOperations.account(user.id()).forUpdate().addSecondaryEmail(secondaryEmail).update();
+
+    RenameEmailInput input = new RenameEmailInput();
+    input.oldEmail = user.email();
+    input.newEmail = secondaryEmail;
+    RenameEmailResultInfo result = renameEmail(project, "master", input);
+    assertThat(result.commit).isNotNull();
+    assertThat(result.commit.message).isEqualTo(RenameEmail.DEFAULT_COMMIT_MESSAGE);
+  }
+
+  @Test
+  public void renameEmailWithSpecifiedCommitMessage() throws Exception {
+    // renaming email is not supported for the proto backend
+    assume().that(backendConfig.getDefaultBackend()).isNotInstanceOf(ProtoBackend.class);
+
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/")
+        .addCodeOwnerEmail(user.email())
+        .addCodeOwnerEmail(admin.email())
+        .create();
+
+    String secondaryEmail = "user-foo@example.com";
+    accountOperations.account(user.id()).forUpdate().addSecondaryEmail(secondaryEmail).update();
+
+    RenameEmailInput input = new RenameEmailInput();
+    input.oldEmail = user.email();
+    input.newEmail = secondaryEmail;
+    input.message = "Update email with custom message";
+    RenameEmailResultInfo result = renameEmail(project, "master", input);
+    assertThat(result.commit).isNotNull();
+    assertThat(result.commit.message).isEqualTo(input.message);
+  }
+
+  @Test
+  public void renameEmail_specifiedCommitMessageIsTrimmed() throws Exception {
+    // renaming email is not supported for the proto backend
+    assume().that(backendConfig.getDefaultBackend()).isNotInstanceOf(ProtoBackend.class);
+
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/")
+        .addCodeOwnerEmail(user.email())
+        .addCodeOwnerEmail(admin.email())
+        .create();
+
+    String secondaryEmail = "user-foo@example.com";
+    accountOperations.account(user.id()).forUpdate().addSecondaryEmail(secondaryEmail).update();
+
+    String message = "Update email with custom message";
+    RenameEmailInput input = new RenameEmailInput();
+    input.oldEmail = user.email();
+    input.newEmail = secondaryEmail;
+    input.message = "  " + message + "\t";
+    RenameEmailResultInfo result = renameEmail(project, "master", input);
+    assertThat(result.commit).isNotNull();
+    assertThat(result.commit.message).isEqualTo(message);
+  }
+
+  @Test
+  public void renameEmail_lineCommentsArePreserved() throws Exception {
+    // renaming email is not supported for the proto backend
+    assume().that(backendConfig.getDefaultBackend()).isNotInstanceOf(ProtoBackend.class);
+
+    CodeOwnerConfig.Key codeOwnerConfigKey =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(project)
+            .branch("master")
+            .folderPath("/")
+            .addCodeOwnerEmail(user.email())
+            .addCodeOwnerEmail(admin.email())
+            .create();
+
+    // insert some comments
+    codeOwnerConfigFileUpdateScanner.update(
+        BranchNameKey.create(project, "master"),
+        "Insert comments",
+        (codeOwnerConfigFilePath, codeOwnerConfigFileContent) -> {
+          StringBuilder b = new StringBuilder();
+          // insert comment line at the top of the file
+          b.append("# top comment\n");
+
+          Iterable<String> lines = Splitter.on('\n').split(codeOwnerConfigFileContent);
+          b.append(Iterables.get(lines, /* position= */ 0) + "\n");
+
+          // insert comment line in the middle of the file
+          b.append("# middle comment\n");
+
+          for (String line : Iterables.skip(lines, /* numberToSkip= */ 1)) {
+            b.append(line + "\n");
+          }
+
+          // insert comment line at the bottom of the file
+          b.append("# bottom comment\n");
+
+          return Optional.of(b.toString());
+        });
+
+    String secondaryEmail = "user-foo@example.com";
+    accountOperations.account(user.id()).forUpdate().addSecondaryEmail(secondaryEmail).update();
+
+    RenameEmailInput input = new RenameEmailInput();
+    input.oldEmail = user.email();
+    input.newEmail = secondaryEmail;
+    renameEmail(project, "master", input);
+
+    assertThat(codeOwnerConfigOperations.codeOwnerConfig(codeOwnerConfigKey).get())
+        .hasCodeOwnerSetsThat()
+        .onlyElement()
+        .hasCodeOwnersEmailsThat()
+        .containsExactly(secondaryEmail, admin.email());
+
+    // verify that the comments are still present
+    String codeOwnerConfigFileContent =
+        codeOwnerConfigOperations.codeOwnerConfig(codeOwnerConfigKey).getContent();
+    Iterable<String> lines = Splitter.on('\n').split(codeOwnerConfigFileContent);
+    assertThat(Iterables.get(lines, /* position= */ 0)).isEqualTo("# top comment");
+    assertThat(Iterables.get(lines, /* position= */ 2)).isEqualTo("# middle comment");
+    assertThat(Iterables.get(lines, /* position= */ Iterables.size(lines) - 2))
+        .isEqualTo("# bottom comment");
+  }
+
+  @Test
+  public void renameEmail_inlineCommentsArePreserved() throws Exception {
+    // renaming email is not supported for the proto backend
+    assume().that(backendConfig.getDefaultBackend()).isNotInstanceOf(ProtoBackend.class);
+
+    CodeOwnerConfig.Key codeOwnerConfigKey =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(project)
+            .branch("master")
+            .folderPath("/")
+            .addCodeOwnerEmail(user.email())
+            .addCodeOwnerEmail(admin.email())
+            .create();
+
+    // insert some inline comments
+    codeOwnerConfigFileUpdateScanner.update(
+        BranchNameKey.create(project, "master"),
+        "Insert comments",
+        (codeOwnerConfigFilePath, codeOwnerConfigFileContent) -> {
+          StringBuilder b = new StringBuilder();
+          for (String line : Splitter.on('\n').split(codeOwnerConfigFileContent)) {
+            if (line.contains(user.email())) {
+              b.append(line + "# some comment\n");
+              continue;
+            }
+            if (line.contains(admin.email())) {
+              b.append(line + "# other comment\n");
+              continue;
+            }
+            b.append(line + "\n");
+          }
+
+          return Optional.of(b.toString());
+        });
+
+    String secondaryEmail = "user-foo@example.com";
+    accountOperations.account(user.id()).forUpdate().addSecondaryEmail(secondaryEmail).update();
+
+    RenameEmailInput input = new RenameEmailInput();
+    input.oldEmail = user.email();
+    input.newEmail = secondaryEmail;
+    renameEmail(project, "master", input);
+
+    assertThat(codeOwnerConfigOperations.codeOwnerConfig(codeOwnerConfigKey).get())
+        .hasCodeOwnerSetsThat()
+        .onlyElement()
+        .hasCodeOwnersEmailsThat()
+        .containsExactly(secondaryEmail, admin.email());
+
+    // verify that the inline comments are still present
+    String codeOwnerConfigFileContent =
+        codeOwnerConfigOperations.codeOwnerConfig(codeOwnerConfigKey).getContent();
+    for (String line : Splitter.on('\n').split(codeOwnerConfigFileContent)) {
+      if (line.contains(secondaryEmail)) {
+        assertThat(line).endsWith("# some comment");
+      } else if (line.contains(admin.email())) {
+        assertThat(line).endsWith("# other comment");
+      }
+    }
+  }
+
+  @Test
+  public void renameEmail_emailInCommentIsReplaced() throws Exception {
+    // renaming email is not supported for the proto backend
+    assume().that(backendConfig.getDefaultBackend()).isNotInstanceOf(ProtoBackend.class);
+
+    CodeOwnerConfig.Key codeOwnerConfigKey =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(project)
+            .branch("master")
+            .folderPath("/")
+            .addCodeOwnerEmail(user.email())
+            .addCodeOwnerEmail(admin.email())
+            .create();
+
+    // insert some comments
+    codeOwnerConfigFileUpdateScanner.update(
+        BranchNameKey.create(project, "master"),
+        "Insert comments",
+        (codeOwnerConfigFilePath, codeOwnerConfigFileContent) ->
+            Optional.of("# foo " + user.email() + " bar\n" + codeOwnerConfigFileContent));
+
+    String secondaryEmail = "user-foo@example.com";
+    accountOperations.account(user.id()).forUpdate().addSecondaryEmail(secondaryEmail).update();
+
+    RenameEmailInput input = new RenameEmailInput();
+    input.oldEmail = user.email();
+    input.newEmail = secondaryEmail;
+    renameEmail(project, "master", input);
+
+    assertThat(codeOwnerConfigOperations.codeOwnerConfig(codeOwnerConfigKey).get())
+        .hasCodeOwnerSetsThat()
+        .onlyElement()
+        .hasCodeOwnersEmailsThat()
+        .containsExactly(secondaryEmail, admin.email());
+
+    // verify that the comments are still present
+    String codeOwnerConfigFileContent =
+        codeOwnerConfigOperations.codeOwnerConfig(codeOwnerConfigKey).getContent();
+    assertThat(
+            Iterables.get(Splitter.on('\n').split(codeOwnerConfigFileContent), /* position= */ 0))
+        .endsWith("# foo " + secondaryEmail + " bar");
+  }
+
+  @Test
+  public void renameEmail_emailThatContainsEmailToBeReplacesAsSubstringStaysIntact()
+      throws Exception {
+    // renaming email is not supported for the proto backend
+    assume().that(backendConfig.getDefaultBackend()).isNotInstanceOf(ProtoBackend.class);
+
+    TestAccount otherUser1 =
+        accountCreator.create(
+            "otherUser1", "foo" + user.email(), "Other User 1", /* displayName= */ null);
+    TestAccount otherUser2 =
+        accountCreator.create(
+            "otherUser2", user.email() + "bar", "Other User 2", /* displayName= */ null);
+    TestAccount otherUser3 =
+        accountCreator.create(
+            "otherUser3", "foo" + user.email() + "bar", "Other User 3", /* displayName= */ null);
+
+    CodeOwnerConfig.Key codeOwnerConfigKey =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(project)
+            .branch("master")
+            .folderPath("/")
+            .addCodeOwnerEmail(user.email())
+            .addCodeOwnerEmail(otherUser1.email())
+            .addCodeOwnerEmail(otherUser2.email())
+            .addCodeOwnerEmail(otherUser3.email())
+            .create();
+
+    // grant all users direct push permissions
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.PUSH).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+
+    String secondaryEmail = "user-new@example.com";
+    accountOperations.account(user.id()).forUpdate().addSecondaryEmail(secondaryEmail).update();
+
+    requestScopeOperations.setApiUser(user.id());
+    RenameEmailInput input = new RenameEmailInput();
+    input.oldEmail = user.email();
+    input.newEmail = secondaryEmail;
+    RenameEmailResultInfo result = renameEmail(project, "master", input);
+    assertThat(result.commit).isNotNull();
+
+    assertThat(codeOwnerConfigOperations.codeOwnerConfig(codeOwnerConfigKey).get())
+        .hasCodeOwnerSetsThat()
+        .onlyElement()
+        .hasCodeOwnersEmailsThat()
+        .containsExactly(
+            secondaryEmail, otherUser1.email(), otherUser2.email(), otherUser3.email());
+  }
+
+  private RenameEmailResultInfo renameEmail(
+      Project.NameKey projectName, String branchName, RenameEmailInput input)
+      throws RestApiException {
+    return projectCodeOwnersApiFactory
+        .project(projectName)
+        .branch(branchName)
+        .renameEmailInCodeOwnerConfigFiles(input);
+  }
+}
diff --git a/javatests/com/google/gerrit/plugins/codeowners/acceptance/restapi/AbstractGetCodeOwnersForPathRestIT.java b/javatests/com/google/gerrit/plugins/codeowners/acceptance/restapi/AbstractGetCodeOwnersForPathRestIT.java
index fe0023c..dcd0cbb 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/acceptance/restapi/AbstractGetCodeOwnersForPathRestIT.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/restapi/AbstractGetCodeOwnersForPathRestIT.java
@@ -165,4 +165,18 @@
                 + " 'non-existing-backend' that is configured in gerrit.config (parameter"
                 + " plugin.code-owners.backend) not found.");
   }
+
+  @Test
+  public void cannotGetCodeOwnersWithInvalidLimit() throws Exception {
+    RestResponse r = adminRestSession.get(getUrl(TEST_PATH, "limit=invalid"));
+    r.assertBadRequest();
+    assertThat(r.getEntityContent()).contains("\"invalid\" is not a valid value for \"--limit\"");
+  }
+
+  @Test
+  public void cannotGetCodeOwnersWithInvalidSeed() throws Exception {
+    RestResponse r = adminRestSession.get(getUrl(TEST_PATH, "seed=invalid"));
+    r.assertBadRequest();
+    assertThat(r.getEntityContent()).contains("\"invalid\" is not a valid value for \"--seed\"");
+  }
 }
diff --git a/javatests/com/google/gerrit/plugins/codeowners/acceptance/restapi/CodeOwnersRestApiBindingsIT.java b/javatests/com/google/gerrit/plugins/codeowners/acceptance/restapi/CodeOwnersRestApiBindingsIT.java
index aa43cc1..d70be9f 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/acceptance/restapi/CodeOwnersRestApiBindingsIT.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/restapi/CodeOwnersRestApiBindingsIT.java
@@ -52,7 +52,8 @@
   private static final ImmutableList<RestCall> BRANCH_ENDPOINTS =
       ImmutableList.of(
           RestCall.get("/projects/%s/branches/%s/code-owners~code_owners.config_files"),
-          RestCall.get("/projects/%s/branches/%s/code-owners~code_owners.branch_config"));
+          RestCall.get("/projects/%s/branches/%s/code-owners~code_owners.branch_config"),
+          RestCall.post("/projects/%s/branches/%s/code-owners~code_owners.rename"));
 
   private static final ImmutableList<RestCall> BRANCH_CODE_OWNER_CONFIGS_ENDPOINTS =
       ImmutableList.of(RestCall.get("/projects/%s/branches/%s/code-owners~code_owners.config/%s"));
diff --git a/javatests/com/google/gerrit/plugins/codeowners/acceptance/restapi/GetCodeOwnersForPathInChangeRestIT.java b/javatests/com/google/gerrit/plugins/codeowners/acceptance/restapi/GetCodeOwnersForPathInChangeRestIT.java
index 55a1e05..3c88f2d 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/acceptance/restapi/GetCodeOwnersForPathInChangeRestIT.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/restapi/GetCodeOwnersForPathInChangeRestIT.java
@@ -17,6 +17,7 @@
 import static com.google.common.truth.Truth.assertThat;
 
 import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.plugins.codeowners.JgitPath;
 import org.junit.Before;
@@ -37,10 +38,14 @@
 
   @Before
   public void createTestChange() throws Exception {
+    TestAccount changeOwner =
+        accountCreator.create(
+            "changeOwner", "changeOwner@example.com", "ChangeOwner", /* displayName= */ null);
     // Create a change that contains the file that is used in the tests. This is necessary since
     // CodeOwnersInChangeCollection rejects requests for paths that are not present in the change.
     changeId =
-        createChange("Test Change", JgitPath.of(TEST_PATH).get(), "some content").getChangeId();
+        createChange(changeOwner, "Test Change", JgitPath.of(TEST_PATH).get(), "some content")
+            .getChangeId();
   }
 
   @Override
diff --git a/javatests/com/google/gerrit/plugins/codeowners/acceptance/restapi/RenameEmailRestIT.java b/javatests/com/google/gerrit/plugins/codeowners/acceptance/restapi/RenameEmailRestIT.java
new file mode 100644
index 0000000..5978272
--- /dev/null
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/restapi/RenameEmailRestIT.java
@@ -0,0 +1,93 @@
+// 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.
+
+package com.google.gerrit.plugins.codeowners.acceptance.restapi;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.TruthJUnit.assume;
+
+import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.plugins.codeowners.acceptance.AbstractCodeOwnersIT;
+import com.google.gerrit.plugins.codeowners.api.RenameEmailInput;
+import com.google.gerrit.plugins.codeowners.backend.CodeOwnerBackendId;
+import com.google.gerrit.plugins.codeowners.backend.proto.ProtoBackend;
+import com.google.gerrit.plugins.codeowners.config.BackendConfig;
+import com.google.inject.Inject;
+import org.junit.Before;
+import org.junit.Test;
+
+/**
+ * Acceptance test for the {@link com.google.gerrit.plugins.codeowners.restapi.RenameEmail} REST
+ * endpoint. that require using via REST.
+ *
+ * <p>Acceptance test for the {@link com.google.gerrit.plugins.codeowners.restapi.RenameEmail} REST
+ * endpoint that can use the Java API are implemented in {@link
+ * com.google.gerrit.plugins.codeowners.acceptance.api.RenameEmailIT}.
+ */
+public class RenameEmailRestIT extends AbstractCodeOwnersIT {
+  @Inject private AccountOperations accountOperations;
+
+  private BackendConfig backendConfig;
+
+  @Before
+  public void setup() throws Exception {
+    backendConfig = plugin.getSysInjector().getInstance(BackendConfig.class);
+  }
+
+  @Test
+  public void cannotRenameEmailsAnonymously() throws Exception {
+    RestResponse r =
+        anonymousRestSession.post(
+            String.format(
+                "/projects/%s/branches/%s/code_owners.rename",
+                IdString.fromDecoded(project.get()), "master"));
+    r.assertForbidden();
+    assertThat(r.getEntityContent()).contains("Authentication required");
+  }
+
+  @Test
+  public void renameEmailNotSupported() throws Exception {
+    // renaming email is only unsupported for the proto backend
+    assume().that(backendConfig.getDefaultBackend()).isInstanceOf(ProtoBackend.class);
+
+    String secondaryEmail = "user-foo@example.com";
+    accountOperations.account(user.id()).forUpdate().addSecondaryEmail(secondaryEmail).update();
+
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/")
+        .addCodeOwnerEmail(user.email())
+        .create();
+
+    RenameEmailInput input = new RenameEmailInput();
+    input.oldEmail = user.email();
+    input.newEmail = secondaryEmail;
+    RestResponse r =
+        adminRestSession.post(
+            String.format(
+                "/projects/%s/branches/%s/code_owners.rename",
+                IdString.fromDecoded(project.get()), "master"),
+            input);
+    r.assertMethodNotAllowed();
+    assertThat(r.getEntityContent())
+        .contains(
+            String.format(
+                "rename email not supported by %s backend",
+                CodeOwnerBackendId.getBackendId(backendConfig.getDefaultBackend().getClass())));
+  }
+}
diff --git a/javatests/com/google/gerrit/plugins/codeowners/acceptance/testsuite/CodeOwnerConfigOperationsImplTest.java b/javatests/com/google/gerrit/plugins/codeowners/acceptance/testsuite/CodeOwnerConfigOperationsImplTest.java
index 278dcb3..17e3ccd 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/acceptance/testsuite/CodeOwnerConfigOperationsImplTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/testsuite/CodeOwnerConfigOperationsImplTest.java
@@ -334,9 +334,7 @@
   @Test
   public void setIgnoreParentCodeOwners() throws Exception {
     CodeOwnerConfig codeOwnerConfig =
-        createCodeOwnerConfig(
-            /** ignoreParentCodeOwners = */
-            false, admin.email());
+        createCodeOwnerConfig(/* ignoreParentCodeOwners= */ false, admin.email());
     codeOwnerConfigOperations
         .codeOwnerConfig(codeOwnerConfig.key())
         .forUpdate()
@@ -350,9 +348,7 @@
   @Test
   public void unsetIgnoreParentCodeOwners() throws Exception {
     CodeOwnerConfig codeOwnerConfig =
-        createCodeOwnerConfig(
-            /** ignoreParentCodeOwners = */
-            true, admin.email());
+        createCodeOwnerConfig(/* ignoreParentCodeOwners= */ true, admin.email());
     codeOwnerConfigOperations
         .codeOwnerConfig(codeOwnerConfig.key())
         .forUpdate()
@@ -429,8 +425,8 @@
     // Create a code owner config that contains only a single code owner set.
     CodeOwnerConfig codeOwnerConfig =
         createCodeOwnerConfig(
-            /** ignoreParentCodeOwners = */
-            false, CodeOwnerSetModification.set(ImmutableList.of(codeOwnerSet)));
+            /* ignoreParentCodeOwners= */ false,
+            CodeOwnerSetModification.set(ImmutableList.of(codeOwnerSet)));
 
     // Remove all code owners so that the code owner set becomes empty.
     codeOwnerConfigOperations
@@ -713,9 +709,7 @@
   }
 
   private CodeOwnerConfig createCodeOwnerConfig(String... emails) {
-    return createCodeOwnerConfig(
-        /** ignoreParentCodeOwners = */
-        false, emails);
+    return createCodeOwnerConfig(/* ignoreParentCodeOwners= */ false, emails);
   }
 
   private CodeOwnerConfig createCodeOwnerConfig(boolean ignoreParentCodeOwners, String... emails) {
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/BUILD b/javatests/com/google/gerrit/plugins/codeowners/backend/BUILD
index 1cea7cb..df2542e 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/backend/BUILD
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/BUILD
@@ -5,12 +5,9 @@
     default_visibility = ["//plugins/code-owners:visibility"],
 )
 
-acceptance_tests(
-    srcs = glob(
-        ["*Test.java"],
-        exclude = ["Abstract*.java"],
-    ),
-    group = "backend",
+[acceptance_tests(
+    srcs = [f],
+    group = f[:f.index(".")],
     deps = [
         ":testbases",
         "//plugins/code-owners:code-owners__plugin",
@@ -19,11 +16,17 @@
         "//plugins/code-owners/java/com/google/gerrit/plugins/codeowners/testing",
         "//plugins/code-owners/java/com/google/gerrit/plugins/codeowners/testing/backend:testutil",
     ],
-)
+) for f in glob(
+    ["*Test.java"],
+    exclude = ["Abstract*.java"],
+)]
 
 java_library(
     name = "testbases",
-    srcs = glob(["Abstract*.java"]),
+    srcs = glob([
+        "Abstract*.java",
+        "GlobMatcherTest.java",
+    ]),
     deps = [
         "//java/com/google/gerrit/acceptance/config",
         "//java/com/google/gerrit/common:annotations",
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheckForAccountTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheckForAccountTest.java
new file mode 100644
index 0000000..e392e47
--- /dev/null
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheckForAccountTest.java
@@ -0,0 +1,265 @@
+// 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.
+
+package com.google.gerrit.plugins.codeowners.backend;
+
+import static com.google.gerrit.plugins.codeowners.testing.FileCodeOwnerStatusSubject.assertThatStream;
+
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.config.GerritConfig;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.plugins.codeowners.JgitPath;
+import com.google.gerrit.plugins.codeowners.acceptance.AbstractCodeOwnersTest;
+import com.google.gerrit.plugins.codeowners.acceptance.testsuite.CodeOwnerConfigOperations;
+import com.google.gerrit.plugins.codeowners.api.CodeOwnerStatus;
+import com.google.gerrit.plugins.codeowners.testing.FileCodeOwnerStatusSubject;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.inject.Inject;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.stream.Stream;
+import org.junit.Before;
+import org.junit.Test;
+
+/** Tests for {@link CodeOwnerApprovalCheck#getFileStatusesForAccount(ChangeNotes, Account.Id)}. */
+public class CodeOwnerApprovalCheckForAccountTest extends AbstractCodeOwnersTest {
+  @Inject private ChangeNotes.Factory changeNotesFactory;
+  @Inject private RequestScopeOperations requestScopeOperations;
+
+  private CodeOwnerApprovalCheck codeOwnerApprovalCheck;
+  private CodeOwnerConfigOperations codeOwnerConfigOperations;
+
+  @Before
+  public void setUpCodeOwnersPlugin() throws Exception {
+    codeOwnerApprovalCheck = plugin.getSysInjector().getInstance(CodeOwnerApprovalCheck.class);
+    codeOwnerConfigOperations =
+        plugin.getSysInjector().getInstance(CodeOwnerConfigOperations.class);
+  }
+
+  @Test
+  public void notApprovedByUser() throws Exception {
+    // create arbitrary code owner config to avoid entering the bootstrapping code path in
+    // CodeOwnerApprovalCheck
+    createArbitraryCodeOwnerConfigFile();
+
+    Path path = Paths.get("/foo/bar.baz");
+    String changeId =
+        createChange("Change Adding A File", JgitPath.of(path).get(), "file content").getChangeId();
+
+    // Verify that the file would not be approved by the user.
+    Stream<FileCodeOwnerStatus> fileCodeOwnerStatuses =
+        codeOwnerApprovalCheck.getFileStatusesForAccount(getChangeNotes(changeId), user.id());
+    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
+        assertThatStream(fileCodeOwnerStatuses).onlyElement();
+    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
+    fileCodeOwnerStatusSubject
+        .hasNewPathStatus()
+        .value()
+        .hasStatusThat()
+        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+  }
+
+  @Test
+  public void approvalFromOtherCodeOwnerHasNoEffect() throws Exception {
+    TestAccount codeOwner =
+        accountCreator.create(
+            "codeOwner", "codeOwner@example.com", "CodeOwner", /* displayName= */ null);
+
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/foo/")
+        .addCodeOwnerEmail(codeOwner.email())
+        .create();
+
+    Path path = Paths.get("/foo/bar.baz");
+    String changeId =
+        createChange("Change Adding A File", JgitPath.of(path).get(), "file content").getChangeId();
+
+    // Add a Code-Review+1 (= code owner approval) from the code owner.
+    requestScopeOperations.setApiUser(codeOwner.id());
+    recommend(changeId);
+
+    // Verify that the file would not be approved by the user.
+    Stream<FileCodeOwnerStatus> fileCodeOwnerStatuses =
+        codeOwnerApprovalCheck.getFileStatusesForAccount(getChangeNotes(changeId), user.id());
+    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
+        assertThatStream(fileCodeOwnerStatuses).onlyElement();
+    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
+    fileCodeOwnerStatusSubject
+        .hasNewPathStatus()
+        .value()
+        .hasStatusThat()
+        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+  }
+
+  @Test
+  public void approvedByUser() throws Exception {
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/foo/")
+        .addCodeOwnerEmail(user.email())
+        .create();
+
+    Path path = Paths.get("/foo/bar.baz");
+    String changeId =
+        createChange("Change Adding A File", JgitPath.of(path).get(), "file content").getChangeId();
+
+    // Verify that the file would be approved by the user.
+    Stream<FileCodeOwnerStatus> fileCodeOwnerStatuses =
+        codeOwnerApprovalCheck.getFileStatusesForAccount(getChangeNotes(changeId), user.id());
+    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
+        assertThatStream(fileCodeOwnerStatuses).onlyElement();
+    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
+    fileCodeOwnerStatusSubject
+        .hasNewPathStatus()
+        .value()
+        .hasStatusThat()
+        .isEqualTo(CodeOwnerStatus.APPROVED);
+  }
+
+  @Test
+  public void notApprovedByUser_bootstrapping() throws Exception {
+    // since no code owner config exists we are entering the bootstrapping code path in
+    // CodeOwnerApprovalCheck
+
+    Path path = Paths.get("/foo/bar.baz");
+    String changeId =
+        createChange("Change Adding A File", JgitPath.of(path).get(), "file content").getChangeId();
+
+    // Verify that the file would not be approved by the user since the user is not a project owner.
+    Stream<FileCodeOwnerStatus> fileCodeOwnerStatuses =
+        codeOwnerApprovalCheck.getFileStatusesForAccount(getChangeNotes(changeId), user.id());
+    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
+        assertThatStream(fileCodeOwnerStatuses).onlyElement();
+    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
+    fileCodeOwnerStatusSubject
+        .hasNewPathStatus()
+        .value()
+        .hasStatusThat()
+        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+  }
+
+  @Test
+  public void approvedByProjectOwner_bootstrapping() throws Exception {
+    // since no code owner config exists we are entering the bootstrapping code path in
+    // CodeOwnerApprovalCheck
+
+    Path path = Paths.get("/foo/bar.baz");
+    String changeId =
+        createChange("Change Adding A File", JgitPath.of(path).get(), "file content").getChangeId();
+
+    // Verify that the file would be approved by the 'admin' user since the 'admin' user is a
+    // project owner.
+    Stream<FileCodeOwnerStatus> fileCodeOwnerStatuses =
+        codeOwnerApprovalCheck.getFileStatusesForAccount(getChangeNotes(changeId), admin.id());
+    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
+        assertThatStream(fileCodeOwnerStatuses).onlyElement();
+    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
+    fileCodeOwnerStatusSubject
+        .hasNewPathStatus()
+        .value()
+        .hasStatusThat()
+        .isEqualTo(CodeOwnerStatus.APPROVED);
+  }
+
+  @GerritConfig(name = "plugin.code-owners.fallbackCodeOwners", value = "ALL_USERS")
+  @Test
+  public void approvedByFallbackCodeOwner() throws Exception {
+    // create arbitrary code owner config to avoid entering the bootstrapping code path in
+    // CodeOwnerApprovalCheck
+    createArbitraryCodeOwnerConfigFile();
+
+    Path path = Paths.get("/foo/bar.baz");
+    String changeId =
+        createChange("Change Adding A File", JgitPath.of(path).get(), "file content").getChangeId();
+
+    // Verify that the file would be approved by the user since the user is a fallback code owner.
+    Stream<FileCodeOwnerStatus> fileCodeOwnerStatuses =
+        codeOwnerApprovalCheck.getFileStatusesForAccount(getChangeNotes(changeId), user.id());
+    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
+        assertThatStream(fileCodeOwnerStatuses).onlyElement();
+    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
+    fileCodeOwnerStatusSubject
+        .hasNewPathStatus()
+        .value()
+        .hasStatusThat()
+        .isEqualTo(CodeOwnerStatus.APPROVED);
+  }
+
+  @GerritConfig(name = "plugin.code-owners.fallbackCodeOwners", value = "ALL_USERS")
+  @Test
+  public void notApprovedByFallbackCodeOwnerIfCodeOwerIsDefined() throws Exception {
+    TestAccount codeOwner =
+        accountCreator.create(
+            "codeOwner", "codeOwner@example.com", "CodeOwner", /* displayName= */ null);
+
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/foo/")
+        .addCodeOwnerEmail(codeOwner.email())
+        .create();
+
+    Path path = Paths.get("/foo/bar.baz");
+    String changeId =
+        createChange("Change Adding A File", JgitPath.of(path).get(), "file content").getChangeId();
+
+    // Verify that the file would not be approved by the user since fallback code owners do not
+    // apply.
+    Stream<FileCodeOwnerStatus> fileCodeOwnerStatuses =
+        codeOwnerApprovalCheck.getFileStatusesForAccount(getChangeNotes(changeId), user.id());
+    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
+        assertThatStream(fileCodeOwnerStatuses).onlyElement();
+    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
+    fileCodeOwnerStatusSubject
+        .hasNewPathStatus()
+        .value()
+        .hasStatusThat()
+        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+  }
+
+  @GerritConfig(name = "plugin.code-owners.fallbackCodeOwners", value = "ALL_USERS")
+  @Test
+  public void approvedByFallbackCodeOwner_bootstrappingMode() throws Exception {
+    // since no code owner config exists we are entering the bootstrapping code path in
+    // CodeOwnerApprovalCheck
+
+    Path path = Paths.get("/foo/bar.baz");
+    String changeId =
+        createChange("Change Adding A File", JgitPath.of(path).get(), "file content").getChangeId();
+
+    // Verify that the file would be approved by the user since the user is a fallback code owner.
+    Stream<FileCodeOwnerStatus> fileCodeOwnerStatuses =
+        codeOwnerApprovalCheck.getFileStatusesForAccount(getChangeNotes(changeId), user.id());
+    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
+        assertThatStream(fileCodeOwnerStatuses).onlyElement();
+    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
+    fileCodeOwnerStatusSubject
+        .hasNewPathStatus()
+        .value()
+        .hasStatusThat()
+        .isEqualTo(CodeOwnerStatus.APPROVED);
+  }
+
+  private ChangeNotes getChangeNotes(String changeId) throws Exception {
+    return changeNotesFactory.create(project, Change.id(gApi.changes().id(changeId).get()._number));
+  }
+}
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheckTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheckTest.java
index 563ed57..c300583 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheckTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheckTest.java
@@ -27,12 +27,13 @@
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.projects.DeleteBranchesInput;
+import com.google.gerrit.extensions.common.LabelDefinitionInput;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.plugins.codeowners.JgitPath;
 import com.google.gerrit.plugins.codeowners.acceptance.AbstractCodeOwnersTest;
@@ -49,7 +50,15 @@
 import org.junit.Before;
 import org.junit.Test;
 
-/** Tests for {@link CodeOwnerApprovalCheck}. */
+/**
+ * Tests for {@link CodeOwnerApprovalCheck}.
+ *
+ * <p>Further tests with fallback code owners are implemented in {@link
+ * CodeOwnerApprovalCheckWithAllUsersAsFallbackCodeOwnersTest} and the functionality of {@link
+ * CodeOwnerApprovalCheck#getFileStatusesForAccount(ChangeNotes,
+ * com.google.gerrit.entities.Account.Id)} is covered by {@link
+ * CodeOwnerApprovalCheckForAccountTest}.
+ */
 public class CodeOwnerApprovalCheckTest extends AbstractCodeOwnersTest {
   @Inject private ChangeNotes.Factory changeNotesFactory;
   @Inject private RequestScopeOperations requestScopeOperations;
@@ -70,10 +79,7 @@
     NullPointerException npe =
         assertThrows(
             NullPointerException.class,
-            () ->
-                codeOwnerApprovalCheck.getFileStatuses(
-                    /** changeNotes = */
-                    null));
+            () -> codeOwnerApprovalCheck.getFileStatuses(/* changeNotes= */ null));
     assertThat(npe).hasMessageThat().isEqualTo("changeNotes");
   }
 
@@ -223,13 +229,7 @@
   public void getStatusForFileAddition_pending() throws Exception {
     TestAccount user2 = accountCreator.user2();
 
-    codeOwnerConfigOperations
-        .newCodeOwnerConfig()
-        .project(project)
-        .branch("master")
-        .folderPath("/foo/")
-        .addCodeOwnerEmail(user.email())
-        .create();
+    setAsRootCodeOwners(user);
 
     Path path = Paths.get("/foo/bar.baz");
     String changeId =
@@ -261,13 +261,7 @@
   public void getStatusForFileModification_pending() throws Exception {
     TestAccount user2 = accountCreator.user2();
 
-    codeOwnerConfigOperations
-        .newCodeOwnerConfig()
-        .project(project)
-        .branch("master")
-        .folderPath("/foo/")
-        .addCodeOwnerEmail(user.email())
-        .create();
+    setAsRootCodeOwners(user);
 
     Path path = Paths.get("/foo/bar.baz");
     createChange("Test Change", JgitPath.of(path).get(), "file content").getChangeId();
@@ -301,13 +295,7 @@
   public void getStatusForFileDeletion_pending() throws Exception {
     TestAccount user2 = accountCreator.user2();
 
-    codeOwnerConfigOperations
-        .newCodeOwnerConfig()
-        .project(project)
-        .branch("master")
-        .folderPath("/foo/")
-        .addCodeOwnerEmail(user.email())
-        .create();
+    setAsRootCodeOwners(user);
 
     Path path = Paths.get("/foo/bar.baz");
     String changeId = createChangeWithFileDeletion(path);
@@ -338,13 +326,7 @@
   public void getStatusForFileRename_pendingOldPath() throws Exception {
     TestAccount user2 = accountCreator.user2();
 
-    codeOwnerConfigOperations
-        .newCodeOwnerConfig()
-        .project(project)
-        .branch("master")
-        .folderPath("/foo/bar/")
-        .addCodeOwnerEmail(user.email())
-        .create();
+    setAsCodeOwners("/foo/bar/", user);
 
     Path oldPath = Paths.get("/foo/bar/abc.txt");
     Path newPath = Paths.get("/foo/baz/abc.txt");
@@ -381,13 +363,7 @@
   public void getStatusForFileRename_pendingNewPath() throws Exception {
     TestAccount user2 = accountCreator.user2();
 
-    codeOwnerConfigOperations
-        .newCodeOwnerConfig()
-        .project(project)
-        .branch("master")
-        .folderPath("/foo/baz/")
-        .addCodeOwnerEmail(user.email())
-        .create();
+    setAsCodeOwners("/foo/baz/", user);
 
     Path oldPath = Paths.get("/foo/bar/abc.txt");
     Path newPath = Paths.get("/foo/baz/abc.txt");
@@ -422,13 +398,7 @@
 
   @Test
   public void getStatusForFileAddition_approved() throws Exception {
-    codeOwnerConfigOperations
-        .newCodeOwnerConfig()
-        .project(project)
-        .branch("master")
-        .folderPath("/foo/")
-        .addCodeOwnerEmail(user.email())
-        .create();
+    setAsRootCodeOwners(user);
 
     Path path = Paths.get("/foo/bar.baz");
     String changeId =
@@ -455,13 +425,7 @@
 
   @Test
   public void getStatusForFileModification_approved() throws Exception {
-    codeOwnerConfigOperations
-        .newCodeOwnerConfig()
-        .project(project)
-        .branch("master")
-        .folderPath("/foo/")
-        .addCodeOwnerEmail(user.email())
-        .create();
+    setAsRootCodeOwners(user);
 
     Path path = Paths.get("/foo/bar.baz");
     createChange("Test Change", JgitPath.of(path).get(), "file content").getChangeId();
@@ -490,13 +454,7 @@
 
   @Test
   public void getStatusForFileDeletion_approved() throws Exception {
-    codeOwnerConfigOperations
-        .newCodeOwnerConfig()
-        .project(project)
-        .branch("master")
-        .folderPath("/foo/")
-        .addCodeOwnerEmail(user.email())
-        .create();
+    setAsRootCodeOwners(user);
 
     Path path = Paths.get("/foo/bar.baz");
     String changeId = createChangeWithFileDeletion(path);
@@ -522,13 +480,7 @@
 
   @Test
   public void getStatusForFileRename_approvedOldPath() throws Exception {
-    codeOwnerConfigOperations
-        .newCodeOwnerConfig()
-        .project(project)
-        .branch("master")
-        .folderPath("/foo/bar/")
-        .addCodeOwnerEmail(user.email())
-        .create();
+    setAsCodeOwners("/foo/bar/", user);
 
     Path oldPath = Paths.get("/foo/bar/abc.txt");
     Path newPath = Paths.get("/foo/baz/abc.txt");
@@ -561,13 +513,7 @@
 
   @Test
   public void getStatusForFileRename_approvedNewPath() throws Exception {
-    codeOwnerConfigOperations
-        .newCodeOwnerConfig()
-        .project(project)
-        .branch("master")
-        .folderPath("/foo/baz/")
-        .addCodeOwnerEmail(user.email())
-        .create();
+    setAsCodeOwners("/foo/baz/", user);
 
     Path oldPath = Paths.get("/foo/bar/abc.txt");
     Path newPath = Paths.get("/foo/baz/abc.txt");
@@ -601,27 +547,19 @@
   @Test
   public void getStatusForFileAddition_noImplicitApprovalByPatchSetUploader() throws Exception {
     testImplicitApprovalByPatchSetUploaderOnGetStatusForFileAddition(
-        /** implicitApprovalsEnabled = */
-        false);
+        /* implicitApprovalsEnabled= */ false);
   }
 
   @Test
   @GerritConfig(name = "plugin.code-owners.enableImplicitApprovals", value = "true")
   public void getStatusForFileAddition_withImplicitApprovalByPatchSetUploader() throws Exception {
     testImplicitApprovalByPatchSetUploaderOnGetStatusForFileAddition(
-        /** implicitApprovalsEnabled = */
-        true);
+        /* implicitApprovalsEnabled= */ true);
   }
 
   private void testImplicitApprovalByPatchSetUploaderOnGetStatusForFileAddition(
       boolean implicitApprovalsEnabled) throws Exception {
-    codeOwnerConfigOperations
-        .newCodeOwnerConfig()
-        .project(project)
-        .branch("master")
-        .folderPath("/foo/")
-        .addCodeOwnerEmail(user.email())
-        .create();
+    setAsRootCodeOwners(user);
 
     Path path = Paths.get("/foo/bar.baz");
     String changeId =
@@ -649,8 +587,7 @@
   @Test
   public void getStatusForFileModification_noImplicitApprovalByPatchSetUploader() throws Exception {
     testImplicitApprovalByPatchSetUploaderOnGetStatusForFileModification(
-        /** implicitApprovalsEnabled = */
-        false);
+        /* implicitApprovalsEnabled= */ false);
   }
 
   @Test
@@ -658,19 +595,12 @@
   public void getStatusForFileModification_withImplicitApprovalByPatchSetUploader()
       throws Exception {
     testImplicitApprovalByPatchSetUploaderOnGetStatusForFileModification(
-        /** implicitApprovalsEnabled = */
-        true);
+        /* implicitApprovalsEnabled= */ true);
   }
 
   private void testImplicitApprovalByPatchSetUploaderOnGetStatusForFileModification(
       boolean implicitApprovalsEnabled) throws Exception {
-    codeOwnerConfigOperations
-        .newCodeOwnerConfig()
-        .project(project)
-        .branch("master")
-        .folderPath("/foo/")
-        .addCodeOwnerEmail(user.email())
-        .create();
+    setAsRootCodeOwners(user);
 
     Path path = Paths.get("/foo/bar.baz");
     createChange("Test Change", JgitPath.of(path).get(), "file content").getChangeId();
@@ -700,27 +630,19 @@
   @Test
   public void getStatusForFileDeletion_noImplicitApprovalByPatchSetUploader() throws Exception {
     testImplicitApprovalByPatchSetUploaderOnGetStatusForFileDeletion(
-        /** implicitApprovalsEnabled = */
-        false);
+        /* implicitApprovalsEnabled= */ false);
   }
 
   @Test
   @GerritConfig(name = "plugin.code-owners.enableImplicitApprovals", value = "true")
   public void getStatusForFileDeletion_withImplicitApprovalByPatchSetUploader() throws Exception {
     testImplicitApprovalByPatchSetUploaderOnGetStatusForFileDeletion(
-        /** implicitApprovalsEnabled = */
-        true);
+        /* implicitApprovalsEnabled= */ true);
   }
 
   private void testImplicitApprovalByPatchSetUploaderOnGetStatusForFileDeletion(
       boolean implicitApprovalsEnabled) throws Exception {
-    codeOwnerConfigOperations
-        .newCodeOwnerConfig()
-        .project(project)
-        .branch("master")
-        .folderPath("/foo/")
-        .addCodeOwnerEmail(user.email())
-        .create();
+    setAsRootCodeOwners(user);
 
     Path path = Paths.get("/foo/bar.baz");
     String changeId = createChangeWithFileDeletion(path);
@@ -748,8 +670,7 @@
   public void getStatusForFileRename_noImplicitApprovalByPatchSetUploaderOnOldPath()
       throws Exception {
     testImplicitApprovalByPatchSetUploaderOnStatusForFileRenameOnOldPath(
-        /** implicitApprovalsEnabled = */
-        false);
+        /* implicitApprovalsEnabled= */ false);
   }
 
   @Test
@@ -757,19 +678,12 @@
   public void getStatusForFileRename_withImplicitApprovalByPatchSetUploaderOnOldPath()
       throws Exception {
     testImplicitApprovalByPatchSetUploaderOnStatusForFileRenameOnOldPath(
-        /** implicitApprovalsEnabled = */
-        true);
+        /* implicitApprovalsEnabled= */ true);
   }
 
   private void testImplicitApprovalByPatchSetUploaderOnStatusForFileRenameOnOldPath(
       boolean implicitApprovalsEnabled) throws Exception {
-    codeOwnerConfigOperations
-        .newCodeOwnerConfig()
-        .project(project)
-        .branch("master")
-        .folderPath("/foo/bar/")
-        .addCodeOwnerEmail(user.email())
-        .create();
+    setAsCodeOwners("/foo/bar/", user);
 
     Path oldPath = Paths.get("/foo/bar/abc.txt");
     Path newPath = Paths.get("/foo/baz/abc.txt");
@@ -803,8 +717,7 @@
   public void getStatusForFileRename_noImplicitApprovalByPatchSetUploaderOnNewPath()
       throws Exception {
     testImplicitApprovalByPatchSetUploaderOnStatusForFileRenameOnNewPath(
-        /** implicitApprovalsEnabled = */
-        false);
+        /* implicitApprovalsEnabled= */ false);
   }
 
   @Test
@@ -812,19 +725,12 @@
   public void getStatusForFileRename_withImplicitApprovalByPatchSetUploaderOnNewPath()
       throws Exception {
     testImplicitApprovalByPatchSetUploaderOnStatusForFileRenameOnNewPath(
-        /** implicitApprovalsEnabled = */
-        true);
+        /* implicitApprovalsEnabled= */ true);
   }
 
   private void testImplicitApprovalByPatchSetUploaderOnStatusForFileRenameOnNewPath(
       boolean implicitApprovalsEnabled) throws Exception {
-    codeOwnerConfigOperations
-        .newCodeOwnerConfig()
-        .project(project)
-        .branch("master")
-        .folderPath("/foo/baz/")
-        .addCodeOwnerEmail(user.email())
-        .create();
+    setAsCodeOwners("/foo/baz/", user);
 
     Path oldPath = Paths.get("/foo/bar/abc.txt");
     Path newPath = Paths.get("/foo/baz/abc.txt");
@@ -857,13 +763,7 @@
   @Test
   @GerritConfig(name = "plugin.code-owners.enableImplicitApprovals", value = "true")
   public void getStatusForFileAddition_noImplicitlyApprovalByChangeOwner() throws Exception {
-    codeOwnerConfigOperations
-        .newCodeOwnerConfig()
-        .project(project)
-        .branch("master")
-        .folderPath("/foo/")
-        .addCodeOwnerEmail(admin.email())
-        .create();
+    setAsRootCodeOwners(admin);
 
     Path path = Paths.get("/foo/bar.baz");
     String changeId =
@@ -891,13 +791,7 @@
       throws Exception {
     TestAccount user2 = accountCreator.user2();
 
-    codeOwnerConfigOperations
-        .newCodeOwnerConfig()
-        .project(project)
-        .branch("master")
-        .folderPath("/foo/")
-        .addCodeOwnerEmail(user.email())
-        .create();
+    setAsRootCodeOwners(user);
 
     Path path = Paths.get("/foo/bar.baz");
     String changeId =
@@ -967,17 +861,13 @@
 
   @Test
   public void everyoneIsCodeOwner_noImplicitApproval() throws Exception {
-    testImplicitlyApprovedWhenEveryoneIsCodeOwner(
-        /** implicitApprovalsEnabled = */
-        false);
+    testImplicitlyApprovedWhenEveryoneIsCodeOwner(/* implicitApprovalsEnabled= */ false);
   }
 
   @Test
   @GerritConfig(name = "plugin.code-owners.enableImplicitApprovals", value = "true")
   public void everyoneIsCodeOwner_withImplicitApproval() throws Exception {
-    testImplicitlyApprovedWhenEveryoneIsCodeOwner(
-        /** implicitApprovalsEnabled = */
-        true);
+    testImplicitlyApprovedWhenEveryoneIsCodeOwner(/* implicitApprovalsEnabled= */ true);
   }
 
   private void testImplicitlyApprovedWhenEveryoneIsCodeOwner(boolean implicitApprovalsEnabled)
@@ -1057,38 +947,23 @@
   @Test
   @GerritConfig(name = "plugin.code-owners.globalCodeOwner", value = "bot@example.com")
   public void approvedByGlobalCodeOwner() throws Exception {
-    testApprovedByGlobalCodeOwner(
-        /** bootstrappingMode = */
-        false);
+    testApprovedByGlobalCodeOwner(/* bootstrappingMode= */ false);
   }
 
   @Test
   @GerritConfig(name = "plugin.code-owners.globalCodeOwner", value = "bot@example.com")
   public void approvedByGlobalCodeOwner_bootstrappingMode() throws Exception {
-    testApprovedByGlobalCodeOwner(
-        /** bootstrappingMode = */
-        true);
+    testApprovedByGlobalCodeOwner(/* bootstrappingMode= */ true);
   }
 
   private void testApprovedByGlobalCodeOwner(boolean bootstrappingMode) throws Exception {
     // Create a bot user that is a global code owner.
     TestAccount bot =
-        accountCreator.create(
-            "bot",
-            "bot@example.com",
-            "Bot",
-            /** displayName = */
-            null);
+        accountCreator.create("bot", "bot@example.com", "Bot", /* displayName= */ null);
 
     if (!bootstrappingMode) {
       // Create a code owner config file so that we are not in the bootstrapping mode.
-      codeOwnerConfigOperations
-          .newCodeOwnerConfig()
-          .project(project)
-          .branch("master")
-          .folderPath("/foo/")
-          .addCodeOwnerEmail(admin.email())
-          .create();
+      createArbitraryCodeOwnerConfigFile();
     }
 
     // Create a change as a user that is not a code owner.
@@ -1134,10 +1009,7 @@
   @GerritConfig(name = "plugin.code-owners.globalCodeOwner", value = "bot@example.com")
   public void globalCodeOwner_noImplicitApproval() throws Exception {
     testImplicitlyApprovedByGlobalCodeOwner(
-        /** implicitApprovalsEnabled = */
-        false,
-        /** bootstrappingMode = */
-        false);
+        /* implicitApprovalsEnabled= */ false, /* bootstrappingMode= */ false);
   }
 
   @Test
@@ -1145,20 +1017,14 @@
   @GerritConfig(name = "plugin.code-owners.enableImplicitApprovals", value = "true")
   public void globalCodeOwner_withImplicitApproval() throws Exception {
     testImplicitlyApprovedByGlobalCodeOwner(
-        /** implicitApprovalsEnabled = */
-        true,
-        /** bootstrappingMode = */
-        false);
+        /* implicitApprovalsEnabled= */ true, /* bootstrappingMode= */ false);
   }
 
   @Test
   @GerritConfig(name = "plugin.code-owners.globalCodeOwner", value = "bot@example.com")
   public void globalCodeOwner_noImplicitApproval_bootstrappingMode() throws Exception {
     testImplicitlyApprovedByGlobalCodeOwner(
-        /** implicitApprovalsEnabled = */
-        false,
-        /** bootstrappingMode = */
-        true);
+        /* implicitApprovalsEnabled= */ false, /* bootstrappingMode= */ true);
   }
 
   @Test
@@ -1166,30 +1032,17 @@
   @GerritConfig(name = "plugin.code-owners.enableImplicitApprovals", value = "true")
   public void globalCodeOwner_withImplicitApproval_bootstrappingMode() throws Exception {
     testImplicitlyApprovedByGlobalCodeOwner(
-        /** implicitApprovalsEnabled = */
-        true,
-        /** bootstrappingMode = */
-        true);
+        /* implicitApprovalsEnabled= */ true, /* bootstrappingMode= */ true);
   }
 
   private void testImplicitlyApprovedByGlobalCodeOwner(
       boolean implicitApprovalsEnabled, boolean bootstrappingMode) throws Exception {
     TestAccount bot =
-        accountCreator.create(
-            "bot",
-            "bot@example.com",
-            "Bot",
-            /** displayName = */
-            null);
+        accountCreator.create("bot", "bot@example.com", "Bot", /* displayName= */ null);
 
     if (!bootstrappingMode) {
-      codeOwnerConfigOperations
-          .newCodeOwnerConfig()
-          .project(project)
-          .branch("master")
-          .folderPath("/foo/")
-          .addCodeOwnerEmail(admin.email())
-          .create();
+      // Create a code owner config file so that we are not in the bootstrapping mode.
+      createArbitraryCodeOwnerConfigFile();
     }
 
     Path path = Paths.get("/foo/bar.baz");
@@ -1215,38 +1068,23 @@
   @Test
   @GerritConfig(name = "plugin.code-owners.globalCodeOwner", value = "bot@example.com")
   public void globalCodeOwnerAsReviewer() throws Exception {
-    testGlobalCodeOwnerAsReviewer(
-        /** bootstrappingMode = */
-        false);
+    testGlobalCodeOwnerAsReviewer(/* bootstrappingMode= */ false);
   }
 
   @Test
   @GerritConfig(name = "plugin.code-owners.globalCodeOwner", value = "bot@example.com")
   public void globalCodeOwnerAsReviewer_bootstrappingMode() throws Exception {
-    testGlobalCodeOwnerAsReviewer(
-        /** bootstrappingMode = */
-        true);
+    testGlobalCodeOwnerAsReviewer(/* bootstrappingMode= */ true);
   }
 
   private void testGlobalCodeOwnerAsReviewer(boolean bootstrappingMode) throws Exception {
     // Create a bot user that is a global code owner.
     TestAccount bot =
-        accountCreator.create(
-            "bot",
-            "bot@example.com",
-            "Bot",
-            /** displayName = */
-            null);
+        accountCreator.create("bot", "bot@example.com", "Bot", /* displayName= */ null);
 
     if (!bootstrappingMode) {
       // Create a code owner config file so that we are not in the bootstrapping mode.
-      codeOwnerConfigOperations
-          .newCodeOwnerConfig()
-          .project(project)
-          .branch("master")
-          .folderPath("/foo/")
-          .addCodeOwnerEmail(admin.email())
-          .create();
+      createArbitraryCodeOwnerConfigFile();
     }
 
     // Create a change as a user that is not a code owner.
@@ -1305,30 +1143,20 @@
   @Test
   @GerritConfig(name = "plugin.code-owners.globalCodeOwner", value = "*")
   public void approvedByAnyoneWhenEveryoneIsGlobalCodeOwner() throws Exception {
-    testApprovedByAnyoneWhenEveryoneIsGlobalCodeOwner(
-        /** bootstrappingMode = */
-        false);
+    testApprovedByAnyoneWhenEveryoneIsGlobalCodeOwner(/* bootstrappingMode= */ false);
   }
 
   @Test
   @GerritConfig(name = "plugin.code-owners.globalCodeOwner", value = "*")
   public void approvedByAnyoneWhenEveryoneIsGlobalCodeOwner_bootstrappingMode() throws Exception {
-    testApprovedByAnyoneWhenEveryoneIsGlobalCodeOwner(
-        /** bootstrappingMode = */
-        true);
+    testApprovedByAnyoneWhenEveryoneIsGlobalCodeOwner(/* bootstrappingMode= */ true);
   }
 
   private void testApprovedByAnyoneWhenEveryoneIsGlobalCodeOwner(boolean bootstrappingMode)
       throws Exception {
     if (!bootstrappingMode) {
       // Create a code owner config file so that we are not in the bootstrapping mode.
-      codeOwnerConfigOperations
-          .newCodeOwnerConfig()
-          .project(project)
-          .branch("master")
-          .folderPath("/foo/")
-          .addCodeOwnerEmail(user.email())
-          .create();
+      createArbitraryCodeOwnerConfigFile();
     }
 
     // Create a change.
@@ -1369,10 +1197,7 @@
   @GerritConfig(name = "plugin.code-owners.globalCodeOwner", value = "*")
   public void everyoneIsGlobalCodeOwner_noImplicitApproval() throws Exception {
     testImplicitlyApprovedByGlobalCodeOwnerWhenEveryoneIsGlobalCodeOwner(
-        /** implicitApprovalsEnabled = */
-        false,
-        /** bootstrappingMode = */
-        false);
+        /* implicitApprovalsEnabled= */ false, /* bootstrappingMode= */ false);
   }
 
   @Test
@@ -1380,20 +1205,14 @@
   @GerritConfig(name = "plugin.code-owners.enableImplicitApprovals", value = "true")
   public void everyoneIsGlobalCodeOwner_withImplicitApproval() throws Exception {
     testImplicitlyApprovedByGlobalCodeOwnerWhenEveryoneIsGlobalCodeOwner(
-        /** implicitApprovalsEnabled = */
-        true,
-        /** bootstrappingMode = */
-        false);
+        /* implicitApprovalsEnabled= */ true, /* bootstrappingMode= */ false);
   }
 
   @Test
   @GerritConfig(name = "plugin.code-owners.globalCodeOwner", value = "*")
   public void everyoneIsGlobalCodeOwner_noImplicitApproval_bootstrappingMode() throws Exception {
     testImplicitlyApprovedByGlobalCodeOwnerWhenEveryoneIsGlobalCodeOwner(
-        /** implicitApprovalsEnabled = */
-        false,
-        /** bootstrappingMode = */
-        true);
+        /* implicitApprovalsEnabled= */ false, /* bootstrappingMode= */ true);
   }
 
   @Test
@@ -1401,22 +1220,14 @@
   @GerritConfig(name = "plugin.code-owners.enableImplicitApprovals", value = "true")
   public void everyoneIsGlobalCodeOwner_withImplicitApproval_bootstrappingMode() throws Exception {
     testImplicitlyApprovedByGlobalCodeOwnerWhenEveryoneIsGlobalCodeOwner(
-        /** implicitApprovalsEnabled = */
-        true,
-        /** bootstrappingMode = */
-        true);
+        /* implicitApprovalsEnabled= */ true, /* bootstrappingMode= */ true);
   }
 
   private void testImplicitlyApprovedByGlobalCodeOwnerWhenEveryoneIsGlobalCodeOwner(
       boolean implicitApprovalsEnabled, boolean bootstrappingMode) throws Exception {
     if (!bootstrappingMode) {
-      codeOwnerConfigOperations
-          .newCodeOwnerConfig()
-          .project(project)
-          .branch("master")
-          .folderPath("/foo/")
-          .addCodeOwnerEmail(user.email())
-          .create();
+      // Create a code owner config file so that we are not in the bootstrapping mode.
+      createArbitraryCodeOwnerConfigFile();
     }
 
     // Create a change as a user that is a code owner only through the global code ownership.
@@ -1442,32 +1253,20 @@
   @Test
   @GerritConfig(name = "plugin.code-owners.globalCodeOwner", value = "*")
   public void anyReviewerWhenEveryoneIsGlobalCodeOwner() throws Exception {
-    testAnyReviewerWhenEveryoneIsGlobalCodeOwner(
-        /** bootstrappingMode = */
-        false);
+    testAnyReviewerWhenEveryoneIsGlobalCodeOwner(/* bootstrappingMode= */ false);
   }
 
   @Test
   @GerritConfig(name = "plugin.code-owners.globalCodeOwner", value = "*")
   public void anyReviewerWhenEveryoneIsGlobalCodeOwner_bootstrappingMode() throws Exception {
-    testAnyReviewerWhenEveryoneIsGlobalCodeOwner(
-        /** bootstrappingMode = */
-        true);
+    testAnyReviewerWhenEveryoneIsGlobalCodeOwner(/* bootstrappingMode= */ true);
   }
 
   private void testAnyReviewerWhenEveryoneIsGlobalCodeOwner(boolean bootstrappingMode)
       throws Exception {
-    TestAccount user2 = accountCreator.user2();
-
     if (!bootstrappingMode) {
       // Create a code owner config file so that we are not in the bootstrapping mode.
-      codeOwnerConfigOperations
-          .newCodeOwnerConfig()
-          .project(project)
-          .branch("master")
-          .folderPath("/foo/")
-          .addCodeOwnerEmail(user2.email())
-          .create();
+      createArbitraryCodeOwnerConfigFile();
     }
 
     // Create a change as a user that is a code owner only through the global code ownership.
@@ -1506,35 +1305,11 @@
   public void parentCodeOwnerConfigsAreConsidered() throws Exception {
     TestAccount user2 = accountCreator.user2();
     TestAccount user3 =
-        accountCreator.create(
-            "user3",
-            "user3@example.com",
-            "User3",
-            /** displayName = */
-            null);
+        accountCreator.create("user3", "user3@example.com", "User3", /* displayName= */ null);
 
-    codeOwnerConfigOperations
-        .newCodeOwnerConfig()
-        .project(project)
-        .branch("master")
-        .folderPath("/")
-        .addCodeOwnerEmail(user.email())
-        .create();
-    codeOwnerConfigOperations
-        .newCodeOwnerConfig()
-        .project(project)
-        .branch("master")
-        .folderPath("/foo/")
-        .addCodeOwnerEmail(user2.email())
-        .create();
-
-    codeOwnerConfigOperations
-        .newCodeOwnerConfig()
-        .project(project)
-        .branch("master")
-        .folderPath("/foo/bar/")
-        .addCodeOwnerEmail(user3.email())
-        .create();
+    setAsCodeOwners("/", user);
+    setAsCodeOwners("/foo/", user2);
+    setAsCodeOwners("/foo/bar/", user3);
 
     Path path = Paths.get("/foo/bar/baz.txt");
     String changeId =
@@ -1613,14 +1388,94 @@
   }
 
   @Test
+  @GerritConfig(
+      name = "plugin.code-owners.overrideApproval",
+      values = {"Owners-Override+1", "Another-Override+1"})
+  public void getStatus_anyOverrideApprovesAllFiles() throws Exception {
+    // create arbitrary code owner config to avoid entering the bootstrapping code path in
+    // CodeOwnerApprovalCheck
+    createArbitraryCodeOwnerConfigFile();
+
+    createOwnersOverrideLabel();
+    createOwnersOverrideLabel("Another-Override");
+
+    // Create a change.
+    String changeId =
+        pushFactory
+            .create(
+                admin.newIdent(),
+                testRepo,
+                "Test Change",
+                ImmutableMap.of(
+                    "foo/baz.config", "content",
+                    "bar/baz.config", "other content"))
+            .to("refs/for/master")
+            .getChangeId();
+
+    // Without override approval the expected status is INSUFFICIENT_REVIEWERS.
+    for (FileCodeOwnerStatus fileCodeOwnerStatus :
+        codeOwnerApprovalCheck
+            .getFileStatuses(getChangeNotes(changeId))
+            .collect(toImmutableList())) {
+      assertThat(fileCodeOwnerStatus)
+          .hasNewPathStatus()
+          .value()
+          .hasStatusThat()
+          .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+    }
+
+    // Add an override approval.
+    gApi.changes().id(changeId).current().review(new ReviewInput().label("Owners-Override", 1));
+
+    // With override approval the expected status is APPROVED.
+    for (FileCodeOwnerStatus fileCodeOwnerStatus :
+        codeOwnerApprovalCheck
+            .getFileStatuses(getChangeNotes(changeId))
+            .collect(toImmutableList())) {
+      assertThat(fileCodeOwnerStatus)
+          .hasNewPathStatus()
+          .value()
+          .hasStatusThat()
+          .isEqualTo(CodeOwnerStatus.APPROVED);
+    }
+
+    // Delete the override approval.
+    gApi.changes().id(changeId).current().review(new ReviewInput().label("Owners-Override", 0));
+
+    // Without override approval the expected status is INSUFFICIENT_REVIEWERS.
+    for (FileCodeOwnerStatus fileCodeOwnerStatus :
+        codeOwnerApprovalCheck
+            .getFileStatuses(getChangeNotes(changeId))
+            .collect(toImmutableList())) {
+      assertThat(fileCodeOwnerStatus)
+          .hasNewPathStatus()
+          .value()
+          .hasStatusThat()
+          .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+    }
+
+    // Add another override approval.
+    gApi.changes().id(changeId).current().review(new ReviewInput().label("Another-Override", 1));
+
+    // With override approval the expected status is APPROVED.
+    for (FileCodeOwnerStatus fileCodeOwnerStatus :
+        codeOwnerApprovalCheck
+            .getFileStatuses(getChangeNotes(changeId))
+            .collect(toImmutableList())) {
+      assertThat(fileCodeOwnerStatus)
+          .hasNewPathStatus()
+          .value()
+          .hasStatusThat()
+          .isEqualTo(CodeOwnerStatus.APPROVED);
+    }
+  }
+
+  @Test
   public void cannotCheckIfSubmittableForNullChangeNotes() throws Exception {
     NullPointerException npe =
         assertThrows(
             NullPointerException.class,
-            () ->
-                codeOwnerApprovalCheck.isSubmittable(
-                    /** changeNotes = */
-                    null));
+            () -> codeOwnerApprovalCheck.isSubmittable(/* changeNotes= */ null));
     assertThat(npe).hasMessageThat().isEqualTo("changeNotes");
   }
 
@@ -1628,21 +1483,8 @@
   public void isSubmittable() throws Exception {
     TestAccount user2 = accountCreator.user2();
 
-    codeOwnerConfigOperations
-        .newCodeOwnerConfig()
-        .project(project)
-        .branch("master")
-        .folderPath("/foo/")
-        .addCodeOwnerEmail(user.email())
-        .create();
-
-    codeOwnerConfigOperations
-        .newCodeOwnerConfig()
-        .project(project)
-        .branch("master")
-        .folderPath("/bar/")
-        .addCodeOwnerEmail(user2.email())
-        .create();
+    setAsCodeOwners("/foo/", user);
+    setAsCodeOwners("/bar/", user2);
 
     String changeId =
         pushFactory
@@ -1704,18 +1546,60 @@
   }
 
   @Test
+  @GerritConfig(
+      name = "plugin.code-owners.overrideApproval",
+      values = {"Owners-Override+1", "Another-Override+1"})
+  public void isSubmittableIfAnyOverrideIsPresent() throws Exception {
+    // create arbitrary code owner config to avoid entering the bootstrapping code path in
+    // CodeOwnerApprovalCheck
+    createArbitraryCodeOwnerConfigFile();
+
+    createOwnersOverrideLabel();
+    createOwnersOverrideLabel("Another-Override");
+
+    // Create a change.
+    String changeId =
+        pushFactory
+            .create(
+                admin.newIdent(),
+                testRepo,
+                "Test Change",
+                ImmutableMap.of(
+                    "foo/baz.config", "content",
+                    "bar/baz.config", "other content"))
+            .to("refs/for/master")
+            .getChangeId();
+
+    // Without override approval the change is not submittable.
+    assertThat(codeOwnerApprovalCheck.isSubmittable(getChangeNotes(changeId))).isFalse();
+
+    // Add an override approval.
+    gApi.changes().id(changeId).current().review(new ReviewInput().label("Owners-Override", 1));
+
+    // With override approval the change is submittable.
+    assertThat(codeOwnerApprovalCheck.isSubmittable(getChangeNotes(changeId))).isTrue();
+
+    // Delete the override approval.
+    gApi.changes().id(changeId).current().review(new ReviewInput().label("Owners-Override", 0));
+
+    // Without override approval the change is not submittable.
+    assertThat(codeOwnerApprovalCheck.isSubmittable(getChangeNotes(changeId))).isFalse();
+
+    // Add another override approval.
+    gApi.changes().id(changeId).current().review(new ReviewInput().label("Another-Override", 1));
+
+    // With override approval the change is submittable.
+    assertThat(codeOwnerApprovalCheck.isSubmittable(getChangeNotes(changeId))).isTrue();
+  }
+
+  @Test
   public void bootstrappingGetStatus_insufficientReviewers() throws Exception {
     // since no code owner config exists we are entering the bootstrapping code path in
     // CodeOwnerApprovalCheck
 
     TestAccount user2 = accountCreator.user2();
     TestAccount user3 =
-        accountCreator.create(
-            "user3",
-            "user3@example.com",
-            "User3",
-            /** displayName = */
-            null);
+        accountCreator.create("user3", "user3@example.com", "User3", /* displayName= */ null);
 
     // Create change with a user that is not a project owner.
     Path path = Paths.get("/foo/bar.baz");
@@ -1813,16 +1697,14 @@
   @Test
   public void bootstrappingGetStatus_noImplicitApprovalByPatchSetUploader() throws Exception {
     testImplicitApprovalByPatchSetUploaderOnBootstrappingGetStatus(
-        /** implicitApprovalsEnabled = */
-        false);
+        /* implicitApprovalsEnabled= */ false);
   }
 
   @Test
   @GerritConfig(name = "plugin.code-owners.enableImplicitApprovals", value = "true")
   public void bootstrappingGetStatus_withImplicitApprovalByPatchSetUploader() throws Exception {
     testImplicitApprovalByPatchSetUploaderOnBootstrappingGetStatus(
-        /** implicitApprovalsEnabled = */
-        true);
+        /* implicitApprovalsEnabled= */ true);
   }
 
   private void testImplicitApprovalByPatchSetUploaderOnBootstrappingGetStatus(
@@ -1937,6 +1819,90 @@
   }
 
   @Test
+  @GerritConfig(
+      name = "plugin.code-owners.overrideApproval",
+      values = {"Owners-Override+1", "Another-Override+1"})
+  public void bootstrappingGetStatus_anyOverrideApprovesAllFiles() throws Exception {
+    // since no code owner config exists we are entering the bootstrapping code path in
+    // CodeOwnerApprovalCheck
+
+    createOwnersOverrideLabel();
+    createOwnersOverrideLabel("Another-Override");
+
+    // Create a change with a user that is not a project owner.
+    TestRepository<InMemoryRepository> testRepo = cloneProject(project, user);
+    String changeId =
+        pushFactory
+            .create(
+                user.newIdent(),
+                testRepo,
+                "Test Change",
+                ImmutableMap.of(
+                    "foo/baz.config", "content",
+                    "bar/baz.config", "other content"))
+            .to("refs/for/master")
+            .getChangeId();
+
+    // Without override approval the expected status is INSUFFICIENT_REVIEWERS.
+    for (FileCodeOwnerStatus fileCodeOwnerStatus :
+        codeOwnerApprovalCheck
+            .getFileStatuses(getChangeNotes(changeId))
+            .collect(toImmutableList())) {
+      assertThat(fileCodeOwnerStatus)
+          .hasNewPathStatus()
+          .value()
+          .hasStatusThat()
+          .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+    }
+
+    // Add an override approval (by a user that is not a project owners, and hence no code owner).
+    requestScopeOperations.setApiUser(user.id());
+    gApi.changes().id(changeId).current().review(new ReviewInput().label("Owners-Override", 1));
+
+    // With override approval the expected status is APPROVED.
+    for (FileCodeOwnerStatus fileCodeOwnerStatus :
+        codeOwnerApprovalCheck
+            .getFileStatuses(getChangeNotes(changeId))
+            .collect(toImmutableList())) {
+      assertThat(fileCodeOwnerStatus)
+          .hasNewPathStatus()
+          .value()
+          .hasStatusThat()
+          .isEqualTo(CodeOwnerStatus.APPROVED);
+    }
+
+    // Delete the override approval.
+    gApi.changes().id(changeId).current().review(new ReviewInput().label("Owners-Override", 0));
+
+    // Without override approval the expected status is INSUFFICIENT_REVIEWERS.
+    for (FileCodeOwnerStatus fileCodeOwnerStatus :
+        codeOwnerApprovalCheck
+            .getFileStatuses(getChangeNotes(changeId))
+            .collect(toImmutableList())) {
+      assertThat(fileCodeOwnerStatus)
+          .hasNewPathStatus()
+          .value()
+          .hasStatusThat()
+          .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+    }
+
+    // Add another override approval.
+    gApi.changes().id(changeId).current().review(new ReviewInput().label("Another-Override", 1));
+
+    // With override approval the expected status is APPROVED.
+    for (FileCodeOwnerStatus fileCodeOwnerStatus :
+        codeOwnerApprovalCheck
+            .getFileStatuses(getChangeNotes(changeId))
+            .collect(toImmutableList())) {
+      assertThat(fileCodeOwnerStatus)
+          .hasNewPathStatus()
+          .value()
+          .hasStatusThat()
+          .isEqualTo(CodeOwnerStatus.APPROVED);
+    }
+  }
+
+  @Test
   public void getStatus_branchDeleted() throws Exception {
     String branchName = "tempBranch";
     createBranch(BranchNameKey.create(project, branchName));
@@ -1959,14 +1925,7 @@
   public void approvedByStickyApprovalOnOldPatchSet() throws Exception {
     TestAccount user2 = accountCreator.user2();
 
-    // Create a code owner config file with 'user' as code owner
-    codeOwnerConfigOperations
-        .newCodeOwnerConfig()
-        .project(project)
-        .branch("master")
-        .folderPath("/")
-        .addCodeOwnerEmail(user.email())
-        .create();
+    setAsRootCodeOwners(user);
 
     // Create a change as a user that is not a code owner.
     Path path = Paths.get("/foo/bar.baz");
@@ -2033,18 +1992,82 @@
   }
 
   @Test
+  @GerritConfig(name = "plugin.code-owners.overrideApproval", value = "Owners-Override+1")
+  public void overridenByStickyApprovalOnOldPatchSet() throws Exception {
+    createOwnersOverrideLabel();
+
+    // make the override label sticky
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.copyAnyScore = true;
+    gApi.projects().name(project.get()).label("Owners-Override").update(input);
+
+    // create arbitrary code owner config to avoid entering the bootstrapping code path in
+    // CodeOwnerApprovalCheck
+    createArbitraryCodeOwnerConfigFile();
+
+    // Create a change as a user that is not a code owner.
+    Path path = Paths.get("/foo/bar.baz");
+    String changeId =
+        createChange(user, "Change Adding A File", JgitPath.of(path).get(), "file content")
+            .getChangeId();
+
+    // Verify that the file is not approved yet.
+    Stream<FileCodeOwnerStatus> fileCodeOwnerStatuses =
+        codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
+    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
+        assertThatStream(fileCodeOwnerStatuses).onlyElement();
+    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
+    fileCodeOwnerStatusSubject
+        .hasNewPathStatus()
+        .value()
+        .hasStatusThat()
+        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+
+    // Apply an override
+    gApi.changes().id(changeId).current().review(new ReviewInput().label("Owners-Override", 1));
+
+    // Check that the file is approved now.
+    requestScopeOperations.setApiUser(admin.id());
+    fileCodeOwnerStatuses = codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
+    fileCodeOwnerStatusSubject = assertThatStream(fileCodeOwnerStatuses).onlyElement();
+    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
+    fileCodeOwnerStatusSubject
+        .hasNewPathStatus()
+        .value()
+        .hasStatusThat()
+        .isEqualTo(CodeOwnerStatus.APPROVED);
+
+    // Change some other file and submit the change with an override.
+    String changeId2 =
+        createChange(user, "Change Other File", "other.txt", "file content").getChangeId();
+    approve(changeId2);
+    gApi.changes().id(changeId2).current().review(new ReviewInput().label("Owners-Override", 1));
+    gApi.changes().id(changeId2).current().submit();
+
+    // Rebase the first change (trivial rebase).
+    gApi.changes().id(changeId).rebase();
+
+    // Check that the override is still there (since Owners-Override is sticky).
+    assertThat(gApi.changes().id(changeId).get().labels.get("Owners-Override").approved.email)
+        .isEqualTo(admin.email());
+
+    // Check that the file is still approved.
+    fileCodeOwnerStatuses = codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
+    fileCodeOwnerStatusSubject = assertThatStream(fileCodeOwnerStatuses).onlyElement();
+    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
+    fileCodeOwnerStatusSubject
+        .hasNewPathStatus()
+        .value()
+        .hasStatusThat()
+        .isEqualTo(CodeOwnerStatus.APPROVED);
+  }
+
+  @Test
   @GerritConfig(name = "plugin.code-owners.requiredApproval", value = "Code-Review+1")
   public void codeReviewPlus2CountsAsApprovalIfCodeReviewPlus1IsRequired() throws Exception {
     TestAccount user2 = accountCreator.user2();
 
-    // Create a code owner config file with 'user' as code owner
-    codeOwnerConfigOperations
-        .newCodeOwnerConfig()
-        .project(project)
-        .branch("master")
-        .folderPath("/")
-        .addCodeOwnerEmail(user.email())
-        .create();
+    setAsRootCodeOwners(user);
 
     // Create a change as 'user2' that is not a code owner.
     Path path = Paths.get("/foo/bar.baz");
@@ -2086,17 +2109,67 @@
   }
 
   @Test
+  @GerritConfig(name = "plugin.code-owners.overrideApproval", value = "Owners-Override+1")
+  public void ownersOverridePlus2CountsAsOverrideIfOverridePlus1IsRequired() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("+2", "Override+2", "+1", "Override", " 0", "No Override");
+    gApi.projects().name(project.get()).label("Owners-Override").create(input).get();
+
+    // Allow to vote on the Owners-Override label.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            TestProjectUpdate.allowLabel("Owners-Override")
+                .range(0, 2)
+                .ref("refs/*")
+                .group(REGISTERED_USERS)
+                .build())
+        .update();
+
+    TestAccount user2 = accountCreator.user2();
+
+    setAsRootCodeOwners(admin);
+
+    // Create a change as 'user' that is not a code owner.
+    Path path = Paths.get("/foo/bar.baz");
+    String changeId =
+        createChange(user, "Change Adding A File", JgitPath.of(path).get(), "file content")
+            .getChangeId();
+
+    // Verify that the file is not approved yet.
+    Stream<FileCodeOwnerStatus> fileCodeOwnerStatuses =
+        codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
+    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
+        assertThatStream(fileCodeOwnerStatuses).onlyElement();
+    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
+    fileCodeOwnerStatusSubject
+        .hasNewPathStatus()
+        .value()
+        .hasStatusThat()
+        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+
+    // Let 'user2' override with Owners-Override+2
+    requestScopeOperations.setApiUser(user2.id());
+    gApi.changes().id(changeId).current().review(new ReviewInput().label("Owners-Override", 2));
+
+    // Check that the file is approved now.
+    requestScopeOperations.setApiUser(admin.id());
+    fileCodeOwnerStatuses = codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
+    fileCodeOwnerStatusSubject = assertThatStream(fileCodeOwnerStatuses).onlyElement();
+    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
+    fileCodeOwnerStatusSubject
+        .hasNewPathStatus()
+        .value()
+        .hasStatusThat()
+        .isEqualTo(CodeOwnerStatus.APPROVED);
+  }
+
+  @Test
   public void noBootstrappingIfDefaultCodeOwnerConfigExists() throws Exception {
     TestAccount user2 = accountCreator.user2();
 
-    // Create default code owner config file in refs/meta/config.
-    codeOwnerConfigOperations
-        .newCodeOwnerConfig()
-        .project(project)
-        .branch(RefNames.REFS_CONFIG)
-        .folderPath("/")
-        .addCodeOwnerEmail(user.email())
-        .create();
+    setAsDefaultCodeOwners(user);
 
     // Create a change as a user that is neither a code owner nor a project owner.
     Path path = Paths.get("/foo/bar.baz");
@@ -2156,14 +2229,7 @@
   public void approvedByDefaultCodeOwner() throws Exception {
     TestAccount user2 = accountCreator.user2();
 
-    // Create default code owner config file in refs/meta/config.
-    codeOwnerConfigOperations
-        .newCodeOwnerConfig()
-        .project(project)
-        .branch(RefNames.REFS_CONFIG)
-        .folderPath("/")
-        .addCodeOwnerEmail(user.email())
-        .create();
+    setAsDefaultCodeOwners(user);
 
     // Create a change as a user that is not a code owner.
     Path path = Paths.get("/foo/bar.baz");
@@ -2206,29 +2272,18 @@
 
   @Test
   public void defaultCodeOwner_noImplicitApproval() throws Exception {
-    testImplicitlyApprovedByDefaultCodeOwner(
-        /** implicitApprovalsEnabled = */
-        false);
+    testImplicitlyApprovedByDefaultCodeOwner(/* implicitApprovalsEnabled= */ false);
   }
 
   @Test
   @GerritConfig(name = "plugin.code-owners.enableImplicitApprovals", value = "true")
   public void defaultCodeOwner_withImplicitApproval() throws Exception {
-    testImplicitlyApprovedByDefaultCodeOwner(
-        /** implicitApprovalsEnabled = */
-        true);
+    testImplicitlyApprovedByDefaultCodeOwner(/* implicitApprovalsEnabled= */ true);
   }
 
   private void testImplicitlyApprovedByDefaultCodeOwner(boolean implicitApprovalsEnabled)
       throws Exception {
-    // Create default code owner config file in refs/meta/config.
-    codeOwnerConfigOperations
-        .newCodeOwnerConfig()
-        .project(project)
-        .branch(RefNames.REFS_CONFIG)
-        .folderPath("/")
-        .addCodeOwnerEmail(user.email())
-        .create();
+    setAsDefaultCodeOwners(user);
 
     Path path = Paths.get("/foo/bar.baz");
     String changeId =
@@ -2254,14 +2309,7 @@
   public void defaultCodeOwnerAsReviewer() throws Exception {
     TestAccount user2 = accountCreator.user2();
 
-    // Create default code owner config file in refs/meta/config.
-    codeOwnerConfigOperations
-        .newCodeOwnerConfig()
-        .project(project)
-        .branch(RefNames.REFS_CONFIG)
-        .folderPath("/")
-        .addCodeOwnerEmail(user.email())
-        .create();
+    setAsDefaultCodeOwners(user);
 
     // Create a change as a user that is not a code owner.
     Path path = Paths.get("/foo/bar.baz");
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheckWithAllUsersAsFallbackCodeOwnersTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheckWithAllUsersAsFallbackCodeOwnersTest.java
new file mode 100644
index 0000000..d2d2c5b
--- /dev/null
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheckWithAllUsersAsFallbackCodeOwnersTest.java
@@ -0,0 +1,654 @@
+// 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.
+
+package com.google.gerrit.plugins.codeowners.backend;
+
+import static com.google.gerrit.plugins.codeowners.testing.FileCodeOwnerStatusSubject.assertThatStream;
+
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.config.GerritConfig;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.plugins.codeowners.JgitPath;
+import com.google.gerrit.plugins.codeowners.acceptance.AbstractCodeOwnersTest;
+import com.google.gerrit.plugins.codeowners.acceptance.testsuite.CodeOwnerConfigOperations;
+import com.google.gerrit.plugins.codeowners.api.CodeOwnerStatus;
+import com.google.gerrit.plugins.codeowners.config.GeneralConfig;
+import com.google.gerrit.plugins.codeowners.testing.FileCodeOwnerStatusSubject;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.testing.ConfigSuite;
+import com.google.inject.Inject;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.stream.Stream;
+import org.eclipse.jgit.lib.Config;
+import org.junit.Before;
+import org.junit.Test;
+
+/** Tests for {@link CodeOwnerApprovalCheck} with fallback code owners. */
+public class CodeOwnerApprovalCheckWithAllUsersAsFallbackCodeOwnersTest
+    extends AbstractCodeOwnersTest {
+  @Inject private ChangeNotes.Factory changeNotesFactory;
+  @Inject private RequestScopeOperations requestScopeOperations;
+
+  private CodeOwnerApprovalCheck codeOwnerApprovalCheck;
+  private CodeOwnerConfigOperations codeOwnerConfigOperations;
+
+  /** Returns a {@code gerrit.config} that configures all users as fallback code owners. */
+  @ConfigSuite.Default
+  public static Config defaultConfig() {
+    Config cfg = new Config();
+    cfg.setEnum(
+        "plugin",
+        "code-owners",
+        GeneralConfig.KEY_FALLBACK_CODE_OWNERS,
+        FallbackCodeOwners.ALL_USERS);
+    return cfg;
+  }
+
+  @Before
+  public void setUpCodeOwnersPlugin() throws Exception {
+    codeOwnerApprovalCheck = plugin.getSysInjector().getInstance(CodeOwnerApprovalCheck.class);
+    codeOwnerConfigOperations =
+        plugin.getSysInjector().getInstance(CodeOwnerConfigOperations.class);
+  }
+
+  @Test
+  public void notApprovedByFallbackCodeOwnerIfCodeOwnersDefined() throws Exception {
+    TestAccount codeOwner =
+        accountCreator.create(
+            "codeOwner", "codeOwner@example.com", "CodeOwner", /* displayName= */ null);
+    setAsRootCodeOwners(codeOwner);
+
+    Path path = Paths.get("/foo/bar.baz");
+    String changeId =
+        createChange("Change Adding A File", JgitPath.of(path).get(), "file content").getChangeId();
+
+    // Verify that the file is not approved yet.
+    Stream<FileCodeOwnerStatus> fileCodeOwnerStatuses =
+        codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
+    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
+        assertThatStream(fileCodeOwnerStatuses).onlyElement();
+    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
+    fileCodeOwnerStatusSubject
+        .hasNewPathStatus()
+        .value()
+        .hasStatusThat()
+        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+
+    // Add a fallback code owner as reviewer.
+    gApi.changes().id(changeId).addReviewer(user.email());
+
+    // Verify that the file is not approved (fallback code owner doesn't apply since a code owner is
+    // defined).
+    fileCodeOwnerStatuses = codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
+    fileCodeOwnerStatusSubject = assertThatStream(fileCodeOwnerStatuses).onlyElement();
+    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
+    fileCodeOwnerStatusSubject
+        .hasNewPathStatus()
+        .value()
+        .hasStatusThat()
+        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+
+    // Add a Code-Review+1 (= code owner approval) from a fallback code owner.
+    requestScopeOperations.setApiUser(user.id());
+    recommend(changeId);
+
+    // Verify that the file is not approved (fallback code owner doesn't apply since a code owner is
+    // defined).
+    fileCodeOwnerStatuses = codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
+    fileCodeOwnerStatusSubject = assertThatStream(fileCodeOwnerStatuses).onlyElement();
+    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
+    fileCodeOwnerStatusSubject
+        .hasNewPathStatus()
+        .value()
+        .hasStatusThat()
+        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.enableImplicitApprovals", value = "true")
+  public void notImplicitlyApprovedByFallbackCodeOwnerIfCodeOwnersDefined() throws Exception {
+    TestAccount codeOwner =
+        accountCreator.create(
+            "codeOwner", "codeOwner@example.com", "CodeOwner", /* displayName= */ null);
+    setAsRootCodeOwners(codeOwner);
+
+    Path path = Paths.get("/foo/bar.baz");
+    String changeId =
+        createChange("Change Adding A File", JgitPath.of(path).get(), "file content").getChangeId();
+
+    // Verify that the file is not approved yet.
+    Stream<FileCodeOwnerStatus> fileCodeOwnerStatuses =
+        codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
+    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
+        assertThatStream(fileCodeOwnerStatuses).onlyElement();
+    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
+    fileCodeOwnerStatusSubject
+        .hasNewPathStatus()
+        .value()
+        .hasStatusThat()
+        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+  }
+
+  @Test
+  public void notApprovedByFallbackCodeOwnerIfNonResolvableCodeOwnersDefined() throws Exception {
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/foo/")
+        .addCodeOwnerEmail("non-resolvable-code-owner@example.com")
+        .create();
+
+    Path path = Paths.get("/foo/bar.baz");
+    String changeId =
+        createChange("Change Adding A File", JgitPath.of(path).get(), "file content").getChangeId();
+
+    // Verify that the file is not approved yet.
+    Stream<FileCodeOwnerStatus> fileCodeOwnerStatuses =
+        codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
+    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
+        assertThatStream(fileCodeOwnerStatuses).onlyElement();
+    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
+    fileCodeOwnerStatusSubject
+        .hasNewPathStatus()
+        .value()
+        .hasStatusThat()
+        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+
+    // Add a fallback code owner as reviewer.
+    gApi.changes().id(changeId).addReviewer(user.email());
+
+    // Verify that the file is not approved (fallback code owner doesn't apply since a code owner is
+    // defined).
+    fileCodeOwnerStatuses = codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
+    fileCodeOwnerStatusSubject = assertThatStream(fileCodeOwnerStatuses).onlyElement();
+    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
+    fileCodeOwnerStatusSubject
+        .hasNewPathStatus()
+        .value()
+        .hasStatusThat()
+        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+
+    // Add a Code-Review+1 (= code owner approval) from a fallback code owner.
+    requestScopeOperations.setApiUser(user.id());
+    recommend(changeId);
+
+    // Verify that the file is not approved (fallback code owner doesn't apply since a code owner is
+    // defined).
+    fileCodeOwnerStatuses = codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
+    fileCodeOwnerStatusSubject = assertThatStream(fileCodeOwnerStatuses).onlyElement();
+    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
+    fileCodeOwnerStatusSubject
+        .hasNewPathStatus()
+        .value()
+        .hasStatusThat()
+        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.enableImplicitApprovals", value = "true")
+  public void notImplicitlyApprovedByFallbackCodeOwnerIfNonResolvableCodeOwnersDefined()
+      throws Exception {
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/foo/")
+        .addCodeOwnerEmail("non-resolvable-code-owner@example.com")
+        .create();
+
+    Path path = Paths.get("/foo/bar.baz");
+    String changeId =
+        createChange("Change Adding A File", JgitPath.of(path).get(), "file content").getChangeId();
+
+    // Verify that the file is not approved yet.
+    Stream<FileCodeOwnerStatus> fileCodeOwnerStatuses =
+        codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
+    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
+        assertThatStream(fileCodeOwnerStatuses).onlyElement();
+    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
+    fileCodeOwnerStatusSubject
+        .hasNewPathStatus()
+        .value()
+        .hasStatusThat()
+        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+  }
+
+  @Test
+  public void approvedByFallbackCodeOwner() throws Exception {
+    // create arbitrary code owner config to avoid entering the bootstrapping code path in
+    // CodeOwnerApprovalCheck
+    createArbitraryCodeOwnerConfigFile();
+
+    Path path = Paths.get("/foo/bar.baz");
+    String changeId =
+        createChange("Change Adding A File", JgitPath.of(path).get(), "file content").getChangeId();
+
+    // Verify that the file is not approved yet (the change owner is a code owner, but
+    // implicit approvals are disabled).
+    Stream<FileCodeOwnerStatus> fileCodeOwnerStatuses =
+        codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
+    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
+        assertThatStream(fileCodeOwnerStatuses).onlyElement();
+    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
+    fileCodeOwnerStatusSubject
+        .hasNewPathStatus()
+        .value()
+        .hasStatusThat()
+        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+
+    // Add a user a fallback code owner as reviewer.
+    gApi.changes().id(changeId).addReviewer(user.email());
+
+    // Verify that the status is pending now .
+    fileCodeOwnerStatuses = codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
+    fileCodeOwnerStatusSubject = assertThatStream(fileCodeOwnerStatuses).onlyElement();
+    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
+    fileCodeOwnerStatusSubject
+        .hasNewPathStatus()
+        .value()
+        .hasStatusThat()
+        .isEqualTo(CodeOwnerStatus.PENDING);
+
+    // Add a Code-Review+1 (= code owner approval) from a fallback code owner.
+    requestScopeOperations.setApiUser(user.id());
+    recommend(changeId);
+
+    // Verify that the status is approved now
+    fileCodeOwnerStatuses = codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
+    fileCodeOwnerStatusSubject = assertThatStream(fileCodeOwnerStatuses).onlyElement();
+    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
+    fileCodeOwnerStatusSubject
+        .hasNewPathStatus()
+        .value()
+        .hasStatusThat()
+        .isEqualTo(CodeOwnerStatus.APPROVED);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.enableImplicitApprovals", value = "true")
+  public void implicitlyApprovedByFallbackCodeOwner() throws Exception {
+    // create arbitrary code owner config to avoid entering the bootstrapping code path in
+    // CodeOwnerApprovalCheck
+    createArbitraryCodeOwnerConfigFile();
+
+    Path path = Paths.get("/foo/bar.baz");
+    String changeId =
+        createChange("Change Adding A File", JgitPath.of(path).get(), "file content").getChangeId();
+
+    // Verify that the file is approved (the change owner is a code owner and implicit approvals are
+    // enabled).
+    Stream<FileCodeOwnerStatus> fileCodeOwnerStatuses =
+        codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
+    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
+        assertThatStream(fileCodeOwnerStatuses).onlyElement();
+    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
+    fileCodeOwnerStatusSubject
+        .hasNewPathStatus()
+        .value()
+        .hasStatusThat()
+        .isEqualTo(CodeOwnerStatus.APPROVED);
+  }
+
+  @Test
+  public void approvedByFallbackCodeOwner_bootstrappingMode() throws Exception {
+    // since no code owner config exists we are entering the bootstrapping code path in
+    // CodeOwnerApprovalCheck
+
+    TestAccount user2 = accountCreator.user2();
+
+    Path path = Paths.get("/foo/bar.baz");
+    String changeId =
+        createChange(user, "Change Adding A File", JgitPath.of(path).get(), "file content")
+            .getChangeId();
+
+    // Verify that the file is not approved yet (the change owner is a code owner, but
+    // implicit approvals are disabled).
+    Stream<FileCodeOwnerStatus> fileCodeOwnerStatuses =
+        codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
+    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
+        assertThatStream(fileCodeOwnerStatuses).onlyElement();
+    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
+    fileCodeOwnerStatusSubject
+        .hasNewPathStatus()
+        .value()
+        .hasStatusThat()
+        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+
+    // Add a user a fallback code owner as reviewer.
+    gApi.changes().id(changeId).addReviewer(user2.email());
+
+    // Verify that the status is pending now .
+    fileCodeOwnerStatuses = codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
+    fileCodeOwnerStatusSubject = assertThatStream(fileCodeOwnerStatuses).onlyElement();
+    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
+    fileCodeOwnerStatusSubject
+        .hasNewPathStatus()
+        .value()
+        .hasStatusThat()
+        .isEqualTo(CodeOwnerStatus.PENDING);
+
+    // Add a Code-Review+1 (= code owner approval) from a fallback code owner.
+    requestScopeOperations.setApiUser(user2.id());
+    recommend(changeId);
+
+    // Verify that the status is approved now
+    fileCodeOwnerStatuses = codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
+    fileCodeOwnerStatusSubject = assertThatStream(fileCodeOwnerStatuses).onlyElement();
+    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
+    fileCodeOwnerStatusSubject
+        .hasNewPathStatus()
+        .value()
+        .hasStatusThat()
+        .isEqualTo(CodeOwnerStatus.APPROVED);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.enableImplicitApprovals", value = "true")
+  public void implicitlyApprovedByFallbackCodeOwner_bootstrappingMode() throws Exception {
+    // since no code owner config exists we are entering the bootstrapping code path in
+    // CodeOwnerApprovalCheck
+
+    Path path = Paths.get("/foo/bar.baz");
+    String changeId =
+        createChange(user, "Change Adding A File", JgitPath.of(path).get(), "file content")
+            .getChangeId();
+
+    // Verify that the file is approved (the change owner is a code owner and implicit approvals are
+    // enabled).
+    Stream<FileCodeOwnerStatus> fileCodeOwnerStatuses =
+        codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
+    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
+        assertThatStream(fileCodeOwnerStatuses).onlyElement();
+    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
+    fileCodeOwnerStatusSubject
+        .hasNewPathStatus()
+        .value()
+        .hasStatusThat()
+        .isEqualTo(CodeOwnerStatus.APPROVED);
+  }
+
+  @Test
+  public void notApprovedByFallbackCodeOwnerIfParentCodeOwnersIgnored() throws Exception {
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/foo/")
+        .ignoreParentCodeOwners()
+        .create();
+
+    Path path = Paths.get("/foo/bar.baz");
+    String changeId =
+        createChange("Change Adding A File", JgitPath.of(path).get(), "file content").getChangeId();
+
+    // Verify that the file is not approved yet.
+    Stream<FileCodeOwnerStatus> fileCodeOwnerStatuses =
+        codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
+    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
+        assertThatStream(fileCodeOwnerStatuses).onlyElement();
+    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
+    fileCodeOwnerStatusSubject
+        .hasNewPathStatus()
+        .value()
+        .hasStatusThat()
+        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+
+    // Add a fallback code owner as reviewer.
+    gApi.changes().id(changeId).addReviewer(user.email());
+
+    // Verify that the file is not approved (fallback code owner doesn't apply since a code owner is
+    // defined).
+    fileCodeOwnerStatuses = codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
+    fileCodeOwnerStatusSubject = assertThatStream(fileCodeOwnerStatuses).onlyElement();
+    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
+    fileCodeOwnerStatusSubject
+        .hasNewPathStatus()
+        .value()
+        .hasStatusThat()
+        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+
+    // Add a Code-Review+1 (= code owner approval) from a fallback code owner.
+    requestScopeOperations.setApiUser(user.id());
+    recommend(changeId);
+
+    // Verify that the file is not approved (fallback code owner doesn't apply since a code owner is
+    // defined).
+    fileCodeOwnerStatuses = codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
+    fileCodeOwnerStatusSubject = assertThatStream(fileCodeOwnerStatuses).onlyElement();
+    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
+    fileCodeOwnerStatusSubject
+        .hasNewPathStatus()
+        .value()
+        .hasStatusThat()
+        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.enableImplicitApprovals", value = "true")
+  public void notImplicitlyApprovedByFallbackCodeOwnerIfParentCodeOwnersIgnored() throws Exception {
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/foo/")
+        .ignoreParentCodeOwners()
+        .create();
+
+    Path path = Paths.get("/foo/bar.baz");
+    String changeId =
+        createChange("Change Adding A File", JgitPath.of(path).get(), "file content").getChangeId();
+
+    // Verify that the file is not approved yet.
+    Stream<FileCodeOwnerStatus> fileCodeOwnerStatuses =
+        codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
+    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
+        assertThatStream(fileCodeOwnerStatuses).onlyElement();
+    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
+    fileCodeOwnerStatusSubject
+        .hasNewPathStatus()
+        .value()
+        .hasStatusThat()
+        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+  }
+
+  @Test
+  public void notApprovedByFallbackCodeOwnerIfImportCannotBeResolved() throws Exception {
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/foo/")
+        .addImport(
+            CodeOwnerConfigReference.create(CodeOwnerConfigImportMode.ALL, "/non-existing/OWNERS"))
+        .create();
+
+    Path path = Paths.get("/foo/bar.baz");
+    String changeId =
+        createChange("Change Adding A File", JgitPath.of(path).get(), "file content").getChangeId();
+
+    // Verify that the file is not approved yet.
+    Stream<FileCodeOwnerStatus> fileCodeOwnerStatuses =
+        codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
+    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
+        assertThatStream(fileCodeOwnerStatuses).onlyElement();
+    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
+    fileCodeOwnerStatusSubject
+        .hasNewPathStatus()
+        .value()
+        .hasStatusThat()
+        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+
+    // Add a fallback code owner as reviewer.
+    gApi.changes().id(changeId).addReviewer(user.email());
+
+    // Verify that the file is not approved (fallback code owner doesn't apply since a code owner is
+    // defined).
+    fileCodeOwnerStatuses = codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
+    fileCodeOwnerStatusSubject = assertThatStream(fileCodeOwnerStatuses).onlyElement();
+    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
+    fileCodeOwnerStatusSubject
+        .hasNewPathStatus()
+        .value()
+        .hasStatusThat()
+        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+
+    // Add a Code-Review+1 (= code owner approval) from a fallback code owner.
+    requestScopeOperations.setApiUser(user.id());
+    recommend(changeId);
+
+    // Verify that the file is not approved (fallback code owner doesn't apply since a code owner is
+    // defined).
+    fileCodeOwnerStatuses = codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
+    fileCodeOwnerStatusSubject = assertThatStream(fileCodeOwnerStatuses).onlyElement();
+    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
+    fileCodeOwnerStatusSubject
+        .hasNewPathStatus()
+        .value()
+        .hasStatusThat()
+        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.enableImplicitApprovals", value = "true")
+  public void notImplicitlyApprovedByFallbackCodeOwnerIfImportCannotBeResolved() throws Exception {
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/foo/")
+        .addImport(
+            CodeOwnerConfigReference.create(CodeOwnerConfigImportMode.ALL, "/non-existing/OWNERS"))
+        .create();
+
+    Path path = Paths.get("/foo/bar.baz");
+    String changeId =
+        createChange("Change Adding A File", JgitPath.of(path).get(), "file content").getChangeId();
+
+    // Verify that the file is not approved yet.
+    Stream<FileCodeOwnerStatus> fileCodeOwnerStatuses =
+        codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
+    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
+        assertThatStream(fileCodeOwnerStatuses).onlyElement();
+    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
+    fileCodeOwnerStatusSubject
+        .hasNewPathStatus()
+        .value()
+        .hasStatusThat()
+        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+  }
+
+  @Test
+  public void notApprovedByFallbackCodeOwnerIfPerFileImportCannotBeResolved() throws Exception {
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/foo/")
+        .addCodeOwnerSet(
+            CodeOwnerSet.builder()
+                .addPathExpression("*.md")
+                .addImport(
+                    CodeOwnerConfigReference.create(
+                        CodeOwnerConfigImportMode.GLOBAL_CODE_OWNER_SETS_ONLY, "/bar/OWNERS"))
+                .autoBuild())
+        .create();
+
+    Path path = Paths.get("/foo/bar.md");
+    String changeId =
+        createChange("Change Adding A File", JgitPath.of(path).get(), "file content").getChangeId();
+
+    // Verify that the file is not approved yet.
+    Stream<FileCodeOwnerStatus> fileCodeOwnerStatuses =
+        codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
+    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
+        assertThatStream(fileCodeOwnerStatuses).onlyElement();
+    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
+    fileCodeOwnerStatusSubject
+        .hasNewPathStatus()
+        .value()
+        .hasStatusThat()
+        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+
+    // Add a fallback code owner as reviewer.
+    gApi.changes().id(changeId).addReviewer(user.email());
+
+    // Verify that the file is not approved (fallback code owner doesn't apply since a code owner is
+    // defined).
+    fileCodeOwnerStatuses = codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
+    fileCodeOwnerStatusSubject = assertThatStream(fileCodeOwnerStatuses).onlyElement();
+    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
+    fileCodeOwnerStatusSubject
+        .hasNewPathStatus()
+        .value()
+        .hasStatusThat()
+        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+
+    // Add a Code-Review+1 (= code owner approval) from a fallback code owner.
+    requestScopeOperations.setApiUser(user.id());
+    recommend(changeId);
+
+    // Verify that the file is not approved (fallback code owner doesn't apply since a code owner is
+    // defined).
+    fileCodeOwnerStatuses = codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
+    fileCodeOwnerStatusSubject = assertThatStream(fileCodeOwnerStatuses).onlyElement();
+    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
+    fileCodeOwnerStatusSubject
+        .hasNewPathStatus()
+        .value()
+        .hasStatusThat()
+        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.enableImplicitApprovals", value = "true")
+  public void notImplicitlyApprovedByFallbackCodeOwnerIfPerFileImportCannotBeResolved()
+      throws Exception {
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/foo/")
+        .addCodeOwnerSet(
+            CodeOwnerSet.builder()
+                .addPathExpression("*.md")
+                .addImport(
+                    CodeOwnerConfigReference.create(
+                        CodeOwnerConfigImportMode.GLOBAL_CODE_OWNER_SETS_ONLY, "/bar/OWNERS"))
+                .autoBuild())
+        .create();
+
+    Path path = Paths.get("/foo/bar.md");
+    String changeId =
+        createChange("Change Adding A File", JgitPath.of(path).get(), "file content").getChangeId();
+
+    // Verify that the file is not approved yet.
+    Stream<FileCodeOwnerStatus> fileCodeOwnerStatuses =
+        codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
+    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
+        assertThatStream(fileCodeOwnerStatuses).onlyElement();
+    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
+    fileCodeOwnerStatusSubject
+        .hasNewPathStatus()
+        .value()
+        .hasStatusThat()
+        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+  }
+
+  private ChangeNotes getChangeNotes(String changeId) throws Exception {
+    return changeNotesFactory.create(project, Change.id(gApi.changes().id(changeId).get()._number));
+  }
+}
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheckWithSelfApprovalsIgnoredTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheckWithSelfApprovalsIgnoredTest.java
new file mode 100644
index 0000000..9ba465a
--- /dev/null
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheckWithSelfApprovalsIgnoredTest.java
@@ -0,0 +1,411 @@
+// 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.
+
+package com.google.gerrit.plugins.codeowners.backend;
+
+import static com.google.gerrit.plugins.codeowners.testing.FileCodeOwnerStatusSubject.assertThatStream;
+
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.config.GerritConfig;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.common.LabelDefinitionInput;
+import com.google.gerrit.plugins.codeowners.JgitPath;
+import com.google.gerrit.plugins.codeowners.acceptance.AbstractCodeOwnersTest;
+import com.google.gerrit.plugins.codeowners.api.CodeOwnerStatus;
+import com.google.gerrit.plugins.codeowners.config.OverrideApprovalConfig;
+import com.google.gerrit.plugins.codeowners.testing.FileCodeOwnerStatusSubject;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.testing.ConfigSuite;
+import com.google.inject.Inject;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.stream.Stream;
+import org.eclipse.jgit.lib.Config;
+import org.junit.Before;
+import org.junit.Test;
+
+public class CodeOwnerApprovalCheckWithSelfApprovalsIgnoredTest extends AbstractCodeOwnersTest {
+  @Inject private ChangeNotes.Factory changeNotesFactory;
+  @Inject private RequestScopeOperations requestScopeOperations;
+
+  private CodeOwnerApprovalCheck codeOwnerApprovalCheck;
+
+  /** Returns a {@code gerrit.config} that configures all users as fallback code owners. */
+  @ConfigSuite.Default
+  public static Config defaultConfig() {
+    Config cfg = new Config();
+    cfg.setString(
+        "plugin", "code-owners", OverrideApprovalConfig.KEY_OVERRIDE_APPROVAL, "Owners-Override+1");
+    return cfg;
+  }
+
+  @Before
+  public void setUpCodeOwnersPlugin() throws Exception {
+    codeOwnerApprovalCheck = plugin.getSysInjector().getInstance(CodeOwnerApprovalCheck.class);
+  }
+
+  @Before
+  public void defineOwnersOverrideLabel() throws Exception {
+    createOwnersOverrideLabel();
+  }
+
+  @Before
+  public void disableSelfApprovals() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.ignoreSelfApproval = true;
+    gApi.projects().name(allProjects.get()).label("Code-Review").update(input);
+    gApi.projects().name(project.get()).label("Owners-Override").update(input);
+  }
+
+  @Test
+  public void notApprovedByUploaderWhoIsChangeOwner() throws Exception {
+    TestAccount codeOwner =
+        accountCreator.create(
+            "codeOwner", "codeOwner@example.com", "CodeOwner", /* displayName= */ null);
+    setAsRootCodeOwners(codeOwner);
+
+    Path path = Paths.get("/foo/bar.baz");
+    String changeId =
+        createChange(codeOwner, "Change Adding A File", JgitPath.of(path).get(), "file content")
+            .getChangeId();
+
+    // Verify that the file is not approved.
+    Stream<FileCodeOwnerStatus> fileCodeOwnerStatuses =
+        codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
+    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
+        assertThatStream(fileCodeOwnerStatuses).onlyElement();
+    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
+    fileCodeOwnerStatusSubject
+        .hasNewPathStatus()
+        .value()
+        .hasStatusThat()
+        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+
+    // Add a self Code-Review+1 (= code owner approval).
+    requestScopeOperations.setApiUser(codeOwner.id());
+    recommend(changeId);
+
+    // Verify that the file is not approved (since self approvals are ignored).
+    fileCodeOwnerStatuses = codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
+    fileCodeOwnerStatusSubject = assertThatStream(fileCodeOwnerStatuses).onlyElement();
+    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
+    fileCodeOwnerStatusSubject
+        .hasNewPathStatus()
+        .value()
+        .hasStatusThat()
+        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+  }
+
+  @Test
+  public void approvedByChangeOwnerThatIsNotUploader() throws Exception {
+    TestAccount changeOwner =
+        accountCreator.create(
+            "changeOwner", "changeOwner@example.com", "ChangeOwner", /* displayName= */ null);
+    setAsRootCodeOwners(changeOwner);
+
+    Path path = Paths.get("/foo/bar.baz");
+    String changeId =
+        createChange(changeOwner, "Change Adding A File", JgitPath.of(path).get(), "file content")
+            .getChangeId();
+
+    // Upload another patch set by another user.
+    amendChange(admin, changeId);
+
+    // Verify that the file is not approved.
+    Stream<FileCodeOwnerStatus> fileCodeOwnerStatuses =
+        codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
+    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
+        assertThatStream(fileCodeOwnerStatuses).onlyElement();
+    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
+    fileCodeOwnerStatusSubject
+        .hasNewPathStatus()
+        .value()
+        .hasStatusThat()
+        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+
+    // Add a Code-Review+1 (= code owner approval) from the change owner.
+    requestScopeOperations.setApiUser(changeOwner.id());
+    recommend(changeId);
+
+    // Verify that the file is approved now (since the change owner is not the uploader of the
+    // current patch set).
+    fileCodeOwnerStatuses = codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
+    fileCodeOwnerStatusSubject = assertThatStream(fileCodeOwnerStatuses).onlyElement();
+    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
+    fileCodeOwnerStatusSubject
+        .hasNewPathStatus()
+        .value()
+        .hasStatusThat()
+        .isEqualTo(CodeOwnerStatus.APPROVED);
+  }
+
+  @Test
+  public void notApprovedByUploader() throws Exception {
+    TestAccount changeOwner =
+        accountCreator.create(
+            "changeOwner", "changeOwner@example.com", "ChangeOwner", /* displayName= */ null);
+
+    TestAccount codeOwner =
+        accountCreator.create(
+            "codeOwner", "codeOwner@example.com", "CodeOwner", /* displayName= */ null);
+    setAsRootCodeOwners(codeOwner);
+
+    Path path = Paths.get("/foo/bar.baz");
+    String changeId =
+        createChange(changeOwner, "Change Adding A File", JgitPath.of(path).get(), "file content")
+            .getChangeId();
+
+    // Upload another patch set by a code owner.
+    amendChange(codeOwner, changeId);
+
+    // Verify that the file is not approved.
+    Stream<FileCodeOwnerStatus> fileCodeOwnerStatuses =
+        codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
+    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
+        assertThatStream(fileCodeOwnerStatuses).onlyElement();
+    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
+    fileCodeOwnerStatusSubject
+        .hasNewPathStatus()
+        .value()
+        .hasStatusThat()
+        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+
+    // Add the code owner as reviewer.
+    gApi.changes().id(changeId).addReviewer(user.email());
+
+    // Verify that the file is not pending (the code owner is the uploader of the current patch set
+    // and self approvals are ignored).
+    fileCodeOwnerStatuses = codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
+    fileCodeOwnerStatusSubject = assertThatStream(fileCodeOwnerStatuses).onlyElement();
+    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
+    fileCodeOwnerStatusSubject
+        .hasNewPathStatus()
+        .value()
+        .hasStatusThat()
+        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+
+    // Add a Code-Review+1 (= code owner approval) by the code owner.
+    requestScopeOperations.setApiUser(codeOwner.id());
+    recommend(changeId);
+
+    // Verify that the file is not approved (since the code owner is the uploader of the current
+    // patch set and self approvals are ignored).
+    fileCodeOwnerStatuses = codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
+    fileCodeOwnerStatusSubject = assertThatStream(fileCodeOwnerStatuses).onlyElement();
+    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
+    fileCodeOwnerStatusSubject
+        .hasNewPathStatus()
+        .value()
+        .hasStatusThat()
+        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.enableImplicitApprovals", value = "true")
+  public void notImplicitlyApprovedByUploaderWhoIsChangeOwner() throws Exception {
+    TestAccount codeOwner =
+        accountCreator.create(
+            "codeOwner", "codeOwner@example.com", "CodeOwner", /* displayName= */ null);
+    setAsRootCodeOwners(codeOwner);
+
+    Path path = Paths.get("/foo/bar.baz");
+    String changeId =
+        createChange(codeOwner, "Change Adding A File", JgitPath.of(path).get(), "file content")
+            .getChangeId();
+
+    // Verify that the file is not approved.
+    Stream<FileCodeOwnerStatus> fileCodeOwnerStatuses =
+        codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
+    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
+        assertThatStream(fileCodeOwnerStatuses).onlyElement();
+    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
+    fileCodeOwnerStatusSubject
+        .hasNewPathStatus()
+        .value()
+        .hasStatusThat()
+        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.enableImplicitApprovals", value = "true")
+  public void notImplicitlyApprovedByUploader() throws Exception {
+    TestAccount changeOwner =
+        accountCreator.create(
+            "changeOwner", "changeOwner@example.com", "ChangeOwner", /* displayName= */ null);
+
+    TestAccount codeOwner =
+        accountCreator.create(
+            "codeOwner", "codeOwner@example.com", "CodeOwner", /* displayName= */ null);
+    setAsRootCodeOwners(codeOwner);
+
+    Path path = Paths.get("/foo/bar.baz");
+    String changeId =
+        createChange(changeOwner, "Change Adding A File", JgitPath.of(path).get(), "file content")
+            .getChangeId();
+
+    // Upload another patch set by a code owner.
+    amendChange(codeOwner, changeId);
+
+    // Verify that the file is not approved.
+    Stream<FileCodeOwnerStatus> fileCodeOwnerStatuses =
+        codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
+    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
+        assertThatStream(fileCodeOwnerStatuses).onlyElement();
+    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
+    fileCodeOwnerStatusSubject
+        .hasNewPathStatus()
+        .value()
+        .hasStatusThat()
+        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+  }
+
+  @Test
+  public void notOverriddenByUploaderWhoIsChangeOwner() throws Exception {
+    // create arbitrary code owner config to avoid entering the bootstrapping code path in
+    // CodeOwnerApprovalCheck
+    createArbitraryCodeOwnerConfigFile();
+
+    TestAccount changeOwner =
+        accountCreator.create(
+            "changeOwner", "changeOwner@example.com", "ChangeOwner", /* displayName= */ null);
+
+    Path path = Paths.get("/foo/bar.baz");
+    String changeId =
+        createChange(changeOwner, "Change Adding A File", JgitPath.of(path).get(), "file content")
+            .getChangeId();
+
+    // Verify that the file is not approved.
+    Stream<FileCodeOwnerStatus> fileCodeOwnerStatuses =
+        codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
+    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
+        assertThatStream(fileCodeOwnerStatuses).onlyElement();
+    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
+    fileCodeOwnerStatusSubject
+        .hasNewPathStatus()
+        .value()
+        .hasStatusThat()
+        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+
+    // Add an override approval.
+    requestScopeOperations.setApiUser(changeOwner.id());
+    gApi.changes().id(changeId).current().review(new ReviewInput().label("Owners-Override", 1));
+
+    // Verify that the file is not approved (since self approvals on the override label are
+    // ignored).
+    fileCodeOwnerStatuses = codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
+    fileCodeOwnerStatusSubject = assertThatStream(fileCodeOwnerStatuses).onlyElement();
+    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
+    fileCodeOwnerStatusSubject
+        .hasNewPathStatus()
+        .value()
+        .hasStatusThat()
+        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+  }
+
+  @Test
+  public void overridenByChangeOwnerThatIsNotUploader() throws Exception {
+    // create arbitrary code owner config to avoid entering the bootstrapping code path in
+    // CodeOwnerApprovalCheck
+    createArbitraryCodeOwnerConfigFile();
+
+    TestAccount changeOwner =
+        accountCreator.create(
+            "changeOwner", "changeOwner@example.com", "ChangeOwner", /* displayName= */ null);
+
+    Path path = Paths.get("/foo/bar.baz");
+    String changeId =
+        createChange(changeOwner, "Change Adding A File", JgitPath.of(path).get(), "file content")
+            .getChangeId();
+
+    // Upload another patch set by another user.
+    amendChange(admin, changeId);
+
+    // Verify that the file is not approved.
+    Stream<FileCodeOwnerStatus> fileCodeOwnerStatuses =
+        codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
+    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
+        assertThatStream(fileCodeOwnerStatuses).onlyElement();
+    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
+    fileCodeOwnerStatusSubject
+        .hasNewPathStatus()
+        .value()
+        .hasStatusThat()
+        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+
+    // Add an override approval from the change owner.
+    requestScopeOperations.setApiUser(changeOwner.id());
+    gApi.changes().id(changeId).current().review(new ReviewInput().label("Owners-Override", 1));
+
+    // Verify that the file is approved now (since the change owner is not the uploader of the
+    // current patch set and hence the override counts).
+    fileCodeOwnerStatuses = codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
+    fileCodeOwnerStatusSubject = assertThatStream(fileCodeOwnerStatuses).onlyElement();
+    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
+    fileCodeOwnerStatusSubject
+        .hasNewPathStatus()
+        .value()
+        .hasStatusThat()
+        .isEqualTo(CodeOwnerStatus.APPROVED);
+  }
+
+  @Test
+  public void notOverridenByUploader() throws Exception {
+    // create arbitrary code owner config to avoid entering the bootstrapping code path in
+    // CodeOwnerApprovalCheck
+    createArbitraryCodeOwnerConfigFile();
+
+    TestAccount changeOwner =
+        accountCreator.create(
+            "changeOwner", "changeOwner@example.com", "ChangeOwner", /* displayName= */ null);
+
+    Path path = Paths.get("/foo/bar.baz");
+    String changeId =
+        createChange(changeOwner, "Change Adding A File", JgitPath.of(path).get(), "file content")
+            .getChangeId();
+
+    // Upload another patch set by another user.
+    amendChange(admin, changeId);
+
+    // Verify that the file is not approved.
+    Stream<FileCodeOwnerStatus> fileCodeOwnerStatuses =
+        codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
+    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
+        assertThatStream(fileCodeOwnerStatuses).onlyElement();
+    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
+    fileCodeOwnerStatusSubject
+        .hasNewPathStatus()
+        .value()
+        .hasStatusThat()
+        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+
+    // Add an override approval.
+    gApi.changes().id(changeId).current().review(new ReviewInput().label("Owners-Override", 1));
+
+    // Verify that the file is not approved (since the override from the uploader is ignored).
+    fileCodeOwnerStatuses = codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
+    fileCodeOwnerStatusSubject = assertThatStream(fileCodeOwnerStatuses).onlyElement();
+    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
+    fileCodeOwnerStatusSubject
+        .hasNewPathStatus()
+        .value()
+        .hasStatusThat()
+        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+  }
+
+  private ChangeNotes getChangeNotes(String changeId) throws Exception {
+    return changeNotesFactory.create(project, Change.id(gApi.changes().id(changeId).get()._number));
+  }
+}
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigFileUpdateScannerTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigFileUpdateScannerTest.java
index 16ec682..839c77b 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigFileUpdateScannerTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigFileUpdateScannerTest.java
@@ -71,8 +71,7 @@
             NullPointerException.class,
             () ->
                 codeOwnerConfigFileUpdateScanner.update(
-                    /** branchNameKey = */
-                    null,
+                    /* branchNameKey= */ null,
                     "Update code owner configs",
                     (codeOwnerConfigFilePath, codeOwnerConfigFileContent) -> Optional.empty()));
     assertThat(npe).hasMessageThat().isEqualTo("branchNameKey");
@@ -87,8 +86,7 @@
             () ->
                 codeOwnerConfigFileUpdateScanner.update(
                     branchNameKey,
-                    /** commitMessage = */
-                    null,
+                    /* commitMessage= */ null,
                     (codeOwnerConfigFilePath, codeOwnerConfigFileContent) -> Optional.empty()));
     assertThat(npe).hasMessageThat().isEqualTo("commitMessage");
   }
@@ -103,8 +101,7 @@
                 codeOwnerConfigFileUpdateScanner.update(
                     branchNameKey,
                     "Update code owner configs",
-                    /** codeOwnerConfigFileUpdater = */
-                    null));
+                    /* codeOwnerConfigFileUpdater= */ null));
     assertThat(npe).hasMessageThat().isEqualTo("codeOwnerConfigFileUpdater");
   }
 
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigHierarchyTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigHierarchyTest.java
index 18ba78f..af08aa3 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigHierarchyTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigHierarchyTest.java
@@ -33,6 +33,7 @@
 import com.google.inject.Inject;
 import java.io.IOException;
 import java.nio.file.Paths;
+import java.util.function.Consumer;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
@@ -53,6 +54,7 @@
   @Rule public final MockitoRule mockito = MockitoJUnit.rule().strictness(Strictness.STRICT_STUBS);
 
   @Mock private CodeOwnerConfigVisitor visitor;
+  @Mock private Consumer<CodeOwnerConfig.Key> parentCodeOwnersIgnoredCallback;
 
   @Inject private ProjectOperations projectOperations;
 
@@ -75,8 +77,7 @@
             NullPointerException.class,
             () ->
                 codeOwnerConfigHierarchy.visit(
-                    /** branchNameKey = */
-                    null,
+                    /* branchNameKey= */ null,
                     getCurrentRevision(BranchNameKey.create(project, "master")),
                     Paths.get("/foo/bar/baz.md"),
                     visitor));
@@ -91,8 +92,7 @@
             () ->
                 codeOwnerConfigHierarchy.visit(
                     BranchNameKey.create(project, "master"),
-                    /** revision = */
-                    null,
+                    /* revision= */ null,
                     Paths.get("/foo/bar/baz.md"),
                     visitor));
     assertThat(npe).hasMessageThat().isEqualTo("revision");
@@ -108,8 +108,7 @@
                 codeOwnerConfigHierarchy.visit(
                     branchNameKey,
                     getCurrentRevision(branchNameKey),
-                    /** absolutePath = */
-                    null,
+                    /* absolutePath= */ null,
                     visitor));
     assertThat(npe).hasMessageThat().isEqualTo("absolutePath");
   }
@@ -143,12 +142,27 @@
                     branchNameKey,
                     getCurrentRevision(branchNameKey),
                     Paths.get("/foo/bar/baz.md"),
-                    /** codeOwnerConfigVisitor = */
-                    null));
+                    /* codeOwnerConfigVisitor= */ null));
     assertThat(npe).hasMessageThat().isEqualTo("codeOwnerConfigVisitor");
   }
 
   @Test
+  public void cannotVisitCodeOwnerConfigsWithNullCallback() throws Exception {
+    BranchNameKey branchNameKey = BranchNameKey.create(project, "master");
+    NullPointerException npe =
+        assertThrows(
+            NullPointerException.class,
+            () ->
+                codeOwnerConfigHierarchy.visit(
+                    branchNameKey,
+                    getCurrentRevision(branchNameKey),
+                    Paths.get("/foo/bar/baz.md"),
+                    visitor,
+                    /* parentCodeOwnersIgnoredCallback= */ null));
+    assertThat(npe).hasMessageThat().isEqualTo("parentCodeOwnersIgnoredCallback");
+  }
+
+  @Test
   public void visitorNotInvokedIfNoCodeOwnerConfigExists() throws Exception {
     visit("master", "/foo/bar/baz.md");
     verifyZeroInteractions(visitor);
@@ -418,6 +432,9 @@
         .verify(visitor)
         .visit(codeOwnerConfigOperations.codeOwnerConfig(fooBarCodeOwnerConfigKey).get());
     verifyNoMoreInteractions(visitor);
+
+    verify(parentCodeOwnersIgnoredCallback).accept(fooBarCodeOwnerConfigKey);
+    verifyNoMoreInteractions(parentCodeOwnersIgnoredCallback);
   }
 
   @Test
@@ -481,6 +498,9 @@
         .verify(visitor)
         .visit(codeOwnerConfigOperations.codeOwnerConfig(fooBarCodeOwnerConfigKey).get());
     verifyNoMoreInteractions(visitor);
+
+    verify(parentCodeOwnersIgnoredCallback).accept(fooBarCodeOwnerConfigKey);
+    verifyNoMoreInteractions(parentCodeOwnersIgnoredCallback);
   }
 
   @Test
@@ -537,6 +557,8 @@
         .verify(visitor)
         .visit(codeOwnerConfigOperations.codeOwnerConfig(rootCodeOwnerConfigKey).get());
     verifyNoMoreInteractions(visitor);
+
+    verifyZeroInteractions(parentCodeOwnersIgnoredCallback);
   }
 
   @Test
@@ -661,6 +683,9 @@
     visit("master", "/foo/bar/baz.md");
     verify(visitor).visit(codeOwnerConfigOperations.codeOwnerConfig(rootCodeOwnerConfigKey).get());
     verifyNoMoreInteractions(visitor);
+
+    verify(parentCodeOwnersIgnoredCallback).accept(rootCodeOwnerConfigKey);
+    verifyNoMoreInteractions(parentCodeOwnersIgnoredCallback);
   }
 
   @Test
@@ -715,7 +740,11 @@
       throws InvalidPluginConfigurationException, IOException {
     BranchNameKey branchNameKey = BranchNameKey.create(project, branchName);
     codeOwnerConfigHierarchy.visit(
-        branchNameKey, getCurrentRevision(branchNameKey), Paths.get(path), visitor);
+        branchNameKey,
+        getCurrentRevision(branchNameKey),
+        Paths.get(path),
+        visitor,
+        parentCodeOwnersIgnoredCallback);
   }
 
   private ObjectId getCurrentRevision(BranchNameKey branchNameKey) throws IOException {
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigReferenceTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigReferenceTest.java
index 747bd83..42d3696 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigReferenceTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigReferenceTest.java
@@ -14,10 +14,15 @@
 
 package com.google.gerrit.plugins.codeowners.backend;
 
+import static com.google.common.truth.PathSubject.paths;
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+import static com.google.common.truth.Truth8.assertThat;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.gerrit.plugins.codeowners.acceptance.AbstractCodeOwnersTest;
+import java.nio.file.Path;
+import java.nio.file.Paths;
 import java.util.Optional;
 import org.junit.Test;
 
@@ -45,4 +50,23 @@
                     .build());
     assertThat(exception).hasMessageThat().isEqualTo("branch must be full name: master");
   }
+
+  @Test
+  public void absoluteFilePathCanBeSpecifiedInDifferentFormats() throws Exception {
+    Path expectedPath = Paths.get("/foo/OWNERS");
+    for (String inputPath : new String[] {"/foo/OWNERS", "//foo/OWNERS"}) {
+      Path path =
+          CodeOwnerConfigReference.create(CodeOwnerConfigImportMode.ALL, inputPath).filePath();
+      assertWithMessage(inputPath).about(paths()).that(path).isEqualTo(expectedPath);
+      assertThat(path.isAbsolute()).isTrue();
+    }
+  }
+
+  @Test
+  public void relativeFilePathCanBeSpecified() throws Exception {
+    Path path =
+        CodeOwnerConfigReference.create(CodeOwnerConfigImportMode.ALL, "foo/OWNERS").filePath();
+    assertThat(path).isEqualTo(Paths.get("foo/OWNERS"));
+    assertThat(path.isAbsolute()).isFalse();
+  }
 }
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigScannerTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigScannerTest.java
index 4a9faa7..df2e602 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigScannerTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigScannerTest.java
@@ -25,7 +25,6 @@
 import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.RefNames;
-import com.google.gerrit.plugins.codeowners.JgitPath;
 import com.google.gerrit.plugins.codeowners.acceptance.AbstractCodeOwnersTest;
 import com.google.gerrit.plugins.codeowners.acceptance.testsuite.CodeOwnerConfigOperations;
 import java.nio.file.Paths;
@@ -70,9 +69,7 @@
             () ->
                 codeOwnerConfigScannerFactory
                     .create()
-                    .visit(
-                        /** branchNameKey = */
-                        null, visitor, invalidCodeOwnerConfigCallback));
+                    .visit(/* branchNameKey= */ null, visitor, invalidCodeOwnerConfigCallback));
     assertThat(npe).hasMessageThat().isEqualTo("branchNameKey");
   }
 
@@ -87,8 +84,7 @@
                     .create()
                     .visit(
                         branchNameKey,
-                        /** codeOwnerConfigVisitor = */
-                        null,
+                        /* codeOwnerConfigVisitor= */ null,
                         invalidCodeOwnerConfigCallback));
     assertThat(npe).hasMessageThat().isEqualTo("codeOwnerConfigVisitor");
   }
@@ -102,11 +98,7 @@
             () ->
                 codeOwnerConfigScannerFactory
                     .create()
-                    .visit(
-                        branchNameKey,
-                        visitor,
-                        /** invalidCodeOwnerConfigCallback = */
-                        null));
+                    .visit(branchNameKey, visitor, /* invalidCodeOwnerConfigCallback= */ null));
     assertThat(npe).hasMessageThat().isEqualTo("invalidCodeOwnerConfigCallback");
   }
 
@@ -160,7 +152,7 @@
 
   @Test
   public void visitorNotInvokedForInvalidCodeOwnerConfigFiles() throws Exception {
-    createInvalidCodeOwnerConfig("/OWNERS");
+    createNonParseableCodeOwnerConfig("/OWNERS");
 
     visit();
     verifyZeroInteractions(visitor);
@@ -174,7 +166,7 @@
   @Test
   public void visitorInvokedForValidCodeOwnerConfigFilesEvenIfInvalidCodeOwnerConfigFileExist()
       throws Exception {
-    createInvalidCodeOwnerConfig("/OWNERS");
+    createNonParseableCodeOwnerConfig("/OWNERS");
 
     // Create a valid code owner config file.
     CodeOwnerConfig.Key codeOwnerConfigKey =
@@ -561,7 +553,7 @@
 
   @Test
   public void containsACodeOwnerConfigFile_invalidCodeOwnerConfigFileExists() throws Exception {
-    createInvalidCodeOwnerConfig("/OWNERS");
+    createNonParseableCodeOwnerConfig("/OWNERS");
 
     codeOwnerConfigOperations
         .newCodeOwnerConfig()
@@ -581,7 +573,7 @@
 
   @Test
   public void containsOnlyInvalidCodeOwnerConfigFiles() throws Exception {
-    createInvalidCodeOwnerConfig("/OWNERS");
+    createNonParseableCodeOwnerConfig("/OWNERS");
 
     assertThat(
             codeOwnerConfigScannerFactory
@@ -632,14 +624,4 @@
         .includeDefaultCodeOwnerConfig(includeDefaultCodeOwnerConfig)
         .visit(BranchNameKey.create(project, "master"), visitor, invalidCodeOwnerConfigCallback);
   }
-
-  private void createInvalidCodeOwnerConfig(String path) throws Exception {
-    disableCodeOwnersForProject(project);
-    String changeId =
-        createChange("Add invalid code owners file", JgitPath.of(path).get(), "INVALID")
-            .getChangeId();
-    approve(changeId);
-    gApi.changes().id(changeId).current().submit();
-    enableCodeOwnersForProject(project);
-  }
 }
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigTest.java
index 433ff34..e3e9e6a 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigTest.java
@@ -109,10 +109,7 @@
     NullPointerException npe =
         assertThrows(
             NullPointerException.class,
-            () ->
-                codeOwnerConfigKey.filePath(
-                    /** defaultCodeOwnerConfigFileName = */
-                    null));
+            () -> codeOwnerConfigKey.filePath(/* defaultCodeOwnerConfigFileName= */ null));
     assertThat(npe).hasMessageThat().isEqualTo("codeOwnerConfigFileName");
   }
 
@@ -121,11 +118,7 @@
     NullPointerException npe =
         assertThrows(
             NullPointerException.class,
-            () ->
-                createCodeOwnerBuilder()
-                    .addCodeOwnerSet(
-                        /** codeOwnerSet = */
-                        null));
+            () -> createCodeOwnerBuilder().addCodeOwnerSet(/* codeOwnerSet= */ null));
     assertThat(npe).hasMessageThat().isEqualTo("codeOwnerSet");
   }
 
@@ -134,11 +127,7 @@
     CodeOwnerConfig codeOwnerConfig = createCodeOwnerBuilder().build();
     NullPointerException npe =
         assertThrows(
-            NullPointerException.class,
-            () ->
-                codeOwnerConfig.relativize(
-                    /** path = */
-                    null));
+            NullPointerException.class, () -> codeOwnerConfig.relativize(/* path= */ null));
     assertThat(npe).hasMessageThat().isEqualTo("path");
   }
 
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerResolverTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerResolverTest.java
index dc4646d..1210ae5 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerResolverTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerResolverTest.java
@@ -34,8 +34,8 @@
 import com.google.inject.Key;
 import com.google.inject.Provider;
 import java.nio.file.Paths;
+import java.util.Optional;
 import java.util.Set;
-import java.util.stream.Stream;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
 import org.junit.Before;
@@ -67,9 +67,7 @@
             () ->
                 codeOwnerResolver
                     .get()
-                    .resolve(
-                        /** codeOwnerReference = */
-                        (CodeOwnerReference) null));
+                    .resolve(/* codeOwnerReference= */ (CodeOwnerReference) null));
     assertThat(npe).hasMessageThat().isEqualTo("codeOwnerReference");
 
     npe =
@@ -78,9 +76,7 @@
             () ->
                 codeOwnerResolver
                     .get()
-                    .resolve(
-                        /** codeOwnerReferences = */
-                        (Set<CodeOwnerReference>) null));
+                    .resolve(/* codeOwnerReferences= */ (Set<CodeOwnerReference>) null));
     assertThat(npe).hasMessageThat().isEqualTo("codeOwnerReferences");
   }
 
@@ -93,14 +89,14 @@
 
   @Test
   public void resolveCodeOwnerReferenceForEmail() throws Exception {
-    Stream<CodeOwner> codeOwner =
+    Optional<CodeOwner> codeOwner =
         codeOwnerResolver.get().resolve(CodeOwnerReference.create(admin.email()));
-    assertThat(codeOwner).onlyElement().hasAccountIdThat().isEqualTo(admin.id());
+    assertThat(codeOwner).value().hasAccountIdThat().isEqualTo(admin.id());
   }
 
   @Test
   public void cannotResolveCodeOwnerReferenceForStarAsEmail() throws Exception {
-    Stream<CodeOwner> codeOwner =
+    Optional<CodeOwner> codeOwner =
         codeOwnerResolver
             .get()
             .resolve(CodeOwnerReference.create(CodeOwnerResolver.ALL_USERS_WILDCARD));
@@ -118,12 +114,7 @@
             (a, u) ->
                 u.addExternalId(
                     ExternalId.create(
-                        "foo",
-                        "bar",
-                        user.id(),
-                        admin.email(),
-                        /** hashedPassword = */
-                        null)));
+                        "foo", "bar", user.id(), admin.email(), /* hashedPassword= */ null)));
 
     assertThat(codeOwnerResolver.get().resolve(CodeOwnerReference.create(admin.email()))).isEmpty();
   }
@@ -163,20 +154,41 @@
 
   @Test
   public void resolveCodeOwnerReferenceForSecondaryEmail() throws Exception {
+    TestAccount user2 = accountCreator.user2();
+
     // add secondary email to user account
     String secondaryEmail = "user@foo.bar";
     accountOperations.account(user.id()).forUpdate().addSecondaryEmail(secondaryEmail).update();
 
     // admin has the "Modify Account" global capability and hence can see the secondary email of the
     // user account.
-    Stream<CodeOwner> codeOwner =
+    Optional<CodeOwner> codeOwner =
         codeOwnerResolver.get().resolve(CodeOwnerReference.create(secondaryEmail));
-    assertThat(codeOwner).onlyElement().hasAccountIdThat().isEqualTo(user.id());
+    assertThat(codeOwner).value().hasAccountIdThat().isEqualTo(user.id());
+
+    // admin has the "Modify Account" global capability and hence can see the secondary email of the
+    // user account if another user is the calling user
+    requestScopeOperations.setApiUser(user2.id());
+    codeOwner =
+        codeOwnerResolver
+            .get()
+            .forUser(identifiedUserFactory.create(admin.id()))
+            .resolve(CodeOwnerReference.create(secondaryEmail));
+    assertThat(codeOwner).value().hasAccountIdThat().isEqualTo(user.id());
 
     // user can see its own secondary email.
     requestScopeOperations.setApiUser(user.id());
     codeOwner = codeOwnerResolver.get().resolve(CodeOwnerReference.create(secondaryEmail));
-    assertThat(codeOwner).onlyElement().hasAccountIdThat().isEqualTo(user.id());
+    assertThat(codeOwner).value().hasAccountIdThat().isEqualTo(user.id());
+
+    // user can see its own secondary email if another user is the calling user.
+    requestScopeOperations.setApiUser(user2.id());
+    codeOwner =
+        codeOwnerResolver
+            .get()
+            .forUser(identifiedUserFactory.create(user.id()))
+            .resolve(CodeOwnerReference.create(secondaryEmail));
+    assertThat(codeOwner).value().hasAccountIdThat().isEqualTo(user.id());
   }
 
   @Test
@@ -190,6 +202,16 @@
     requestScopeOperations.setApiUser(user.id());
     assertThat(codeOwnerResolver.get().resolve(CodeOwnerReference.create(secondaryEmail)))
         .isEmpty();
+
+    // user doesn't have the "Modify Account" global capability and hence cannot see the secondary
+    // email of the admin account if another user is the calling user
+    requestScopeOperations.setApiUser(admin.id());
+    assertThat(
+            codeOwnerResolver
+                .get()
+                .forUser(identifiedUserFactory.create(user.id()))
+                .resolve(CodeOwnerReference.create(secondaryEmail)))
+        .isEmpty();
   }
 
   @Test
@@ -201,6 +223,7 @@
         codeOwnerResolver.get().resolvePathCodeOwners(codeOwnerConfig, Paths.get("/README.md"));
     assertThat(result.codeOwners()).isEmpty();
     assertThat(result.ownedByAllUsers()).isFalse();
+    assertThat(result.hasUnresolvedCodeOwners()).isFalse();
   }
 
   @Test
@@ -214,6 +237,7 @@
         codeOwnerResolver.get().resolvePathCodeOwners(codeOwnerConfig, Paths.get("/README.md"));
     assertThat(result.codeOwnersAccountIds()).containsExactly(admin.id(), user.id());
     assertThat(result.ownedByAllUsers()).isFalse();
+    assertThat(result.hasUnresolvedCodeOwners()).isFalse();
   }
 
   @Test
@@ -228,6 +252,7 @@
         codeOwnerResolver.get().resolvePathCodeOwners(codeOwnerConfig, Paths.get("/README.md"));
     assertThat(result.codeOwnersAccountIds()).isEmpty();
     assertThat(result.ownedByAllUsers()).isTrue();
+    assertThat(result.hasUnresolvedCodeOwners()).isFalse();
   }
 
   @Test
@@ -242,6 +267,23 @@
         codeOwnerResolver.get().resolvePathCodeOwners(codeOwnerConfig, Paths.get("/README.md"));
     assertThat(result.codeOwnersAccountIds()).containsExactly(admin.id());
     assertThat(result.ownedByAllUsers()).isFalse();
+    assertThat(result.hasUnresolvedCodeOwners()).isTrue();
+  }
+
+  @Test
+  public void resolvePathCodeOwnersNonResolvableCodeOwnersAreFilteredOutIfOwnedByAllUsers()
+      throws Exception {
+    CodeOwnerConfig codeOwnerConfig =
+        CodeOwnerConfig.builder(CodeOwnerConfig.Key.create(project, "master", "/"), TEST_REVISION)
+            .addCodeOwnerSet(
+                CodeOwnerSet.createWithoutPathExpressions(
+                    "*", admin.email(), "non-existing@example.com"))
+            .build();
+    CodeOwnerResolverResult result =
+        codeOwnerResolver.get().resolvePathCodeOwners(codeOwnerConfig, Paths.get("/README.md"));
+    assertThat(result.codeOwnersAccountIds()).containsExactly(admin.id());
+    assertThat(result.ownedByAllUsers()).isTrue();
+    assertThat(result.hasUnresolvedCodeOwners()).isTrue();
   }
 
   @Test
@@ -252,9 +294,7 @@
             () ->
                 codeOwnerResolver
                     .get()
-                    .resolvePathCodeOwners(
-                        /** codeOwnerConfig = */
-                        null, Paths.get("/README.md")));
+                    .resolvePathCodeOwners(/* codeOwnerConfig= */ null, Paths.get("/README.md")));
     assertThat(npe).hasMessageThat().isEqualTo("codeOwnerConfig");
   }
 
@@ -270,10 +310,7 @@
             () ->
                 codeOwnerResolver
                     .get()
-                    .resolvePathCodeOwners(
-                        codeOwnerConfig,
-                        /** absolutePath = */
-                        null));
+                    .resolvePathCodeOwners(codeOwnerConfig, /* absolutePath= */ null));
     assertThat(npe).hasMessageThat().isEqualTo("absolutePath");
   }
 
@@ -311,9 +348,9 @@
     assertThat(codeOwnerResolver.get().resolve(adminCodeOwnerReference)).isEmpty();
 
     // if visibility is not enforced the code owner reference can be resolved regardless
-    Stream<CodeOwner> codeOwner =
+    Optional<CodeOwner> codeOwner =
         codeOwnerResolver.get().enforceVisibility(false).resolve(adminCodeOwnerReference);
-    assertThat(codeOwner).onlyElement().hasAccountIdThat().isEqualTo(admin.id());
+    assertThat(codeOwner).value().hasAccountIdThat().isEqualTo(admin.id());
   }
 
   @Test
@@ -325,13 +362,13 @@
 
     // admin is the current user and can see the account
     assertThat(codeOwnerResolver.get().resolve(CodeOwnerReference.create(user.email())))
-        .isNotEmpty();
+        .isPresent();
     assertThat(
             codeOwnerResolver
                 .get()
                 .forUser(identifiedUserFactory.create(admin.id()))
                 .resolve(CodeOwnerReference.create(user.email())))
-        .isNotEmpty();
+        .isPresent();
 
     // user2 cannot see the account
     assertThat(
@@ -354,12 +391,7 @@
     NullPointerException npe =
         assertThrows(
             NullPointerException.class,
-            () ->
-                codeOwnerResolver
-                    .get()
-                    .isEmailDomainAllowed(
-                        /** email = */
-                        null));
+            () -> codeOwnerResolver.get().isEmailDomainAllowed(/* email= */ null));
     assertThat(npe).hasMessageThat().isEqualTo("email");
   }
 
@@ -405,6 +437,21 @@
                     CodeOwnerReference.create(user.email())));
     assertThat(result.codeOwnersAccountIds()).containsExactly(admin.id(), user.id());
     assertThat(result.ownedByAllUsers()).isFalse();
+    assertThat(result.hasUnresolvedCodeOwners()).isFalse();
+  }
+
+  @Test
+  public void resolveCodeOwnerReferencesNonResolveableCodeOwnersAreFilteredOut() throws Exception {
+    CodeOwnerResolverResult result =
+        codeOwnerResolver
+            .get()
+            .resolve(
+                ImmutableSet.of(
+                    CodeOwnerReference.create(admin.email()),
+                    CodeOwnerReference.create("non-existing@example.com")));
+    assertThat(result.codeOwnersAccountIds()).containsExactly(admin.id());
+    assertThat(result.ownedByAllUsers()).isFalse();
+    assertThat(result.hasUnresolvedCodeOwners()).isTrue();
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerScoringTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerScoringTest.java
index 3deb240..0cab290 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerScoringTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerScoringTest.java
@@ -32,10 +32,7 @@
     NullPointerException npe =
         assertThrows(
             NullPointerException.class,
-            () ->
-                builder.putValueForCodeOwner(
-                    /** codeOwner = */
-                    null, 50));
+            () -> builder.putValueForCodeOwner(/* codeOwner= */ null, 50));
     assertThat(npe).hasMessageThat().isEqualTo("codeOwner");
   }
 
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerSetTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerSetTest.java
index fccd87c..3b6304f 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerSetTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerSetTest.java
@@ -45,11 +45,7 @@
     NullPointerException npe =
         assertThrows(
             NullPointerException.class,
-            () ->
-                CodeOwnerSet.builder()
-                    .addCodeOwner(
-                        /** codeOwnerReference = */
-                        null));
+            () -> CodeOwnerSet.builder().addCodeOwner(/* codeOwnerReference= */ null));
     assertThat(npe).hasMessageThat().isEqualTo("codeOwnerReference");
   }
 
@@ -83,11 +79,7 @@
     NullPointerException npe =
         assertThrows(
             NullPointerException.class,
-            () ->
-                CodeOwnerSet.builder()
-                    .addCodeOwnerEmail(
-                        /** email = */
-                        null));
+            () -> CodeOwnerSet.builder().addCodeOwnerEmail(/* email= */ null));
     assertThat(npe).hasMessageThat().isEqualTo("codeOwnerEmail");
   }
 
@@ -121,11 +113,7 @@
     NullPointerException npe =
         assertThrows(
             NullPointerException.class,
-            () ->
-                CodeOwnerSet.builder()
-                    .addPathExpression(
-                        /** pathExpression = */
-                        null));
+            () -> CodeOwnerSet.builder().addPathExpression(/* pathExpression= */ null));
     assertThat(npe).hasMessageThat().isEqualTo("pathExpression");
   }
 
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerSubmitRuleTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerSubmitRuleTest.java
index 25705d9..8e03935 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerSubmitRuleTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerSubmitRuleTest.java
@@ -115,11 +115,7 @@
   @Test
   public void ruleErrorWhenChangeDataIsNull() throws Exception {
     SubmitRecordSubject submitRecordSubject =
-        assertThatOptional(
-                codeOwnerSubmitRule.evaluate(
-                    /** changeData = */
-                    null))
-            .value();
+        assertThatOptional(codeOwnerSubmitRule.evaluate(/* changeData= */ null)).value();
     submitRecordSubject.hasStatusThat().isRuleError();
     submitRecordSubject.hasErrorMessageThat().isEqualTo("Failed to evaluate code owner statuses.");
   }
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnersTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnersTest.java
index 78835dc..fafc620 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnersTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnersTest.java
@@ -53,10 +53,7 @@
     NullPointerException npe =
         assertThrows(
             NullPointerException.class,
-            () ->
-                codeOwners.get(
-                    /** codeOwnerConfigKey = */
-                    null, TEST_REVISION));
+            () -> codeOwners.get(/* codeOwnerConfigKey= */ null, TEST_REVISION));
     assertThat(npe).hasMessageThat().isEqualTo("codeOwnerConfigKey");
   }
 
@@ -67,9 +64,7 @@
             NullPointerException.class,
             () ->
                 codeOwners.get(
-                    CodeOwnerConfig.Key.create(project, "master", "/"),
-                    /** folderPath = */
-                    null));
+                    CodeOwnerConfig.Key.create(project, "master", "/"), /* revision= */ null));
     assertThat(npe).hasMessageThat().isEqualTo("revision");
   }
 
@@ -79,10 +74,7 @@
     NullPointerException npe =
         assertThrows(
             NullPointerException.class,
-            () ->
-                codeOwners.getFromCurrentRevision(
-                    /** codeOwnerConfigKey = */
-                    null));
+            () -> codeOwners.getFromCurrentRevision(/* codeOwnerConfigKey= */ null));
     assertThat(npe).hasMessageThat().isEqualTo("codeOwnerConfigKey");
   }
 
@@ -95,20 +87,13 @@
             .addCodeOwnerSet(CodeOwnerSet.createWithoutPathExpressions(admin.email()))
             .build();
     CodeOwnerBackend codeOwnerBackendMock = mock(CodeOwnerBackend.class);
-    when(codeOwnerBackendMock.getCodeOwnerConfig(
-            codeOwnerConfigKey,
-            /** revision = */
-            null))
+    when(codeOwnerBackendMock.getCodeOwnerConfig(codeOwnerConfigKey, /* revision= */ null))
         .thenReturn(Optional.of(expectedCodeOwnersConfig));
     try (AutoCloseable registration = registerTestBackend("test-backend", codeOwnerBackendMock)) {
       Optional<CodeOwnerConfig> codeOwnerConfig =
           codeOwners.getFromCurrentRevision(codeOwnerConfigKey);
       assertThat(codeOwnerConfig).value().isEqualTo(expectedCodeOwnersConfig);
-      verify(codeOwnerBackendMock)
-          .getCodeOwnerConfig(
-              codeOwnerConfigKey,
-              /** revision = */
-              null);
+      verify(codeOwnerBackendMock).getCodeOwnerConfig(codeOwnerConfigKey, /* revision= */ null);
     }
   }
 
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnersUpdateTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnersUpdateTest.java
index 813f44f..ec7ec15 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnersUpdateTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnersUpdateTest.java
@@ -66,9 +66,7 @@
   @GerritConfig(name = "plugin.code-owners.backend", value = "test-backend")
   public void codeOwnerUpdateIsForwardedToConfiguredBackendServerInitiated() throws Exception {
     testCodeOwnerUpdateIsForwardedToConfiguredBackend(
-        serverInitiatedCodeOwnersUpdate.get(),
-        /** currentUser = */
-        null);
+        serverInitiatedCodeOwnersUpdate.get(), /* currentUser= */ null);
   }
 
   private void testCodeOwnerUpdateIsForwardedToConfiguredBackend(
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/FindOwnersGlobMatcherTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/FindOwnersGlobMatcherTest.java
new file mode 100644
index 0000000..7e0c03e
--- /dev/null
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/FindOwnersGlobMatcherTest.java
@@ -0,0 +1,91 @@
+// 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.
+
+package com.google.gerrit.plugins.codeowners.backend;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import org.junit.Test;
+
+/** Tests for {@link FindOwnersGlobMatcher}. */
+public class FindOwnersGlobMatcherTest extends GlobMatcherTest {
+  @Override
+  protected PathExpressionMatcher getPathExpressionMatcher() {
+    return FindOwnersGlobMatcher.INSTANCE;
+  }
+
+  @Test
+  public void singleStarInGlobIsReplacedWithDoubleStar() throws Exception {
+    assertThat(FindOwnersGlobMatcher.INSTANCE.replaceSingleStarWithDoubleStar("*.md"))
+        .isEqualTo("**.md");
+    assertThat(FindOwnersGlobMatcher.INSTANCE.replaceSingleStarWithDoubleStar("foo/*.md"))
+        .isEqualTo("foo/**.md");
+    assertThat(FindOwnersGlobMatcher.INSTANCE.replaceSingleStarWithDoubleStar("*/foo/*.md"))
+        .isEqualTo("**/foo/**.md");
+    assertThat(FindOwnersGlobMatcher.INSTANCE.replaceSingleStarWithDoubleStar("foo/*"))
+        .isEqualTo("foo/**");
+  }
+
+  @Test
+  public void doubleStarInGlobIsNotReplaced() throws Exception {
+    assertThat(FindOwnersGlobMatcher.INSTANCE.replaceSingleStarWithDoubleStar("**.md"))
+        .isEqualTo("**.md");
+    assertThat(FindOwnersGlobMatcher.INSTANCE.replaceSingleStarWithDoubleStar("foo/**.md"))
+        .isEqualTo("foo/**.md");
+    assertThat(FindOwnersGlobMatcher.INSTANCE.replaceSingleStarWithDoubleStar("**/foo/**.md"))
+        .isEqualTo("**/foo/**.md");
+  }
+
+  @Test
+  public void tripleStarInGlobIsNotReplaced() throws Exception {
+    assertThat(FindOwnersGlobMatcher.INSTANCE.replaceSingleStarWithDoubleStar("***.md"))
+        .isEqualTo("***.md");
+    assertThat(FindOwnersGlobMatcher.INSTANCE.replaceSingleStarWithDoubleStar("foo/***.md"))
+        .isEqualTo("foo/***.md");
+    assertThat(FindOwnersGlobMatcher.INSTANCE.replaceSingleStarWithDoubleStar("***/foo/***.md"))
+        .isEqualTo("***/foo/***.md");
+  }
+
+  /**
+   * This test differs from the base class ({@link GlobMatcherTest}), since for {@link
+   * FindOwnersGlobMatcher} {@code *} also matches slashes and the test in the base class has an
+   * assertion that checks that slashes are not matched by {@code *}.
+   */
+  @Test
+  @Override
+  public void matchFileTypeInCurrentFolder() throws Exception {
+    String pathExpression = "*.md";
+    assertMatch(pathExpression, "README.md", "config.md");
+    assertNoMatch(pathExpression, "README", "README.md5");
+  }
+
+  @Test
+  public void matchFileTypeInCurrentFolderAndAllSubfoldersBySingleStar() throws Exception {
+    String pathExpression = "*.md";
+    assertMatch(pathExpression, "README.md", "config.md", "foo/README.md", "foo/bar/README.md");
+    assertNoMatch(pathExpression, "README", "README.md5");
+  }
+
+  @Test
+  public void matchAllFilesInSubfolderBySingleStar() throws Exception {
+    String pathExpression = "foo/*";
+    assertMatch(
+        pathExpression,
+        "foo/README.md",
+        "foo/config.txt",
+        "foo/bar/README.md",
+        "foo/bar/baz/README.md");
+    assertNoMatch(pathExpression, "README", "foo2/README");
+  }
+}
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/PathCodeOwnersTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/PathCodeOwnersTest.java
index e5d06ac..066fcbb 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/backend/PathCodeOwnersTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/PathCodeOwnersTest.java
@@ -81,7 +81,9 @@
     NullPointerException npe =
         assertThrows(
             NullPointerException.class,
-            () -> pathCodeOwnersFactory.create(null, Paths.get("/foo/bar/baz.md")));
+            () ->
+                pathCodeOwnersFactory.create(
+                    /* codeOwnerConfig= */ null, Paths.get("/foo/bar/baz.md")));
     assertThat(npe).hasMessageThat().isEqualTo("codeOwnerConfig");
   }
 
@@ -90,7 +92,8 @@
     CodeOwnerConfig codeOwnerConfig = createCodeOwnerBuilder().build();
     NullPointerException npe =
         assertThrows(
-            NullPointerException.class, () -> pathCodeOwnersFactory.create(codeOwnerConfig, null));
+            NullPointerException.class,
+            () -> pathCodeOwnersFactory.create(codeOwnerConfig, /* absolutePath= */ null));
     assertThat(npe).hasMessageThat().isEqualTo("path");
   }
 
@@ -133,7 +136,7 @@
             NullPointerException.class,
             () ->
                 pathCodeOwnersFactory.create(
-                    null,
+                    /* codeOwnerConfigKey= */ null,
                     projectOperations.project(project).getHead("master"),
                     Paths.get("/foo/bar/baz.md")));
     assertThat(npe).hasMessageThat().isEqualTo("codeOwnerConfigKey");
@@ -148,7 +151,7 @@
                 pathCodeOwnersFactory.create(
                     CodeOwnerConfig.Key.create(
                         BranchNameKey.create(project, "master"), Paths.get("/")),
-                    null,
+                    /* revision= */ null,
                     Paths.get("/foo/bar/baz.md")));
     assertThat(npe).hasMessageThat().isEqualTo("revision");
   }
@@ -171,7 +174,7 @@
                 pathCodeOwnersFactory.create(
                     codeOwnerConfigKey,
                     projectOperations.project(project).getHead("master"),
-                    null));
+                    /* absolutePath= */ null));
     assertThat(npe).hasMessageThat().isEqualTo("path");
   }
 
@@ -205,7 +208,8 @@
     CodeOwnerConfig emptyCodeOwnerConfig = createCodeOwnerBuilder().build();
     PathCodeOwners pathCodeOwners =
         pathCodeOwnersFactory.create(emptyCodeOwnerConfig, Paths.get("/foo/bar/baz.md"));
-    assertThat(pathCodeOwners.get()).isEmpty();
+    assertThat(pathCodeOwners.resolveCodeOwnerConfig().getPathCodeOwners()).isEmpty();
+    assertThat(pathCodeOwners.resolveCodeOwnerConfig().hasUnresolvedImports()).isFalse();
   }
 
   @Test
@@ -216,7 +220,7 @@
             .build();
     PathCodeOwners pathCodeOwners =
         pathCodeOwnersFactory.create(codeOwnerConfig, Paths.get("/foo/bar/baz.md"));
-    assertThat(pathCodeOwners.get())
+    assertThat(pathCodeOwners.resolveCodeOwnerConfig().getPathCodeOwners())
         .comparingElementsUsing(hasEmail())
         .containsExactly(admin.email(), user.email());
   }
@@ -254,7 +258,7 @@
               .build();
       PathCodeOwners pathCodeOwners =
           pathCodeOwnersFactory.create(codeOwnerConfig, Paths.get("/foo/bar/baz.md"));
-      assertThat(pathCodeOwners.get())
+      assertThat(pathCodeOwners.resolveCodeOwnerConfig().getPathCodeOwners())
           .comparingElementsUsing(hasEmail())
           .containsExactly(admin.email(), user.email());
     }
@@ -264,7 +268,7 @@
   @GerritConfig(name = "plugin.code-owners.backend", value = TestCodeOwnerBackend.ID)
   public void codeOwnerSetsWithPathExpressionsAreIgnoredIfBackendDoesntSupportPathExpressions()
       throws Exception {
-    try (AutoCloseable registration = registerTestBackend(null)) {
+    try (AutoCloseable registration = registerTestBackend(/* pathExpressionMatcher= */ null)) {
       CodeOwnerConfig codeOwnerConfig =
           createCodeOwnerBuilder()
               .addCodeOwnerSet(
@@ -275,7 +279,7 @@
               .build();
       PathCodeOwners pathCodeOwners =
           pathCodeOwnersFactory.create(codeOwnerConfig, Paths.get("/foo/bar/baz.md"));
-      assertThat(pathCodeOwners.get()).isEmpty();
+      assertThat(pathCodeOwners.resolveCodeOwnerConfig().getPathCodeOwners()).isEmpty();
     }
   }
 
@@ -307,7 +311,7 @@
               .build();
       PathCodeOwners pathCodeOwners =
           pathCodeOwnersFactory.create(codeOwnerConfig, Paths.get("/foo/bar/baz.md"));
-      assertThat(pathCodeOwners.get())
+      assertThat(pathCodeOwners.resolveCodeOwnerConfig().getPathCodeOwners())
           .comparingElementsUsing(hasEmail())
           .containsExactly(admin.email());
     }
@@ -341,7 +345,7 @@
               .build();
       PathCodeOwners pathCodeOwners =
           pathCodeOwnersFactory.create(codeOwnerConfig, Paths.get("/foo/bar/baz.md"));
-      assertThat(pathCodeOwners.get())
+      assertThat(pathCodeOwners.resolveCodeOwnerConfig().getPathCodeOwners())
           .comparingElementsUsing(hasEmail())
           .containsExactly(admin.email(), user.email());
     }
@@ -376,7 +380,7 @@
               .build();
       PathCodeOwners pathCodeOwners =
           pathCodeOwnersFactory.create(codeOwnerConfig, Paths.get("/foo/bar/baz.md"));
-      assertThat(pathCodeOwners.get())
+      assertThat(pathCodeOwners.resolveCodeOwnerConfig().getPathCodeOwners())
           .comparingElementsUsing(hasEmail())
           .containsExactly(admin.email(), user.email());
     }
@@ -389,7 +393,7 @@
         pathCodeOwnersFactory.create(
             createCodeOwnerBuilder().setIgnoreParentCodeOwners().build(),
             Paths.get("/foo/bar/baz.md"));
-    assertThat(pathCodeOwners.ignoreParentCodeOwners()).isTrue();
+    assertThat(pathCodeOwners.resolveCodeOwnerConfig().ignoreParentCodeOwners()).isTrue();
   }
 
   @Test
@@ -399,7 +403,7 @@
         pathCodeOwnersFactory.create(
             createCodeOwnerBuilder().setIgnoreParentCodeOwners(false).build(),
             Paths.get("/foo/bar/baz.md"));
-    assertThat(pathCodeOwners.ignoreParentCodeOwners()).isFalse();
+    assertThat(pathCodeOwners.resolveCodeOwnerConfig().ignoreParentCodeOwners()).isFalse();
   }
 
   @Test
@@ -415,7 +419,7 @@
                         .build())
                 .build(),
             Paths.get("/foo.md"));
-    assertThat(pathCodeOwners.ignoreParentCodeOwners()).isTrue();
+    assertThat(pathCodeOwners.resolveCodeOwnerConfig().ignoreParentCodeOwners()).isTrue();
   }
 
   @Test
@@ -432,7 +436,7 @@
                         .build())
                 .build(),
             Paths.get("/foo.md"));
-    assertThat(pathCodeOwners.ignoreParentCodeOwners()).isFalse();
+    assertThat(pathCodeOwners.resolveCodeOwnerConfig().ignoreParentCodeOwners()).isFalse();
   }
 
   @Test
@@ -459,9 +463,10 @@
 
     // Expectation: we get the global code owner from the importing code owner config, the
     // non-resolveable import is silently ignored
-    assertThat(pathCodeOwners.get().get())
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().getPathCodeOwners())
         .comparingElementsUsing(hasEmail())
         .containsExactly(admin.email());
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().hasUnresolvedImports()).isTrue();
   }
 
   @Test
@@ -504,9 +509,10 @@
 
     // Expectation: we get the global code owners from the importing and the imported code owner
     // config
-    assertThat(pathCodeOwners.get().get())
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().getPathCodeOwners())
         .comparingElementsUsing(hasEmail())
         .containsExactly(admin.email(), user.email());
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().hasUnresolvedImports()).isFalse();
   }
 
   @Test
@@ -549,9 +555,10 @@
 
     // Expectation: we get the matching per-file code owners from the importing and the imported
     // code owner config
-    assertThat(pathCodeOwners.get().get())
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().getPathCodeOwners())
         .comparingElementsUsing(hasEmail())
         .containsExactly(admin.email(), user.email());
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().hasUnresolvedImports()).isFalse();
   }
 
   @Test
@@ -595,9 +602,10 @@
     // Expectation: we only get the matching per-file code owners from the importing code owner
     // config, the per-file code owners from the imported code owner config are not relevant since
     // they do not match
-    assertThat(pathCodeOwners.get().get())
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().getPathCodeOwners())
         .comparingElementsUsing(hasEmail())
         .containsExactly(admin.email());
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().hasUnresolvedImports()).isFalse();
   }
 
   @Test
@@ -642,9 +650,10 @@
     // Expectation: we only get the matching per-file code owners from the importing code owner
     // config, the matching per-file code owners from the imported code owner config are not
     // relevant with import mode GLOBAL_CODE_OWNER_SETS_ONLY
-    assertThat(pathCodeOwners.get().get())
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().getPathCodeOwners())
         .comparingElementsUsing(hasEmail())
         .containsExactly(admin.email());
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().hasUnresolvedImports()).isFalse();
   }
 
   @Test
@@ -690,10 +699,11 @@
     // the matching per-file code owner set in the imported code owner config has the
     // ignoreGlobalAndParentCodeOwners flag set to true which causes global code owners to be
     // ignored, in addition this flag causes parent code owners to be ignored
-    assertThat(pathCodeOwners.get().get())
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().getPathCodeOwners())
         .comparingElementsUsing(hasEmail())
         .containsExactly(user.email());
-    assertThat(pathCodeOwners.get().ignoreParentCodeOwners()).isTrue();
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().ignoreParentCodeOwners()).isTrue();
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().hasUnresolvedImports()).isFalse();
   }
 
   @Test
@@ -738,10 +748,11 @@
     // per-file code owners from the imported code owner config and its
     // ignoreGlobalAndParentCodeOwners flag are not relevant since the per-file code owner set does
     // not match
-    assertThat(pathCodeOwners.get().get())
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().getPathCodeOwners())
         .comparingElementsUsing(hasEmail())
         .containsExactly(admin.email());
-    assertThat(pathCodeOwners.get().ignoreParentCodeOwners()).isFalse();
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().ignoreParentCodeOwners()).isFalse();
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().hasUnresolvedImports()).isFalse();
   }
 
   @Test
@@ -786,10 +797,11 @@
     // matching per-file code owners from the imported code owner config and its
     // ignoreGlobalAndParentCodeOwners flag are not relevant with import mode
     // GLOBAL_CODE_OWNER_SETS_ONLY
-    assertThat(pathCodeOwners.get().get())
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().getPathCodeOwners())
         .comparingElementsUsing(hasEmail())
         .containsExactly(admin.email());
-    assertThat(pathCodeOwners.get().ignoreParentCodeOwners()).isFalse();
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().ignoreParentCodeOwners()).isFalse();
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().hasUnresolvedImports()).isFalse();
   }
 
   @Test
@@ -824,7 +836,7 @@
 
     // Expectation: ignoreParentCodeOwners is true because the ignoreParentCodeOwners flag in the
     // imported code owner config is set to true
-    assertThat(pathCodeOwners.get().ignoreParentCodeOwners()).isTrue();
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().ignoreParentCodeOwners()).isTrue();
   }
 
   @Test
@@ -861,7 +873,7 @@
 
     // Expectation: ignoreParentCodeOwners is false because the ignoreParentCodeOwners flag in the
     // imported code owner config is not relevant with import mode GLOBAL_CODE_OWNER_SETS_ONLY
-    assertThat(pathCodeOwners.get().ignoreParentCodeOwners()).isFalse();
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().ignoreParentCodeOwners()).isFalse();
   }
 
   @Test
@@ -919,9 +931,10 @@
 
     // Expectation: we get the global owners from the importing code owner config, the imported code
     // owner config and the code owner config that is imported by the imported code owner config
-    assertThat(pathCodeOwners.get().get())
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().getPathCodeOwners())
         .comparingElementsUsing(hasEmail())
         .containsExactly(admin.email(), user.email(), user2.email());
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().hasUnresolvedImports()).isFalse();
   }
 
   @Test
@@ -977,9 +990,10 @@
     // Expectation: we get the global owners from the importing code owner config and the imported
     // code owner config but not the per file code owner from the code owner config that is imported
     // by the imported code owner config
-    assertThat(pathCodeOwners.get().get())
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().getPathCodeOwners())
         .comparingElementsUsing(hasEmail())
         .containsExactly(admin.email(), user.email());
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().hasUnresolvedImports()).isFalse();
   }
 
   @Test
@@ -1027,7 +1041,7 @@
 
     // Expectation: we get the global code owners from the importing and the imported code owner
     // config
-    assertThat(pathCodeOwners.get().get())
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().getPathCodeOwners())
         .comparingElementsUsing(hasEmail())
         .containsExactly(admin.email(), user.email());
   }
@@ -1064,7 +1078,7 @@
     assertThat(pathCodeOwners).isPresent();
 
     // Expectation: we get the global owners from the importing and the imported code owner config
-    assertThat(pathCodeOwners.get().get())
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().getPathCodeOwners())
         .comparingElementsUsing(hasEmail())
         .containsExactly(admin.email(), user.email());
   }
@@ -1111,7 +1125,7 @@
 
     // Expectation: we get the global owners from the importing and the imported code owner config
     // as they were defined at oldRevision
-    assertThat(pathCodeOwners.get().get())
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().getPathCodeOwners())
         .comparingElementsUsing(hasEmail())
         .containsExactly(admin.email(), user.email());
   }
@@ -1147,9 +1161,10 @@
     assertThat(pathCodeOwners).isPresent();
 
     // Expectation: we get the global owners from the importing and the imported code owner config
-    assertThat(pathCodeOwners.get().get())
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().getPathCodeOwners())
         .comparingElementsUsing(hasEmail())
         .containsExactly(admin.email(), user.email());
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().hasUnresolvedImports()).isFalse();
   }
 
   @Test
@@ -1176,9 +1191,10 @@
     assertThat(pathCodeOwners).isPresent();
 
     // Expectation: we get the global owners from the importing code owner config
-    assertThat(pathCodeOwners.get().get())
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().getPathCodeOwners())
         .comparingElementsUsing(hasEmail())
         .containsExactly(admin.email());
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().hasUnresolvedImports()).isTrue();
   }
 
   @Test
@@ -1220,9 +1236,10 @@
     // Expectation: we get the global owners from the importing code owner config, the global code
     // owners from the imported code owner config are ignored since the project that contains the
     // code owner config is hidden
-    assertThat(pathCodeOwners.get().get())
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().getPathCodeOwners())
         .comparingElementsUsing(hasEmail())
         .containsExactly(admin.email());
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().hasUnresolvedImports()).isTrue();
   }
 
   @Test
@@ -1250,9 +1267,10 @@
     assertThat(pathCodeOwners).isPresent();
 
     // Expectation: we get the global owners from the importing code owner config
-    assertThat(pathCodeOwners.get().get())
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().getPathCodeOwners())
         .comparingElementsUsing(hasEmail())
         .containsExactly(admin.email());
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().hasUnresolvedImports()).isTrue();
   }
 
   @Test
@@ -1290,9 +1308,10 @@
     assertThat(pathCodeOwners).isPresent();
 
     // Expectation: we get the global owners from the importing and the imported code owner config
-    assertThat(pathCodeOwners.get().get())
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().getPathCodeOwners())
         .comparingElementsUsing(hasEmail())
         .containsExactly(admin.email(), user.email());
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().hasUnresolvedImports()).isFalse();
   }
 
   @Test
@@ -1337,9 +1356,10 @@
     assertThat(pathCodeOwners).isPresent();
 
     // Expectation: we get the global owners from the importing and the imported code owner config
-    assertThat(pathCodeOwners.get().get())
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().getPathCodeOwners())
         .comparingElementsUsing(hasEmail())
         .containsExactly(admin.email(), user.email());
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().hasUnresolvedImports()).isFalse();
   }
 
   @Test
@@ -1380,9 +1400,10 @@
     assertThat(pathCodeOwners).isPresent();
 
     // Expectation: we get the global owners from the importing and the imported code owner config
-    assertThat(pathCodeOwners.get().get())
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().getPathCodeOwners())
         .comparingElementsUsing(hasEmail())
         .containsExactly(admin.email(), user.email());
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().hasUnresolvedImports()).isFalse();
   }
 
   @Test
@@ -1425,9 +1446,10 @@
     assertThat(pathCodeOwners).isPresent();
 
     // Expectation: we get the global owners from the importing and the imported code owner config
-    assertThat(pathCodeOwners.get().get())
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().getPathCodeOwners())
         .comparingElementsUsing(hasEmail())
         .containsExactly(admin.email(), user.email());
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().hasUnresolvedImports()).isFalse();
   }
 
   @Test
@@ -1459,9 +1481,10 @@
 
     // Expectation: we get the per file code owner from the importing code owner config, the
     // non-resolveable per file import is silently ignored
-    assertThat(pathCodeOwners.get().get())
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().getPathCodeOwners())
         .comparingElementsUsing(hasEmail())
         .containsExactly(admin.email());
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().hasUnresolvedImports()).isTrue();
   }
 
   @Test
@@ -1511,12 +1534,13 @@
     // Expectation: we get the per file code owners from the importing and the global code owner
     // from the imported code owner config, but not the per file code owner from the imported code
     // owner config
-    assertThat(pathCodeOwners.get().get())
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().getPathCodeOwners())
         .comparingElementsUsing(hasEmail())
         .containsExactly(admin.email(), user.email());
 
     // Expectation: the ignoreParentCodeOwners flag from the imported code owner config is ignored
-    assertThat(pathCodeOwners.get().ignoreParentCodeOwners()).isFalse();
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().ignoreParentCodeOwners()).isFalse();
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().hasUnresolvedImports()).isFalse();
   }
 
   @Test
@@ -1570,9 +1594,10 @@
 
     // Expectation: we get the global owners from the importing code owner config, the imported code
     // owner config and the code owner config that is imported by the imported code owner config
-    assertThat(pathCodeOwners.get().get())
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().getPathCodeOwners())
         .comparingElementsUsing(hasEmail())
         .containsExactly(admin.email(), user.email(), user2.email());
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().hasUnresolvedImports()).isFalse();
   }
 
   @Test
@@ -1629,9 +1654,10 @@
     // Expectation: we get the global owners from the importing code owner config and the imported
     // code owner config, but not the per file code owner from the code owner config that is
     // imported by the imported code owner config
-    assertThat(pathCodeOwners.get().get())
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().getPathCodeOwners())
         .comparingElementsUsing(hasEmail())
         .containsExactly(admin.email(), user.email());
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().hasUnresolvedImports()).isFalse();
   }
 
   @Test
@@ -1641,7 +1667,9 @@
             NullPointerException.class,
             () ->
                 PathCodeOwners.matches(
-                    null, Paths.get("bar/baz.md"), mock(PathExpressionMatcher.class)));
+                    /* codeOwnerSet= */ null,
+                    Paths.get("bar/baz.md"),
+                    mock(PathExpressionMatcher.class)));
     assertThat(npe).hasMessageThat().isEqualTo("codeOwnerSet");
   }
 
@@ -1653,7 +1681,7 @@
             () ->
                 PathCodeOwners.matches(
                     CodeOwnerSet.createWithoutPathExpressions(admin.email()),
-                    null,
+                    /* relativePath= */ null,
                     mock(PathExpressionMatcher.class)));
     assertThat(npe).hasMessageThat().isEqualTo("relativePath");
   }
@@ -1683,7 +1711,7 @@
                 PathCodeOwners.matches(
                     CodeOwnerSet.createWithoutPathExpressions(admin.email()),
                     Paths.get("bar/baz.md"),
-                    null));
+                    /* matcher= */ null));
     assertThat(npe).hasMessageThat().isEqualTo("matcher");
   }
 
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/findowners/FindOwnersBackendTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/findowners/FindOwnersBackendTest.java
index 14bb3c3..ff6f184 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/backend/findowners/FindOwnersBackendTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/findowners/FindOwnersBackendTest.java
@@ -19,7 +19,7 @@
 import com.google.gerrit.plugins.codeowners.backend.AbstractFileBasedCodeOwnerBackend;
 import com.google.gerrit.plugins.codeowners.backend.AbstractFileBasedCodeOwnerBackendTest;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfigParser;
-import com.google.gerrit.plugins.codeowners.backend.GlobMatcher;
+import com.google.gerrit.plugins.codeowners.backend.FindOwnersGlobMatcher;
 import org.junit.Test;
 
 /** Tests for {@link FindOwnersBackend}. */
@@ -41,6 +41,8 @@
 
   @Test
   public void getPathExpressionMatcher() throws Exception {
-    assertThat(codeOwnerBackend.getPathExpressionMatcher()).value().isInstanceOf(GlobMatcher.class);
+    assertThat(codeOwnerBackend.getPathExpressionMatcher())
+        .value()
+        .isInstanceOf(FindOwnersGlobMatcher.class);
   }
 }
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/findowners/FindOwnersCodeOwnerConfigParserTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/findowners/FindOwnersCodeOwnerConfigParserTest.java
index b92fc0c..f0ec10b 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/backend/findowners/FindOwnersCodeOwnerConfigParserTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/findowners/FindOwnersCodeOwnerConfigParserTest.java
@@ -16,10 +16,12 @@
 
 import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.gerrit.plugins.codeowners.testing.CodeOwnerConfigSubject.assertThat;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.util.stream.Collectors.joining;
 
+import com.google.common.base.Joiner;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.plugins.codeowners.backend.AbstractCodeOwnerConfigParserTest;
@@ -34,6 +36,7 @@
 import com.google.gerrit.plugins.codeowners.testing.CodeOwnerSetSubject;
 import java.nio.file.Path;
 import java.nio.file.Paths;
+import java.util.regex.Pattern;
 import org.junit.Test;
 
 /** Tests for {@link FindOwnersCodeOwnerConfigParser}. */
@@ -603,4 +606,125 @@
               .isEqualTo(CodeOwnerConfigImportMode.GLOBAL_CODE_OWNER_SETS_ONLY);
         });
   }
+
+  @Test
+  public void replaceEmail_contentCannotBeNull() throws Exception {
+    NullPointerException npe =
+        assertThrows(
+            NullPointerException.class,
+            () ->
+                FindOwnersCodeOwnerConfigParser.replaceEmail(
+                    /* codeOwnerConfigFileContent= */ null, admin.email(), user.email()));
+    assertThat(npe).hasMessageThat().isEqualTo("codeOwnerConfigFileContent");
+  }
+
+  @Test
+  public void replaceEmail_oldEmailCannotBeNull() throws Exception {
+    NullPointerException npe =
+        assertThrows(
+            NullPointerException.class,
+            () ->
+                FindOwnersCodeOwnerConfigParser.replaceEmail(
+                    "content", /* oldEmail= */ null, user.email()));
+    assertThat(npe).hasMessageThat().isEqualTo("oldEmail");
+  }
+
+  @Test
+  public void replaceEmail_newEmailCannotBeNull() throws Exception {
+    NullPointerException npe =
+        assertThrows(
+            NullPointerException.class,
+            () ->
+                FindOwnersCodeOwnerConfigParser.replaceEmail(
+                    "content", admin.email(), /* newEmail= */ null));
+    assertThat(npe).hasMessageThat().isEqualTo("newEmail");
+  }
+
+  @Test
+  public void replaceEmail() throws Exception {
+    String oldEmail = "old@example.com";
+    String newEmail = "new@example.com";
+
+    // In the following test lines '${email} is used as placefolder for the email that we expect to
+    // be replaced.
+    String[] testLines = {
+      // line = email
+      "${email}",
+      // line = email with leading whitespace
+      " ${email}",
+      // line = email with trailing whitespace
+      "${email} ",
+      // line = email with leading and trailing whitespace
+      " ${email} ",
+      // line = email with comment
+      "${email}# comment",
+      // line = email with trailing whitespace and comment
+      "${email} # comment",
+      // line = email with leading whitespace and comment
+      " ${email}# comment",
+      // line = email with leading and trailing whitespace and comment
+      " ${email} # comment",
+      // line = email that ends with oldEmail
+      "foo" + oldEmail,
+      // line = email that starts with oldEmail
+      oldEmail + "bar",
+      // line = email that contains oldEmail
+      "foo" + oldEmail + "bar",
+      // line = email that contains oldEmail with leading and trailing whitespace
+      " foo" + oldEmail + "bar ",
+      // line = email that contains oldEmail with leading and trailing whitespace and comment
+      " foo" + oldEmail + "bar # comment",
+      // email in comment
+      "foo@example.com # ${email}",
+      // email in comment that contains oldEmail
+      "foo@example.com # foo" + oldEmail + "bar",
+      // per-file line with email
+      "per-file *.md=${email}",
+      // per-file line with email and whitespace
+      "per-file *.md = ${email} ",
+      // per-file line with multiple emails and old email at first position
+      "per-file *.md=${email},foo@example.com,bar@example.com",
+      // per-file line with multiple emails and old email at middle position
+      "per-file *.md=foo@example.com,${email},bar@example.com",
+      // per-file line with multiple emails and old email at last position
+      "per-file *.md=foo@example.com,bar@example.com,${email}",
+      // per-file line with multiple emails and old email at last position and comment
+      "per-file *.md=foo@example.com,bar@example.com,${email}# comment",
+      // per-file line with multiple emails and old email at last position and comment with
+      // whitespace
+      "per-file *.md = foo@example.com, bar@example.com , ${email} # comment",
+      // per-file line with multiple emails and old email appearing multiple times
+      "per-file *.md=${email},${email}",
+      "per-file *.md=${email},${email},${email}",
+      "per-file *.md=${email},foo@example.com,${email},bar@example.com,${email}",
+      // per-file line with multiple emails and old email appearing multiple times and comment
+      "per-file *.md=${email},foo@example.com,${email},bar@example.com,${email}# comment",
+      // per-file line with multiple emails and old email appearing multiple times and comment and
+      // whitespace
+      "per-file *.md= ${email} , foo@example.com , ${email} , bar@example.com , ${email} # comment",
+      // per-file line with email that contains old email
+      "per-file *.md=for" + oldEmail + "bar",
+      // per-file line with multiple emails and one email that contains the old email
+      "per-file *.md=for" + oldEmail + "bar,${email}",
+    };
+
+    for (String testLine : testLines) {
+      String content = testLine.replaceAll(Pattern.quote("${email}"), oldEmail);
+      String expectedContent = testLine.replaceAll(Pattern.quote("${email}"), newEmail);
+      assertWithMessage(testLine)
+          .that(FindOwnersCodeOwnerConfigParser.replaceEmail(content, oldEmail, newEmail))
+          .isEqualTo(expectedContent);
+    }
+
+    // join all test lines and replace email in all of them at once
+    String testContent = Joiner.on("\n").join(testLines);
+    String content = testContent.replaceAll(Pattern.quote("${email}"), oldEmail);
+    String expectedContent = testContent.replaceAll(Pattern.quote("${email}"), newEmail);
+    assertThat(FindOwnersCodeOwnerConfigParser.replaceEmail(content, oldEmail, newEmail))
+        .isEqualTo(expectedContent);
+
+    // test that trailing new line is preserved
+    assertThat(FindOwnersCodeOwnerConfigParser.replaceEmail(content + "\n", oldEmail, newEmail))
+        .isEqualTo(expectedContent + "\n");
+  }
 }
diff --git a/javatests/com/google/gerrit/plugins/codeowners/config/AbstractRequiredApprovalConfigTest.java b/javatests/com/google/gerrit/plugins/codeowners/config/AbstractRequiredApprovalConfigTest.java
index 7ab8924..42a32dd 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/config/AbstractRequiredApprovalConfigTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/config/AbstractRequiredApprovalConfigTest.java
@@ -16,16 +16,16 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.plugins.codeowners.config.CodeOwnersPluginConfiguration.SECTION_CODE_OWNERS;
+import static com.google.gerrit.plugins.codeowners.testing.RequiredApprovalSubject.assertThat;
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
-import static com.google.gerrit.truth.OptionalSubject.assertThat;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.plugins.codeowners.acceptance.AbstractCodeOwnersTest;
 import com.google.gerrit.server.git.validators.CommitValidationMessage;
 import com.google.gerrit.server.git.validators.ValidationMessage;
 import com.google.gerrit.server.project.ProjectLevelConfig;
 import com.google.gerrit.server.project.ProjectState;
-import java.util.Optional;
 import org.eclipse.jgit.lib.Config;
 import org.junit.Test;
 
@@ -34,78 +34,76 @@
   /** Must return the {@link AbstractRequiredApprovalConfig} that should be tested. */
   protected abstract AbstractRequiredApprovalConfig getRequiredApprovalConfig();
 
-  protected void testGetFromGlobalPluginConfig() throws Exception {
-    ProjectState projectState = projectCache.get(project).orElseThrow(illegalState(project));
-    Optional<RequiredApproval> requiredApproval =
-        getRequiredApprovalConfig().getFromGlobalPluginConfig(projectState);
-    assertThat(requiredApproval).isPresent();
-    assertThat(requiredApproval.get().labelType().getName()).isEqualTo("Code-Review");
-    assertThat(requiredApproval.get().value()).isEqualTo(2);
-  }
-
-  protected void testCannotGetFromGlobalPluginConfigIfConfigIsInvalid() throws Exception {
+  protected void testCannotGetIfGlobalConfigIsInvalid(String invalidValue) throws Exception {
     ProjectState projectState = projectCache.get(project).orElseThrow(illegalState(project));
     InvalidPluginConfigurationException exception =
         assertThrows(
             InvalidPluginConfigurationException.class,
-            () -> getRequiredApprovalConfig().getFromGlobalPluginConfig(projectState));
+            () -> getRequiredApprovalConfig().get(projectState, new Config()));
     assertThat(exception)
         .hasMessageThat()
         .isEqualTo(
             String.format(
-                "Invalid configuration of the code-owners plugin. Required approval 'INVALID' that is"
+                "Invalid configuration of the code-owners plugin. Required approval '%s' that is"
                     + " configured in gerrit.config (parameter plugin.code-owners.%s) is"
                     + " invalid: Invalid format, expected '<label-name>+<label-value>'.",
-                getRequiredApprovalConfig().getConfigKey()));
+                invalidValue, getRequiredApprovalConfig().getConfigKey()));
   }
 
   @Test
-  public void cannotGetForProjectForNullProjectState() throws Exception {
+  public void cannotGetForNullProjectState() throws Exception {
     NullPointerException npe =
         assertThrows(
             NullPointerException.class,
-            () -> getRequiredApprovalConfig().getForProject(null, new Config()));
+            () -> getRequiredApprovalConfig().get(/* projectState= */ null, new Config()));
     assertThat(npe).hasMessageThat().isEqualTo("projectState");
   }
 
   @Test
-  public void cannotGetForProjectForNullConfig() throws Exception {
+  public void cannotGetForNullConfig() throws Exception {
     ProjectState projectState = projectCache.get(project).orElseThrow(illegalState(project));
     NullPointerException npe =
         assertThrows(
             NullPointerException.class,
-            () -> getRequiredApprovalConfig().getForProject(projectState, null));
+            () -> getRequiredApprovalConfig().get(projectState, /* pluginConfig= */ null));
     assertThat(npe).hasMessageThat().isEqualTo("pluginConfig");
   }
 
   @Test
-  public void getForProjectWhenRequiredApprovalIsNotSet() throws Exception {
+  public void getWhenRequiredApprovalIsNotSet() throws Exception {
     ProjectState projectState = projectCache.get(project).orElseThrow(illegalState(project));
-    assertThat(getRequiredApprovalConfig().getForProject(projectState, new Config())).isEmpty();
+    assertThat(getRequiredApprovalConfig().get(projectState, new Config())).isEmpty();
   }
 
   @Test
-  public void getForProject() throws Exception {
+  public void getFromPluginConfig() throws Exception {
     ProjectState projectState = projectCache.get(project).orElseThrow(illegalState(project));
     Config cfg = new Config();
     cfg.setString(
-        SECTION_CODE_OWNERS, null, getRequiredApprovalConfig().getConfigKey(), "Code-Review+2");
-    Optional<RequiredApproval> requiredApproval =
-        getRequiredApprovalConfig().getForProject(projectState, cfg);
-    assertThat(requiredApproval).isPresent();
-    assertThat(requiredApproval.get().labelType().getName()).isEqualTo("Code-Review");
-    assertThat(requiredApproval.get().value()).isEqualTo(2);
+        SECTION_CODE_OWNERS,
+        /* subsection= */ null,
+        getRequiredApprovalConfig().getConfigKey(),
+        "Code-Review+2");
+    ImmutableList<RequiredApproval> requiredApproval =
+        getRequiredApprovalConfig().get(projectState, cfg);
+    assertThat(requiredApproval).hasSize(1);
+    assertThat(requiredApproval).element(0).hasLabelNameThat().isEqualTo("Code-Review");
+    assertThat(requiredApproval).element(0).hasValueThat().isEqualTo(2);
   }
 
   @Test
-  public void cannotGetForProjectIfConfigIsInvalid() throws Exception {
+  public void cannotGetFromPluginConfigIfConfigIsInvalid() throws Exception {
     ProjectState projectState = projectCache.get(project).orElseThrow(illegalState(project));
     Config cfg = new Config();
-    cfg.setString(SECTION_CODE_OWNERS, null, getRequiredApprovalConfig().getConfigKey(), "INVALID");
+    cfg.setString(
+        SECTION_CODE_OWNERS,
+        /* subsection= */ null,
+        getRequiredApprovalConfig().getConfigKey(),
+        "INVALID");
     InvalidPluginConfigurationException exception =
         assertThrows(
             InvalidPluginConfigurationException.class,
-            () -> getRequiredApprovalConfig().getForProject(projectState, cfg));
+            () -> getRequiredApprovalConfig().get(projectState, cfg));
     assertThat(exception)
         .hasMessageThat()
         .isEqualTo(
@@ -117,21 +115,6 @@
   }
 
   @Test
-  public void cannotGetFromGlobalPluginConfigForNullProjectState() throws Exception {
-    NullPointerException npe =
-        assertThrows(
-            NullPointerException.class,
-            () -> getRequiredApprovalConfig().getFromGlobalPluginConfig(null));
-    assertThat(npe).hasMessageThat().isEqualTo("projectState");
-  }
-
-  @Test
-  public void getFromGlobalPluginConfigWhenRequiredApprovalIsNotSet() throws Exception {
-    ProjectState projectState = projectCache.get(project).orElseThrow(illegalState(project));
-    assertThat(getRequiredApprovalConfig().getFromGlobalPluginConfig(projectState)).isEmpty();
-  }
-
-  @Test
   public void cannotValidateProjectLevelConfigWithNullProjectState() throws Exception {
     NullPointerException npe =
         assertThrows(
@@ -139,7 +122,7 @@
             () ->
                 getRequiredApprovalConfig()
                     .validateProjectLevelConfig(
-                        null,
+                        /* projectState= */ null,
                         "code-owners.config",
                         new ProjectLevelConfig.Bare("code-owners.config")));
     assertThat(npe).hasMessageThat().isEqualTo("projectState");
@@ -154,7 +137,9 @@
             () ->
                 getRequiredApprovalConfig()
                     .validateProjectLevelConfig(
-                        projectState, null, new ProjectLevelConfig.Bare("code-owners.config")));
+                        projectState,
+                        /* fileName= */ null,
+                        new ProjectLevelConfig.Bare("code-owners.config")));
     assertThat(npe).hasMessageThat().isEqualTo("fileName");
   }
 
@@ -166,14 +151,15 @@
             NullPointerException.class,
             () ->
                 getRequiredApprovalConfig()
-                    .validateProjectLevelConfig(projectState, "code-owners.config", null));
+                    .validateProjectLevelConfig(
+                        projectState, "code-owners.config", /* projectLevelConfig= */ null));
     assertThat(npe).hasMessageThat().isEqualTo("projectLevelConfig");
   }
 
   @Test
   public void validateEmptyProjectLevelConfig() throws Exception {
     ProjectState projectState = projectCache.get(project).orElseThrow(illegalState(project));
-    Optional<CommitValidationMessage> commitValidationMessage =
+    ImmutableList<CommitValidationMessage> commitValidationMessage =
         getRequiredApprovalConfig()
             .validateProjectLevelConfig(
                 projectState,
@@ -188,8 +174,11 @@
     ProjectLevelConfig.Bare cfg = new ProjectLevelConfig.Bare("code-owners.config");
     cfg.getConfig()
         .setString(
-            SECTION_CODE_OWNERS, null, getRequiredApprovalConfig().getConfigKey(), "Code-Review+2");
-    Optional<CommitValidationMessage> commitValidationMessage =
+            SECTION_CODE_OWNERS,
+            /* subsection= */ null,
+            getRequiredApprovalConfig().getConfigKey(),
+            "Code-Review+2");
+    ImmutableList<CommitValidationMessage> commitValidationMessage =
         getRequiredApprovalConfig()
             .validateProjectLevelConfig(projectState, "code-owners.config", cfg);
     assertThat(commitValidationMessage).isEmpty();
@@ -201,13 +190,16 @@
     ProjectLevelConfig.Bare cfg = new ProjectLevelConfig.Bare("code-owners.config");
     cfg.getConfig()
         .setString(
-            SECTION_CODE_OWNERS, null, getRequiredApprovalConfig().getConfigKey(), "INVALID");
-    Optional<CommitValidationMessage> commitValidationMessage =
+            SECTION_CODE_OWNERS,
+            /* subsection= */ null,
+            getRequiredApprovalConfig().getConfigKey(),
+            "INVALID");
+    ImmutableList<CommitValidationMessage> commitValidationMessage =
         getRequiredApprovalConfig()
             .validateProjectLevelConfig(projectState, "code-owners.config", cfg);
-    assertThat(commitValidationMessage).isPresent();
-    assertThat(commitValidationMessage.get().getType()).isEqualTo(ValidationMessage.Type.ERROR);
-    assertThat(commitValidationMessage.get().getMessage())
+    assertThat(commitValidationMessage).hasSize(1);
+    assertThat(commitValidationMessage.get(0).getType()).isEqualTo(ValidationMessage.Type.ERROR);
+    assertThat(commitValidationMessage.get(0).getMessage())
         .isEqualTo(
             String.format(
                 "Required approval 'INVALID' that is configured in code-owners.config (parameter"
diff --git a/javatests/com/google/gerrit/plugins/codeowners/config/BUILD b/javatests/com/google/gerrit/plugins/codeowners/config/BUILD
index 9f115b5..7f1985a 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/config/BUILD
+++ b/javatests/com/google/gerrit/plugins/codeowners/config/BUILD
@@ -15,6 +15,7 @@
         ":testbases",
         "//plugins/code-owners:code-owners__plugin",
         "//plugins/code-owners/java/com/google/gerrit/plugins/codeowners/acceptance",
+        "//plugins/code-owners/java/com/google/gerrit/plugins/codeowners/testing",
     ],
 )
 
@@ -25,9 +26,11 @@
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/testing:gerrit-test-util",
         "//java/com/google/gerrit/truth",
+        "//lib:guava",
         "//lib:jgit",
         "//lib/truth",
         "//plugins/code-owners:code-owners__plugin",
         "//plugins/code-owners/java/com/google/gerrit/plugins/codeowners/acceptance",
+        "//plugins/code-owners/java/com/google/gerrit/plugins/codeowners/testing",
     ],
 )
diff --git a/javatests/com/google/gerrit/plugins/codeowners/config/CodeOwnersPluginConfigurationTest.java b/javatests/com/google/gerrit/plugins/codeowners/config/CodeOwnersPluginConfigurationTest.java
index 723836d..f03427d 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/config/CodeOwnersPluginConfigurationTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/config/CodeOwnersPluginConfigurationTest.java
@@ -14,10 +14,14 @@
 
 package com.google.gerrit.plugins.codeowners.config;
 
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.plugins.codeowners.testing.RequiredApprovalSubject.assertThat;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static com.google.gerrit.truth.OptionalSubject.assertThat;
 
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.Nullable;
@@ -25,6 +29,7 @@
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.Project.NameKey;
 import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.common.LabelDefinitionInput;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.registration.PrivateInternals_DynamicMapImpl;
 import com.google.gerrit.extensions.registration.RegistrationHandle;
@@ -34,6 +39,7 @@
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerBackend;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfig;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfigUpdate;
+import com.google.gerrit.plugins.codeowners.backend.FallbackCodeOwners;
 import com.google.gerrit.plugins.codeowners.backend.findowners.FindOwnersBackend;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.inject.Inject;
@@ -66,10 +72,7 @@
     NullPointerException npe =
         assertThrows(
             NullPointerException.class,
-            () ->
-                codeOwnersPluginConfiguration.isDisabled(
-                    /** project = */
-                    (Project.NameKey) null));
+            () -> codeOwnersPluginConfiguration.isDisabled(/* project= */ (Project.NameKey) null));
     assertThat(npe).hasMessageThat().isEqualTo("project");
   }
 
@@ -80,8 +83,7 @@
             NullPointerException.class,
             () ->
                 codeOwnersPluginConfiguration.isDisabled(
-                    /** branchNameKey = */
-                    (BranchNameKey) null));
+                    /* branchNameKey= */ (BranchNameKey) null));
     assertThat(npe).hasMessageThat().isEqualTo("branchNameKey");
   }
 
@@ -464,17 +466,16 @@
   @Test
   public void getDefaultRequiredApprovalWhenNoRequiredApprovalIsConfigured() throws Exception {
     RequiredApproval requiredApproval = codeOwnersPluginConfiguration.getRequiredApproval(project);
-    assertThat(requiredApproval.labelType().getName())
-        .isEqualTo(RequiredApprovalConfig.DEFAULT_LABEL);
-    assertThat(requiredApproval.value()).isEqualTo(RequiredApprovalConfig.DEFAULT_VALUE);
+    assertThat(requiredApproval).hasLabelNameThat().isEqualTo(RequiredApprovalConfig.DEFAULT_LABEL);
+    assertThat(requiredApproval).hasValueThat().isEqualTo(RequiredApprovalConfig.DEFAULT_VALUE);
   }
 
   @Test
   @GerritConfig(name = "plugin.code-owners.requiredApproval", value = "Code-Review+2")
   public void getConfiguredDefaultRequireApproval() throws Exception {
     RequiredApproval requiredApproval = codeOwnersPluginConfiguration.getRequiredApproval(project);
-    assertThat(requiredApproval.labelType().getName()).isEqualTo("Code-Review");
-    assertThat(requiredApproval.value()).isEqualTo(2);
+    assertThat(requiredApproval).hasLabelNameThat().isEqualTo("Code-Review");
+    assertThat(requiredApproval).hasValueThat().isEqualTo(2);
   }
 
   @Test
@@ -534,8 +535,22 @@
   public void getRequiredApprovalConfiguredOnProjectLevel() throws Exception {
     configureRequiredApproval(project, "Code-Review+2");
     RequiredApproval requiredApproval = codeOwnersPluginConfiguration.getRequiredApproval(project);
-    assertThat(requiredApproval.labelType().getName()).isEqualTo("Code-Review");
-    assertThat(requiredApproval.value()).isEqualTo(2);
+    assertThat(requiredApproval).hasLabelNameThat().isEqualTo("Code-Review");
+    assertThat(requiredApproval).hasValueThat().isEqualTo(2);
+  }
+
+  @Test
+  public void getRequiredApprovalMultipleConfiguredOnProjectLevel() throws Exception {
+    setCodeOwnersConfig(
+        project,
+        /* subsection= */ null,
+        RequiredApprovalConfig.KEY_REQUIRED_APPROVAL,
+        ImmutableList.of("Code-Review+2", "Code-Review+1"));
+
+    // If multiple values are set for a key, the last value wins.
+    RequiredApproval requiredApproval = codeOwnersPluginConfiguration.getRequiredApproval(project);
+    assertThat(requiredApproval).hasLabelNameThat().isEqualTo("Code-Review");
+    assertThat(requiredApproval).hasValueThat().isEqualTo(1);
   }
 
   @Test
@@ -544,16 +559,16 @@
       throws Exception {
     configureRequiredApproval(project, "Code-Review+2");
     RequiredApproval requiredApproval = codeOwnersPluginConfiguration.getRequiredApproval(project);
-    assertThat(requiredApproval.labelType().getName()).isEqualTo("Code-Review");
-    assertThat(requiredApproval.value()).isEqualTo(2);
+    assertThat(requiredApproval).hasLabelNameThat().isEqualTo("Code-Review");
+    assertThat(requiredApproval).hasValueThat().isEqualTo(2);
   }
 
   @Test
   public void requiredApprovalIsInheritedFromParentProject() throws Exception {
     configureRequiredApproval(allProjects, "Code-Review+2");
     RequiredApproval requiredApproval = codeOwnersPluginConfiguration.getRequiredApproval(project);
-    assertThat(requiredApproval.labelType().getName()).isEqualTo("Code-Review");
-    assertThat(requiredApproval.value()).isEqualTo(2);
+    assertThat(requiredApproval).hasLabelNameThat().isEqualTo("Code-Review");
+    assertThat(requiredApproval).hasValueThat().isEqualTo(2);
   }
 
   @Test
@@ -561,8 +576,8 @@
   public void inheritedRequiredApprovalOverridesDefaultRequiredApproval() throws Exception {
     configureRequiredApproval(allProjects, "Code-Review+2");
     RequiredApproval requiredApproval = codeOwnersPluginConfiguration.getRequiredApproval(project);
-    assertThat(requiredApproval.labelType().getName()).isEqualTo("Code-Review");
-    assertThat(requiredApproval.value()).isEqualTo(2);
+    assertThat(requiredApproval).hasLabelNameThat().isEqualTo("Code-Review");
+    assertThat(requiredApproval).hasValueThat().isEqualTo(2);
   }
 
   @Test
@@ -570,8 +585,8 @@
     configureRequiredApproval(allProjects, "Code-Review+1");
     configureRequiredApproval(project, "Code-Review+2");
     RequiredApproval requiredApproval = codeOwnersPluginConfiguration.getRequiredApproval(project);
-    assertThat(requiredApproval.labelType().getName()).isEqualTo("Code-Review");
-    assertThat(requiredApproval.value()).isEqualTo(2);
+    assertThat(requiredApproval).hasLabelNameThat().isEqualTo("Code-Review");
+    assertThat(requiredApproval).hasValueThat().isEqualTo(2);
   }
 
   @Test
@@ -635,8 +650,8 @@
     Project.NameKey otherProject = projectOperations.newProject().create();
     configureRequiredApproval(otherProject, "Code-Review+2");
     RequiredApproval requiredApproval = codeOwnersPluginConfiguration.getRequiredApproval(project);
-    assertThat(requiredApproval.labelType().getName()).isEqualTo("Code-Review");
-    assertThat(requiredApproval.value()).isEqualTo(1);
+    assertThat(requiredApproval).hasLabelNameThat().isEqualTo("Code-Review");
+    assertThat(requiredApproval).hasValueThat().isEqualTo(1);
   }
 
   @Test
@@ -661,21 +676,41 @@
   @Test
   @GerritConfig(name = "plugin.code-owners.overrideApproval", value = "Code-Review+2")
   public void getConfiguredDefaultOverrideApproval() throws Exception {
-    Optional<RequiredApproval> requiredApproval =
+    ImmutableSet<RequiredApproval> requiredApproval =
         codeOwnersPluginConfiguration.getOverrideApproval(project);
-    assertThat(requiredApproval).isPresent();
-    assertThat(requiredApproval.get().labelType().getName()).isEqualTo("Code-Review");
-    assertThat(requiredApproval.get().value()).isEqualTo(2);
+    assertThat(requiredApproval).hasSize(1);
+    assertThat(requiredApproval).element(0).hasLabelNameThat().isEqualTo("Code-Review");
+    assertThat(requiredApproval).element(0).hasValueThat().isEqualTo(2);
   }
 
   @Test
   public void getOverrideApprovalConfiguredOnProjectLevel() throws Exception {
     configureOverrideApproval(project, "Code-Review+2");
-    Optional<RequiredApproval> requiredApproval =
+    ImmutableSet<RequiredApproval> requiredApproval =
         codeOwnersPluginConfiguration.getOverrideApproval(project);
-    assertThat(requiredApproval).isPresent();
-    assertThat(requiredApproval.get().labelType().getName()).isEqualTo("Code-Review");
-    assertThat(requiredApproval.get().value()).isEqualTo(2);
+    assertThat(requiredApproval).hasSize(1);
+    assertThat(requiredApproval).element(0).hasLabelNameThat().isEqualTo("Code-Review");
+    assertThat(requiredApproval).element(0).hasValueThat().isEqualTo(2);
+  }
+
+  @Test
+  public void getOverrideApprovalMultipleConfiguredOnProjectLevel() throws Exception {
+    createOwnersOverrideLabel();
+    createOwnersOverrideLabel("Other-Override");
+
+    setCodeOwnersConfig(
+        project,
+        /* subsection= */ null,
+        OverrideApprovalConfig.KEY_OVERRIDE_APPROVAL,
+        ImmutableList.of("Owners-Override+1", "Other-Override+1"));
+
+    ImmutableSet<RequiredApproval> requiredApprovals =
+        codeOwnersPluginConfiguration.getOverrideApproval(project);
+    assertThat(
+            requiredApprovals.stream()
+                .map(requiredApproval -> requiredApproval.toString())
+                .collect(toImmutableSet()))
+        .containsExactly("Owners-Override+1", "Other-Override+1");
   }
 
   @Test
@@ -692,6 +727,22 @@
   }
 
   @Test
+  public void getOverrideApprovalDuplicatesAreFilteredOut() throws Exception {
+    setCodeOwnersConfig(
+        project,
+        /* subsection= */ null,
+        OverrideApprovalConfig.KEY_OVERRIDE_APPROVAL,
+        ImmutableList.of("Code-Review+2", "Code-Review+1", "Code-Review+2"));
+
+    // If multiple values are set for a key, the last value wins.
+    ImmutableSet<RequiredApproval> requiredApproval =
+        codeOwnersPluginConfiguration.getOverrideApproval(project);
+    assertThat(requiredApproval).hasSize(1);
+    assertThat(requiredApproval).element(0).hasLabelNameThat().isEqualTo("Code-Review");
+    assertThat(requiredApproval).element(0).hasValueThat().isEqualTo(1);
+  }
+
+  @Test
   @GerritConfig(name = "plugin.code-owners.enableExperimentalRestEndpoints", value = "false")
   public void checkExperimentalRestEndpointsEnabledThrowsExceptionIfDisabled() throws Exception {
     MethodNotAllowedException exception =
@@ -725,10 +776,7 @@
     NullPointerException npe =
         assertThrows(
             NullPointerException.class,
-            () ->
-                codeOwnersPluginConfiguration.getFileExtension(
-                    /** project = */
-                    null));
+            () -> codeOwnersPluginConfiguration.getFileExtension(/* project= */ null));
     assertThat(npe).hasMessageThat().isEqualTo("project");
   }
 
@@ -769,10 +817,7 @@
     NullPointerException npe =
         assertThrows(
             NullPointerException.class,
-            () ->
-                codeOwnersPluginConfiguration.getMergeCommitStrategy(
-                    /** project = */
-                    null));
+            () -> codeOwnersPluginConfiguration.getMergeCommitStrategy(/* project= */ null));
     assertThat(npe).hasMessageThat().isEqualTo("project");
   }
 
@@ -820,40 +865,123 @@
         .isEqualTo(MergeCommitStrategy.ALL_CHANGED_FILES);
   }
 
+  @Test
+  public void cannotGetFallbackCodeOwnersForNullProject() throws Exception {
+    NullPointerException npe =
+        assertThrows(
+            NullPointerException.class,
+            () -> codeOwnersPluginConfiguration.getFallbackCodeOwners(/* project= */ null));
+    assertThat(npe).hasMessageThat().isEqualTo("project");
+  }
+
+  @Test
+  public void getFallbackCodeOwnersIfNoneIsConfigured() throws Exception {
+    assertThat(codeOwnersPluginConfiguration.getFallbackCodeOwners(project))
+        .isEqualTo(FallbackCodeOwners.NONE);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.fallbackCodeOwners", value = "ALL_USERS")
+  public void getFallbackCodeOwnersIfNoneIsConfiguredOnProjectLevel() throws Exception {
+    assertThat(codeOwnersPluginConfiguration.getFallbackCodeOwners(project))
+        .isEqualTo(FallbackCodeOwners.ALL_USERS);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.fallbackCodeOwners", value = "ALL_USERS")
+  public void fallbackCodeOnwersOnProjectLevelOverridesGlobalFallbackCodeOwners() throws Exception {
+    configureFallbackCodeOwners(project, FallbackCodeOwners.NONE);
+    assertThat(codeOwnersPluginConfiguration.getFallbackCodeOwners(project))
+        .isEqualTo(FallbackCodeOwners.NONE);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.fallbackCodeOwners", value = "ALL_USERS")
+  public void fallbackCodeOwnersIsInheritedFromParentProject() throws Exception {
+    configureFallbackCodeOwners(allProjects, FallbackCodeOwners.NONE);
+    assertThat(codeOwnersPluginConfiguration.getFallbackCodeOwners(project))
+        .isEqualTo(FallbackCodeOwners.NONE);
+  }
+
+  @Test
+  public void inheritedFallbackCodeOwnersCanBeOverridden() throws Exception {
+    configureFallbackCodeOwners(allProjects, FallbackCodeOwners.ALL_USERS);
+    configureFallbackCodeOwners(project, FallbackCodeOwners.NONE);
+    assertThat(codeOwnersPluginConfiguration.getFallbackCodeOwners(project))
+        .isEqualTo(FallbackCodeOwners.NONE);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.enableImplicitApprovals", value = "true")
+  public void implicitApprovalsAreDisabledIfRequiredLabelIgnoresSelfApprovals() throws Exception {
+    assertThat(codeOwnersPluginConfiguration.areImplicitApprovalsEnabled(project)).isTrue();
+
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.ignoreSelfApproval = true;
+    gApi.projects().name(allProjects.get()).label("Code-Review").update(input);
+    assertThat(codeOwnersPluginConfiguration.areImplicitApprovalsEnabled(project)).isFalse();
+  }
+
+  @Test
+  public void cannotGetMaxPathsInChangeMessagesForNullProject() throws Exception {
+    NullPointerException npe =
+        assertThrows(
+            NullPointerException.class,
+            () -> codeOwnersPluginConfiguration.getMaxPathsInChangeMessages(/* project= */ null));
+    assertThat(npe).hasMessageThat().isEqualTo("project");
+  }
+
+  @Test
+  public void getMaxPathsInChangeMessagesIfNoneIsConfigured() throws Exception {
+    assertThat(codeOwnersPluginConfiguration.getMaxPathsInChangeMessages(project))
+        .isEqualTo(GeneralConfig.DEFAULT_MAX_PATHS_IN_CHANGE_MESSAGES);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.maxPathsInChangeMessages", value = "50")
+  public void getMaxPathsInChangeMessagesIfNoneIsConfiguredOnProjectLevel() throws Exception {
+    assertThat(codeOwnersPluginConfiguration.getMaxPathsInChangeMessages(project)).isEqualTo(50);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.maxPathsInChangeMessages", value = "50")
+  public void maxPathInChangeMessagesOnProjectLevelOverridesGlobalMaxPathInChangeMessages()
+      throws Exception {
+    configureFallbackCodeOwners(project, FallbackCodeOwners.NONE);
+    assertThat(codeOwnersPluginConfiguration.getFallbackCodeOwners(project))
+        .isEqualTo(FallbackCodeOwners.NONE);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.maxPathsInChangeMessages", value = "50")
+  public void maxPathInChangeMessagesIsInheritedFromParentProject() throws Exception {
+    configureMaxPathsInChangeMessages(allProjects, 20);
+    assertThat(codeOwnersPluginConfiguration.getMaxPathsInChangeMessages(project)).isEqualTo(20);
+  }
+
+  @Test
+  public void inheritedMaxPathInChangeMessagesCanBeOverridden() throws Exception {
+    configureMaxPathsInChangeMessages(allProjects, 50);
+    configureMaxPathsInChangeMessages(project, 20);
+    assertThat(codeOwnersPluginConfiguration.getMaxPathsInChangeMessages(project)).isEqualTo(20);
+  }
+
   private void configureDisabled(Project.NameKey project, String disabled) throws Exception {
-    setCodeOwnersConfig(
-        project,
-        /** subsection = */
-        null,
-        StatusConfig.KEY_DISABLED,
-        disabled);
+    setCodeOwnersConfig(project, /* subsection= */ null, StatusConfig.KEY_DISABLED, disabled);
   }
 
   private void configureDisabledBranch(Project.NameKey project, String disabledBranch)
       throws Exception {
     setCodeOwnersConfig(
-        project,
-        /** subsection = */
-        null,
-        StatusConfig.KEY_DISABLED_BRANCH,
-        disabledBranch);
+        project, /* subsection= */ null, StatusConfig.KEY_DISABLED_BRANCH, disabledBranch);
   }
 
   private void enableCodeOwnersForAllBranches(Project.NameKey project) throws Exception {
-    setCodeOwnersConfig(
-        project,
-        /** subsection = */
-        null,
-        StatusConfig.KEY_DISABLED_BRANCH,
-        "");
+    setCodeOwnersConfig(project, /* subsection= */ null, StatusConfig.KEY_DISABLED_BRANCH, "");
   }
 
   private void configureBackend(Project.NameKey project, String backendName) throws Exception {
-    configureBackend(
-        project,
-        /** branch = */
-        null,
-        backendName);
+    configureBackend(project, /* branch= */ null, backendName);
   }
 
   private void configureBackend(
@@ -865,8 +993,7 @@
       throws Exception {
     setCodeOwnersConfig(
         project,
-        /** subsection = */
-        null,
+        /* subsection= */ null,
         RequiredApprovalConfig.KEY_REQUIRED_APPROVAL,
         requiredApproval);
   }
@@ -875,8 +1002,7 @@
       throws Exception {
     setCodeOwnersConfig(
         project,
-        /** subsection = */
-        null,
+        /* subsection= */ null,
         OverrideApprovalConfig.KEY_OVERRIDE_APPROVAL,
         requiredApproval);
   }
@@ -884,23 +1010,36 @@
   private void configureFileExtension(Project.NameKey project, String fileExtension)
       throws Exception {
     setCodeOwnersConfig(
-        project,
-        /** subsection = */
-        null,
-        GeneralConfig.KEY_FILE_EXTENSION,
-        fileExtension);
+        project, /* subsection= */ null, GeneralConfig.KEY_FILE_EXTENSION, fileExtension);
   }
 
   private void configureMergeCommitStrategy(
       Project.NameKey project, MergeCommitStrategy mergeCommitStrategy) throws Exception {
     setCodeOwnersConfig(
         project,
-        /** subsection = */
-        null,
+        /* subsection= */ null,
         GeneralConfig.KEY_MERGE_COMMIT_STRATEGY,
         mergeCommitStrategy.name());
   }
 
+  private void configureFallbackCodeOwners(
+      Project.NameKey project, FallbackCodeOwners fallbackCodeOwners) throws Exception {
+    setCodeOwnersConfig(
+        project,
+        /* subsection= */ null,
+        GeneralConfig.KEY_FALLBACK_CODE_OWNERS,
+        fallbackCodeOwners.name());
+  }
+
+  private void configureMaxPathsInChangeMessages(
+      Project.NameKey project, int maxPathsInChangeMessages) throws Exception {
+    setCodeOwnersConfig(
+        project,
+        /* subsection= */ null,
+        GeneralConfig.KEY_MAX_PATHS_IN_CHANGE_MESSAGES,
+        Integer.toString(maxPathsInChangeMessages));
+  }
+
   private AutoCloseable registerTestBackend() {
     RegistrationHandle registrationHandle =
         ((PrivateInternals_DynamicMapImpl<CodeOwnerBackend>) codeOwnerBackends)
diff --git a/javatests/com/google/gerrit/plugins/codeowners/config/GeneralConfigTest.java b/javatests/com/google/gerrit/plugins/codeowners/config/GeneralConfigTest.java
index 19e3300..78adae8 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/config/GeneralConfigTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/config/GeneralConfigTest.java
@@ -16,10 +16,14 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.plugins.codeowners.config.CodeOwnersPluginConfiguration.SECTION_CODE_OWNERS;
+import static com.google.gerrit.plugins.codeowners.config.GeneralConfig.DEFAULT_MAX_PATHS_IN_CHANGE_MESSAGES;
 import static com.google.gerrit.plugins.codeowners.config.GeneralConfig.KEY_ENABLE_IMPLICIT_APPROVALS;
 import static com.google.gerrit.plugins.codeowners.config.GeneralConfig.KEY_ENABLE_VALIDATION_ON_COMMIT_RECEIVED;
+import static com.google.gerrit.plugins.codeowners.config.GeneralConfig.KEY_ENABLE_VALIDATION_ON_SUBMIT;
+import static com.google.gerrit.plugins.codeowners.config.GeneralConfig.KEY_FALLBACK_CODE_OWNERS;
 import static com.google.gerrit.plugins.codeowners.config.GeneralConfig.KEY_FILE_EXTENSION;
 import static com.google.gerrit.plugins.codeowners.config.GeneralConfig.KEY_GLOBAL_CODE_OWNER;
+import static com.google.gerrit.plugins.codeowners.config.GeneralConfig.KEY_MAX_PATHS_IN_CHANGE_MESSAGES;
 import static com.google.gerrit.plugins.codeowners.config.GeneralConfig.KEY_MERGE_COMMIT_STRATEGY;
 import static com.google.gerrit.plugins.codeowners.config.GeneralConfig.KEY_OVERRIDE_INFO_URL;
 import static com.google.gerrit.plugins.codeowners.config.GeneralConfig.KEY_READ_ONLY;
@@ -33,6 +37,7 @@
 import com.google.gerrit.plugins.codeowners.api.CodeOwnerConfigValidationPolicy;
 import com.google.gerrit.plugins.codeowners.api.MergeCommitStrategy;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerReference;
+import com.google.gerrit.plugins.codeowners.backend.FallbackCodeOwners;
 import com.google.gerrit.server.git.validators.CommitValidationMessage;
 import com.google.gerrit.server.git.validators.ValidationMessage;
 import com.google.gerrit.server.project.ProjectLevelConfig;
@@ -175,6 +180,50 @@
   }
 
   @Test
+  public void cannotGetEnableValidationOnSubmitForNullProject() throws Exception {
+    NullPointerException npe =
+        assertThrows(
+            NullPointerException.class,
+            () -> generalConfig.getCodeOwnerConfigValidationPolicyForSubmit(null, new Config()));
+    assertThat(npe).hasMessageThat().isEqualTo("project");
+  }
+
+  @Test
+  public void cannotGetEnableValidationOnSubmitForNullPluginConfig() throws Exception {
+    NullPointerException npe =
+        assertThrows(
+            NullPointerException.class,
+            () -> generalConfig.getCodeOwnerConfigValidationPolicyForSubmit(project, null));
+    assertThat(npe).hasMessageThat().isEqualTo("pluginConfig");
+  }
+
+  @Test
+  public void noEnableValidationOnSubmitConfiguration() throws Exception {
+    assertThat(generalConfig.getCodeOwnerConfigValidationPolicyForSubmit(project, new Config()))
+        .isEqualTo(CodeOwnerConfigValidationPolicy.TRUE);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.enableValidationOnSubmit", value = "false")
+  public void
+      enableValidationOnSubmitConfigurationIsRetrievedFromGerritConfigIfNotSpecifiedOnProjectLevel()
+          throws Exception {
+    assertThat(generalConfig.getCodeOwnerConfigValidationPolicyForSubmit(project, new Config()))
+        .isEqualTo(CodeOwnerConfigValidationPolicy.FALSE);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.enableValidationOnSubmit", value = "false")
+  public void
+      enableValidationOnSubmitConfigurationInPluginConfigOverridesEnableValidationOnSubmitConfigurationInGerritConfig()
+          throws Exception {
+    Config cfg = new Config();
+    cfg.setString(SECTION_CODE_OWNERS, null, KEY_ENABLE_VALIDATION_ON_SUBMIT, "true");
+    assertThat(generalConfig.getCodeOwnerConfigValidationPolicyForSubmit(project, cfg))
+        .isEqualTo(CodeOwnerConfigValidationPolicy.TRUE);
+  }
+
+  @Test
   public void cannotGetMergeCommitStrategyForNullPluginConfig() throws Exception {
     NullPointerException npe =
         assertThrows(
@@ -395,4 +444,119 @@
     cfg.setString(SECTION_CODE_OWNERS, null, KEY_OVERRIDE_INFO_URL, "http://bar.example.com");
     assertThat(generalConfig.getOverrideInfoUrl(cfg)).value().isEqualTo("http://bar.example.com");
   }
+
+  @Test
+  public void cannotGetFallbackCodeOwnersForNullProject() throws Exception {
+    NullPointerException npe =
+        assertThrows(
+            NullPointerException.class,
+            () -> generalConfig.getFallbackCodeOwners(/* project= */ null, new Config()));
+    assertThat(npe).hasMessageThat().isEqualTo("project");
+  }
+
+  @Test
+  public void cannotGetFallbackCodeOwnersForNullPluginConfig() throws Exception {
+    NullPointerException npe =
+        assertThrows(
+            NullPointerException.class,
+            () -> generalConfig.getFallbackCodeOwners(project, /* pluginConfig= */ null));
+    assertThat(npe).hasMessageThat().isEqualTo("pluginConfig");
+  }
+
+  @Test
+  public void noFallbackCodeOwnersConfigured() throws Exception {
+    assertThat(generalConfig.getFallbackCodeOwners(project, new Config()))
+        .isEqualTo(FallbackCodeOwners.NONE);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.fallbackCodeOwners", value = "ALL_USERS")
+  public void fallbackCodeOwnersIsRetrievedFromGerritConfigIfNotSpecifiedOnProjectLevel()
+      throws Exception {
+    assertThat(generalConfig.getFallbackCodeOwners(project, new Config()))
+        .isEqualTo(FallbackCodeOwners.ALL_USERS);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.fallbackCodeOwners", value = "ALL_USERS")
+  public void fallbackCodeOwnersInPluginConfigOverridesFallbackCodeOwnersInGerritConfig()
+      throws Exception {
+    Config cfg = new Config();
+    cfg.setString(SECTION_CODE_OWNERS, null, KEY_FALLBACK_CODE_OWNERS, "NONE");
+    assertThat(generalConfig.getFallbackCodeOwners(project, cfg))
+        .isEqualTo(FallbackCodeOwners.NONE);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.fallbackCodeOwners", value = "ALL_USERS")
+  public void globalFallbackOnwersUsedIfInvalidFallbackCodeOwnersConfigured() throws Exception {
+    Config cfg = new Config();
+    cfg.setString(SECTION_CODE_OWNERS, null, KEY_FALLBACK_CODE_OWNERS, "INVALID");
+    assertThat(generalConfig.getFallbackCodeOwners(project, cfg))
+        .isEqualTo(FallbackCodeOwners.ALL_USERS);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.fallbackCodeOwners", value = "INVALID")
+  public void defaultValueUsedIfInvalidGlobalFallbackCodeOwnersConfigured() throws Exception {
+    assertThat(generalConfig.getFallbackCodeOwners(project, new Config()))
+        .isEqualTo(FallbackCodeOwners.NONE);
+  }
+
+  @Test
+  public void cannotGetMaxPathsInChangeMessagesForNullProject() throws Exception {
+    NullPointerException npe =
+        assertThrows(
+            NullPointerException.class,
+            () -> generalConfig.getMaxPathsInChangeMessages(/* project= */ null, new Config()));
+    assertThat(npe).hasMessageThat().isEqualTo("project");
+  }
+
+  @Test
+  public void cannotGetMaxPathsInChangeMessagesForNullPluginConfig() throws Exception {
+    NullPointerException npe =
+        assertThrows(
+            NullPointerException.class,
+            () -> generalConfig.getMaxPathsInChangeMessages(project, /* pluginConfig= */ null));
+    assertThat(npe).hasMessageThat().isEqualTo("pluginConfig");
+  }
+
+  @Test
+  public void noMaxPathsInChangeMessagesConfigured() throws Exception {
+    assertThat(generalConfig.getMaxPathsInChangeMessages(project, new Config()))
+        .isEqualTo(DEFAULT_MAX_PATHS_IN_CHANGE_MESSAGES);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.maxPathsInChangeMessages", value = "50")
+  public void maxPathsInChangeMessagesIsRetrievedFromGerritConfigIfNotSpecifiedOnProjectLevel()
+      throws Exception {
+    assertThat(generalConfig.getMaxPathsInChangeMessages(project, new Config())).isEqualTo(50);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.maxPathsInChangeMessages", value = "50")
+  public void
+      maxPathsInChangeMessagesInPluginConfigOverridesMaxPathsInChangeMessagesInGerritConfig()
+          throws Exception {
+    Config cfg = new Config();
+    cfg.setString(SECTION_CODE_OWNERS, null, KEY_MAX_PATHS_IN_CHANGE_MESSAGES, "10");
+    assertThat(generalConfig.getMaxPathsInChangeMessages(project, cfg)).isEqualTo(10);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.maxPathsInChangeMessages", value = "50")
+  public void globalMaxPathsInChangeMessagesUsedIfInvalidMaxPathsInChangeMessagesConfigured()
+      throws Exception {
+    Config cfg = new Config();
+    cfg.setString(SECTION_CODE_OWNERS, null, KEY_MAX_PATHS_IN_CHANGE_MESSAGES, "INVALID");
+    assertThat(generalConfig.getMaxPathsInChangeMessages(project, cfg)).isEqualTo(50);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.maxPathsInChangeMessages", value = "INVALID")
+  public void defaultValueUsedIfInvalidMaxPathsInChangeMessagesConfigured() throws Exception {
+    assertThat(generalConfig.getMaxPathsInChangeMessages(project, new Config()))
+        .isEqualTo(DEFAULT_MAX_PATHS_IN_CHANGE_MESSAGES);
+  }
 }
diff --git a/javatests/com/google/gerrit/plugins/codeowners/config/OverrideApprovalConfigTest.java b/javatests/com/google/gerrit/plugins/codeowners/config/OverrideApprovalConfigTest.java
index 343ee30..3ea4e18 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/config/OverrideApprovalConfigTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/config/OverrideApprovalConfigTest.java
@@ -14,7 +14,13 @@
 
 package com.google.gerrit.plugins.codeowners.config;
 
+import static com.google.gerrit.plugins.codeowners.testing.RequiredApprovalSubject.assertThat;
+import static com.google.gerrit.server.project.ProjectCache.illegalState;
+
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.config.GerritConfig;
+import com.google.gerrit.server.project.ProjectState;
+import org.eclipse.jgit.lib.Config;
 import org.junit.Before;
 import org.junit.Test;
 
@@ -33,14 +39,21 @@
   }
 
   @Test
-  @GerritConfig(name = "plugin.code-owners.overrideApproval", value = "Code-Review+2")
+  @GerritConfig(name = "plugin.code-owners.overrideApproval", value = "Owners-Override+1")
   public void getFromGlobalPluginConfig() throws Exception {
-    testGetFromGlobalPluginConfig();
+    createOwnersOverrideLabel();
+
+    ProjectState projectState = projectCache.get(project).orElseThrow(illegalState(project));
+    ImmutableList<RequiredApproval> requiredApproval =
+        getRequiredApprovalConfig().get(projectState, new Config());
+    assertThat(requiredApproval).hasSize(1);
+    assertThat(requiredApproval).element(0).hasLabelNameThat().isEqualTo("Owners-Override");
+    assertThat(requiredApproval).element(0).hasValueThat().isEqualTo(1);
   }
 
   @Test
   @GerritConfig(name = "plugin.code-owners.overrideApproval", value = "INVALID")
-  public void cannotGetFromGlobalPluginConfigIfConfigIsInvalid() throws Exception {
-    testCannotGetFromGlobalPluginConfigIfConfigIsInvalid();
+  public void cannotGetIfGlobalConfigIsInvalid() throws Exception {
+    testCannotGetIfGlobalConfigIsInvalid("INVALID");
   }
 }
diff --git a/javatests/com/google/gerrit/plugins/codeowners/config/RequiredApprovalConfigTest.java b/javatests/com/google/gerrit/plugins/codeowners/config/RequiredApprovalConfigTest.java
index 53c0fa2..37da12d 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/config/RequiredApprovalConfigTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/config/RequiredApprovalConfigTest.java
@@ -15,11 +15,14 @@
 package com.google.gerrit.plugins.codeowners.config;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.plugins.codeowners.testing.RequiredApprovalSubject.assertThat;
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.server.project.ProjectState;
+import org.eclipse.jgit.lib.Config;
 import org.junit.Before;
 import org.junit.Test;
 
@@ -40,13 +43,18 @@
   @Test
   @GerritConfig(name = "plugin.code-owners.requiredApproval", value = "Code-Review+2")
   public void getFromGlobalPluginConfig() throws Exception {
-    testGetFromGlobalPluginConfig();
+    ProjectState projectState = projectCache.get(project).orElseThrow(illegalState(project));
+    ImmutableList<RequiredApproval> requiredApproval =
+        getRequiredApprovalConfig().get(projectState, new Config());
+    assertThat(requiredApproval).hasSize(1);
+    assertThat(requiredApproval).element(0).hasLabelNameThat().isEqualTo("Code-Review");
+    assertThat(requiredApproval).element(0).hasValueThat().isEqualTo(2);
   }
 
   @Test
   @GerritConfig(name = "plugin.code-owners.requiredApproval", value = "INVALID")
-  public void cannotGetFromGlobalPluginConfigIfConfigIsInvalid() throws Exception {
-    testCannotGetFromGlobalPluginConfigIfConfigIsInvalid();
+  public void cannotIfGlobalConfigIsInvalid() throws Exception {
+    testCannotGetIfGlobalConfigIsInvalid("INVALID");
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/plugins/codeowners/restapi/CodeOwnerJsonTest.java b/javatests/com/google/gerrit/plugins/codeowners/restapi/CodeOwnerJsonTest.java
index cb7ede8..2aa5b6f 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/restapi/CodeOwnerJsonTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/restapi/CodeOwnerJsonTest.java
@@ -89,9 +89,7 @@
             () ->
                 codeOwnerJsonFactory
                     .create(EnumSet.of(FillOptions.ID))
-                    .format(
-                        /** codeOwners = */
-                        null));
+                    .format(/* codeOwners= */ null));
     assertThat(npe).hasMessageThat().isEqualTo("codeOwners");
   }
 }
diff --git a/javatests/com/google/gerrit/plugins/codeowners/restapi/CodeOwnerProjectConfigJsonTest.java b/javatests/com/google/gerrit/plugins/codeowners/restapi/CodeOwnerProjectConfigJsonTest.java
index 28d05fe..51b6ea5 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/restapi/CodeOwnerProjectConfigJsonTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/restapi/CodeOwnerProjectConfigJsonTest.java
@@ -21,6 +21,8 @@
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.Mockito.when;
 
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.plugins.codeowners.acceptance.AbstractCodeOwnersTest;
@@ -33,6 +35,7 @@
 import com.google.gerrit.plugins.codeowners.api.RequiredApprovalInfo;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerBackendId;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfigScanner;
+import com.google.gerrit.plugins.codeowners.backend.FallbackCodeOwners;
 import com.google.gerrit.plugins.codeowners.backend.findowners.FindOwnersBackend;
 import com.google.gerrit.plugins.codeowners.backend.proto.ProtoBackend;
 import com.google.gerrit.plugins.codeowners.config.CodeOwnersPluginConfiguration;
@@ -97,10 +100,7 @@
     NullPointerException npe =
         assertThrows(
             NullPointerException.class,
-            () ->
-                CodeOwnerProjectConfigJson.formatRequiredApproval(
-                    /** requiredApproval = */
-                    null));
+            () -> CodeOwnerProjectConfigJson.formatRequiredApproval(/* requiredApproval= */ null));
     assertThat(npe).hasMessageThat().isEqualTo("requiredApproval");
   }
 
@@ -163,12 +163,14 @@
         .thenReturn(Optional.of("http://foo.example.com"));
     when(codeOwnersPluginConfiguration.getMergeCommitStrategy(project))
         .thenReturn(MergeCommitStrategy.FILES_WITH_CONFLICT_RESOLUTION);
+    when(codeOwnersPluginConfiguration.getFallbackCodeOwners(project))
+        .thenReturn(FallbackCodeOwners.ALL_USERS);
     when(codeOwnersPluginConfiguration.areImplicitApprovalsEnabled(project)).thenReturn(true);
     when(codeOwnersPluginConfiguration.getRequiredApproval(project))
         .thenReturn(RequiredApproval.create(getDefaultCodeReviewLabel(), (short) 2));
     when(codeOwnersPluginConfiguration.getOverrideApproval(project))
         .thenReturn(
-            Optional.of(
+            ImmutableSet.of(
                 RequiredApproval.create(
                     LabelType.withDefaultValues("Owners-Override"), (short) 1)));
 
@@ -181,6 +183,8 @@
         .isEqualTo("http://foo.example.com");
     assertThat(codeOwnerProjectConfigInfo.general.mergeCommitStrategy)
         .isEqualTo(MergeCommitStrategy.FILES_WITH_CONFLICT_RESOLUTION);
+    assertThat(codeOwnerProjectConfigInfo.general.fallbackCodeOwners)
+        .isEqualTo(FallbackCodeOwners.ALL_USERS);
     assertThat(codeOwnerProjectConfigInfo.general.implicitApprovals).isTrue();
     assertThat(codeOwnerProjectConfigInfo.backend.id)
         .isEqualTo(CodeOwnerBackendId.FIND_OWNERS.getBackendId());
@@ -188,8 +192,10 @@
         .containsExactly("refs/heads/stable-2.10", CodeOwnerBackendId.PROTO.getBackendId());
     assertThat(codeOwnerProjectConfigInfo.requiredApproval.label).isEqualTo("Code-Review");
     assertThat(codeOwnerProjectConfigInfo.requiredApproval.value).isEqualTo(2);
-    assertThat(codeOwnerProjectConfigInfo.overrideApproval.label).isEqualTo("Owners-Override");
-    assertThat(codeOwnerProjectConfigInfo.overrideApproval.value).isEqualTo(1);
+    assertThat(codeOwnerProjectConfigInfo.overrideApproval).hasSize(1);
+    assertThat(codeOwnerProjectConfigInfo.overrideApproval.get(0).label)
+        .isEqualTo("Owners-Override");
+    assertThat(codeOwnerProjectConfigInfo.overrideApproval.get(0).value).isEqualTo(1);
   }
 
   @Test
@@ -230,6 +236,25 @@
   }
 
   @Test
+  public void withMultipleOverrides() throws Exception {
+    createOwnersOverrideLabel();
+
+    when(codeOwnersPluginConfiguration.getOverrideApproval(project))
+        .thenReturn(
+            ImmutableSet.of(
+                RequiredApproval.create(LabelType.withDefaultValues("Owners-Override"), (short) 1),
+                RequiredApproval.create(LabelType.withDefaultValues("Code-Review"), (short) 2)));
+
+    ImmutableList<RequiredApprovalInfo> requiredApprovalInfos =
+        codeOwnerProjectConfigJson.formatOverrideApprovalInfo(project);
+    assertThat(requiredApprovalInfos).hasSize(2);
+    assertThat(requiredApprovalInfos.get(0).label).isEqualTo("Code-Review");
+    assertThat(requiredApprovalInfos.get(0).value).isEqualTo(2);
+    assertThat(requiredApprovalInfos.get(1).label).isEqualTo("Owners-Override");
+    assertThat(requiredApprovalInfos.get(1).value).isEqualTo(1);
+  }
+
+  @Test
   public void formatCodeOwnerBranchConfig() throws Exception {
     createOwnersOverrideLabel();
 
@@ -249,12 +274,14 @@
         .thenReturn(Optional.of("http://foo.example.com"));
     when(codeOwnersPluginConfiguration.getMergeCommitStrategy(project))
         .thenReturn(MergeCommitStrategy.FILES_WITH_CONFLICT_RESOLUTION);
+    when(codeOwnersPluginConfiguration.getFallbackCodeOwners(project))
+        .thenReturn(FallbackCodeOwners.ALL_USERS);
     when(codeOwnersPluginConfiguration.areImplicitApprovalsEnabled(project)).thenReturn(true);
     when(codeOwnersPluginConfiguration.getRequiredApproval(project))
         .thenReturn(RequiredApproval.create(getDefaultCodeReviewLabel(), (short) 2));
     when(codeOwnersPluginConfiguration.getOverrideApproval(project))
         .thenReturn(
-            Optional.of(
+            ImmutableSet.of(
                 RequiredApproval.create(
                     LabelType.withDefaultValues("Owners-Override"), (short) 1)));
 
@@ -266,13 +293,17 @@
         .isEqualTo("http://foo.example.com");
     assertThat(codeOwnerBranchConfigInfo.general.mergeCommitStrategy)
         .isEqualTo(MergeCommitStrategy.FILES_WITH_CONFLICT_RESOLUTION);
+    assertThat(codeOwnerBranchConfigInfo.general.fallbackCodeOwners)
+        .isEqualTo(FallbackCodeOwners.ALL_USERS);
     assertThat(codeOwnerBranchConfigInfo.general.implicitApprovals).isTrue();
     assertThat(codeOwnerBranchConfigInfo.backendId)
         .isEqualTo(CodeOwnerBackendId.FIND_OWNERS.getBackendId());
     assertThat(codeOwnerBranchConfigInfo.requiredApproval.label).isEqualTo("Code-Review");
     assertThat(codeOwnerBranchConfigInfo.requiredApproval.value).isEqualTo(2);
-    assertThat(codeOwnerBranchConfigInfo.overrideApproval.label).isEqualTo("Owners-Override");
-    assertThat(codeOwnerBranchConfigInfo.overrideApproval.value).isEqualTo(1);
+    assertThat(codeOwnerBranchConfigInfo.overrideApproval).hasSize(1);
+    assertThat(codeOwnerBranchConfigInfo.overrideApproval.get(0).label)
+        .isEqualTo("Owners-Override");
+    assertThat(codeOwnerBranchConfigInfo.overrideApproval.get(0).value).isEqualTo(1);
     assertThat(codeOwnerBranchConfigInfo.noCodeOwnersDefined).isNull();
   }
 
@@ -302,12 +333,14 @@
         .thenReturn(Optional.of("http://foo.example.com"));
     when(codeOwnersPluginConfiguration.getMergeCommitStrategy(project))
         .thenReturn(MergeCommitStrategy.FILES_WITH_CONFLICT_RESOLUTION);
+    when(codeOwnersPluginConfiguration.getFallbackCodeOwners(project))
+        .thenReturn(FallbackCodeOwners.ALL_USERS);
     when(codeOwnersPluginConfiguration.areImplicitApprovalsEnabled(project)).thenReturn(true);
     when(codeOwnersPluginConfiguration.getRequiredApproval(project))
         .thenReturn(RequiredApproval.create(getDefaultCodeReviewLabel(), (short) 2));
     when(codeOwnersPluginConfiguration.getOverrideApproval(project))
         .thenReturn(
-            Optional.of(
+            ImmutableSet.of(
                 RequiredApproval.create(
                     LabelType.withDefaultValues("Owners-Override"), (short) 1)));
 
diff --git a/javatests/com/google/gerrit/plugins/codeowners/restapi/CodeOwnerStatusInfoJsonTest.java b/javatests/com/google/gerrit/plugins/codeowners/restapi/CodeOwnerStatusInfoJsonTest.java
index 93aeeca..75aaeef 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/restapi/CodeOwnerStatusInfoJsonTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/restapi/CodeOwnerStatusInfoJsonTest.java
@@ -50,8 +50,7 @@
             NullPointerException.class,
             () ->
                 CodeOwnerStatusInfoJson.format(
-                    /** pathCodeOwnerStatus = */
-                    (PathCodeOwnerStatus) null));
+                    /* pathCodeOwnerStatus= */ (PathCodeOwnerStatus) null));
     assertThat(npe).hasMessageThat().isEqualTo("pathCodeOwnerStatus");
   }
 
@@ -72,8 +71,7 @@
             NullPointerException.class,
             () ->
                 CodeOwnerStatusInfoJson.format(
-                    /** fileCodeOwnerStatus = */
-                    (FileCodeOwnerStatus) null));
+                    /* fileCodeOwnerStatus= */ (FileCodeOwnerStatus) null));
     assertThat(npe).hasMessageThat().isEqualTo("fileCodeOwnerStatus");
   }
 
@@ -192,9 +190,7 @@
             NullPointerException.class,
             () ->
                 CodeOwnerStatusInfoJson.format(
-                    PatchSet.id(Change.id(1), 1),
-                    /** fileCodeOwnerStatuses = */
-                    null));
+                    PatchSet.id(Change.id(1), 1), /* fileCodeOwnerStatuses= */ null));
     assertThat(npe).hasMessageThat().isEqualTo("fileCodeOwnerStatuses");
   }
 
@@ -203,10 +199,7 @@
     NullPointerException npe =
         assertThrows(
             NullPointerException.class,
-            () ->
-                CodeOwnerStatusInfoJson.format(
-                    /** patchSetId = */
-                    null, ImmutableSet.of()));
+            () -> CodeOwnerStatusInfoJson.format(/* patchSetId= */ null, ImmutableSet.of()));
     assertThat(npe).hasMessageThat().isEqualTo("patchSetId");
   }
 
diff --git a/resources/Documentation/backend-find-owners-cookbook.md b/resources/Documentation/backend-find-owners-cookbook.md
index 7b49820..2af4e74 100644
--- a/resources/Documentation/backend-find-owners-cookbook.md
+++ b/resources/Documentation/backend-find-owners-cookbook.md
@@ -142,19 +142,18 @@
   per-file *.md=tina.toe@example.com
 ```
 \
-To match '*.md' in the current directory and all its subdirectories use:
+This matches all '*.md' in the current directory and all its subdirectories.
 
-```
-  per-file **.md=tina.toe@example.com
-```
-\
+**NOTE**: Using '*.md' is the same as using '**.md' (both expressions match
+files in the current directory and in all subdirectories).
+
 **NOTE:** The syntax for path expressions / globs is explained
 [here](path-expressions.html#globs).
 
 ### <a id="defineCodeOwnersForAllFileInASubdirectory">Define code owners for all files in a subdirectory
 
 It is discouraged to use path expressions that explicitly name subdirectories
-such as `my-subdir/**` as they will break when the subdirectory gets
+such as `my-subdir/*` as they will break when the subdirectory gets
 renamed/moved. Instead prefer to define these code owners in `my-subdir/OWNERS`
 so that the code owners for the subdirectory stay intact when the subdirectory
 gets renamed/moved.
@@ -165,7 +164,7 @@
 expression matches all files in the subdirectory:
 
 ```
-  per-file my-subdir/**=tina.toe@example.com
+  per-file my-subdir/*=tina.toe@example.com
 ```
 
 ### <a id="defineAGroupAsCodeOwner">Define a group as code owner
@@ -322,7 +321,7 @@
 `OWNERS` files that exempts all '*.md' files (in the current directory and all
 subdirectories) from requiring code owner approvals:
 ```
-  per-file **.md=*
+  per-file *.md=*
 ```
 \
 **NOTE:** Files that are not owned by anyone are **not** excluded from requiring
diff --git a/resources/Documentation/backend-find-owners.md b/resources/Documentation/backend-find-owners.md
index b0c0af6..2510e3a 100644
--- a/resources/Documentation/backend-find-owners.md
+++ b/resources/Documentation/backend-find-owners.md
@@ -227,7 +227,8 @@
 In the example below, Jana Roe, John Doe and the code owners that are inherited
 from parent `OWNERS` files are code owners of all files that are contained in
 the directory that contains the `OWNERS` file. In addition Richard Roe is a code
-owner of the `docs.config` file and all `*.md` files in this directory.
+owner of the `docs.config` file in this directory and all `*.md` files in this
+directory and the subdirectories.
 
 ```
   jane.roe@example.com
@@ -237,7 +238,7 @@
 \
 ##### <a id="doNotUsePathExpressionsForSubdirectories">
 **NOTE:** It is discouraged to use path expressions that explicitly name
-subdirectories such as `my-subdir/**` as they will break when the subdirectory
+subdirectories such as `my-subdir/*` as they will break when the subdirectory
 gets renamed/moved. Instead prefer to define these code owners in
 `my-subdir/OWNERS` so that the code owners for the subdirectory stay intact when
 the subdirectory gets renamed/moved.
@@ -268,9 +269,10 @@
 from parent directories.
 
 In the example below, Richard Roe is the only code owner of the `docs.config`
-file and all `*.md` files in this directory. All other files in this directory
-and its subdirectories are owned by Jana Roe, John Doe and the code owners that
-are inherited from parent directories.
+file in this directory and all `*.md` files in this directory and the
+subdirectories. All other files in this directory and its subdirectories are
+owned by Jana Roe, John Doe and the code owners that are inherited from parent
+directories.
 
 ```
   jane.roe@example.com
diff --git a/resources/Documentation/config-guide.md b/resources/Documentation/config-guide.md
new file mode 100644
index 0000000..8a55a6c
--- /dev/null
+++ b/resources/Documentation/config-guide.md
@@ -0,0 +1,222 @@
+# Config Guide
+
+The `@PLUGIN@` plugin has many configuration parameters that can be used to
+customize its behavior. These configuration parameters are described in the
+[config](config.html) documentation. This guide gives some additional
+recommendations for the configuration, but doesn't cover all configuration
+parameters.
+
+## <a id="requiredConfiguration">Required Configuration
+
+**Before** installing/enabling the plugin, or enabling the code owners
+functionality for further projects, it is important to do some basic
+configuration. This includes choosing a [code owner backend](backends.html),
+defining the approvals that count as code owner approval and as code owner
+override, opting-out projects or branches and configuring the allowed email
+domain. All this configuration is covered in detail by the [setup
+guide](setup-guide.html).
+
+## <a id="workflow">Workflow Configuration
+
+Some of the configuration parameters have an effect on the user workflow.
+
+### <a id="stickyApprovals">Make code owner approvals / overrides sticky
+
+Code owner approvals and code owner overrides can be made sticky by enabling
+[copy rules](../../../Documentation/config-labels.html#label_copyAnyScore) in
+the definitions of the labels that are configured as [required
+approval](config.html#pluginCodeOwnersRequiredApproval) and [override
+approval](config.html#pluginCodeOwnersOverrideApproval).
+
+### <a id="implicitApprovals">Implicit code owner approvals
+
+It's possible to [enable implicit approvals](config.html#pluginCodeOwnersEnableImplicitApprovals)
+of code owners on their own changes. If enabled and the uploader of a patch set
+is a code owner, an approval of the uploader is assumed for all owned files.
+This means if a code owner uploads a change / patch set that only touches files
+that they own, no approval from other code owners is required for submitting the
+change.
+
+If implicit approvals are enabled, paths can be exempted from requiring code
+owner approvals by assigning the code ownership to [all
+users](backend-find-owners.html#allUsers), as then any modification to the path
+is always implicitly approved by the uploader.
+
+**NOTE:** If implicit approvals are disabled, users can still self-approve their
+own changes by voting on the required label.
+
+**IMPORTANT**: Enabling implicit approvals is considered unsafe, see [security
+pitfalls](#securityImplicitApprovals) below.
+
+### <a id="mergeCommits">Required code owner approvals on merge commits
+
+For merge commits the list of modified files depends on the base against which
+the merge commit is compared:
+
+1. comparison against the destination branch (aka first parent commit):
+   All files which differ between the merge commit and the destination branch.
+   This includes all files which have been modified in the source branch since
+   the last merge into the destination branch has been done.
+
+2. comparison against the Auto-Merge (Auto-Merge = result of automatically
+   merging the source branch into the destination branch):
+   Only shows files for which a conflict resolution has been done.
+
+Which files a users sees on the change screen depends on their base selection.
+
+For the `@PLUGIN@` plugin it can be configured [which files of a merge commit
+require code owner approvals](config.html#pluginCodeOwnersMergeCommitStrategy),
+all files that differ with the destination branch (case 1) or only files that
+differ with the Auto-Merge (case 2). If case 1 is configured, all file diffs
+that have been approved in one branch must be re-approved when they are merged
+into another branch. If case 2 is configured, only conflict resolutions have to
+be approved when a merge is done.
+
+**IMPORTANT**: Requiring code owner approvals only for files that differ with
+the Auto-Merge (case 2) is considered unsafe, see [security
+pitfalls](#securityMergeCommits) below.
+
+## <a id="codeOwners">Recommendations for defining code owners
+
+Code owners can be defined on different levels, which differ by scope. This
+section gives an overview of the different levels and explains when they should
+be used.
+
+1. Folder and file code owners:
+   These are the code owners that are defined in the [code owner config
+   files](user-guide.html#codeOwnerConfigFiles) that are stored in the source
+   tree of the repository. They can either apply to a whole
+   [folder](backend-find-owners.html#userEmails) (folder code owners) or to
+   [matched files](backend-find-owners.html#perFile) (file code owners).\
+   This is the normal way to define code owners. This code owner definition is
+   discoverable since it is stored in human-readable code owner config file in
+   the source tree of the repository.\
+   Folder and file code owners can differ from branch to branch since they are
+   defined in the source tree.\
+   Folder and file code owners are usually users that are expert for a code area
+   and that should review and approve all changes to this code.
+2. Root code owners:
+   Root code owners are folder code owners (see 1.) that are defined in the code
+   owner config file that is stored in the root directory of a branch.\
+   Usually root code owners are the most experienced developers that can approve
+   changes to all the code base if needed, but that should only review and
+   approve changes if no other, more specific, code owner is available.\
+   Root code owners can differ from branch to branch.
+3. Default code owners:
+   [Default code owners](backend-find-owners.html#defaultCodeOwnerConfiguration)
+   are stored in the code owner config file in the `refs/meta/config` branch
+   that apply for all branches (unless inheritance is ignored).\
+   The same as root code owners these are experienced developers that can
+   approve changes to all the code base if needed.\
+   However in contrast to root code owners that apply to all branches (including
+   newly created branches), and hence can be used if code owners should be kept
+   consistent across all branches.\
+   A small disadvantage is that this code owner definition is not very well
+   discoverable since it is stored in the `refs/meta/config` branch, but default
+   code owners are suggested to users the same way as other code owners.
+4. Global code owners:
+   [Global code owners](config.html#pluginCodeOwnersGlobalCodeOwner) are defined
+   in the plugin configuration and apply to all projects or all child projects.\
+   They are intended to configure bots as code owners that need to operate on
+   all or multiple projects.\
+   Global code owners still apply if parent code owners are ignored.
+5. Fallback code owners:
+   [Fallback code owners](config.html#pluginCodeOwnersFallbackCodeOwners) is a
+   policy configuration that controls who should own paths that have no code
+   owners defined.\
+   Fallback code owners are not included in the code owner suggestion.\
+   Configuring all users as fallback code owners may allow bypassing the code
+   owners check (see [security pitfalls](#securityFallbackCodeOwners) below).
+
+In addition users can be allowed to [override the code owner submit
+check](user-guide.html#codeOwnerOverride). This permission is normally granted
+to users that that need to react to emergencies and need to submit changes
+quickly (e.g sheriffs) or users that need to make large-scale changes across
+many repositories.
+
+## <a id="externalValidationOfCodeOwnerConfigs">External validation of code owner config files
+
+By default, when code owner config files are modified they are
+[validated](validation.html) on push. If any issues in the modified code owner
+config files are found, the push is rejected. This is important since
+non-parsable code owner config files make submissions fail which likely blocks
+the development teams, and hence needs to be prevented.
+
+However rejecting pushes in case of invalid code owner config files is not an
+ideal workflow for everyone. Instead it may be wanted that the push always
+succeeds and that issues with modified code owner config files are then detected
+and reported by a CI bot. The CI bot would then post its findings as checks on
+the open change which prevent the change submission. To enable this the
+validation of code owner config files on push can be
+[disabled](config.html#pluginCodeOwnersEnableValidationOnCommitReceived), but
+then the host admins should setup a bot to do the validation of modified code
+owner config files externally. For this the bot could use the [Check Code Owner
+Config Files In Revision](rest-api.html#check-code-owner-config-files-in-revision)
+REST endpoint.
+
+## <a id="differentCodeOwnerConfigurations">Use different code owner configurations in a fork
+
+If a respository is forked and code owners are used in the original repository,
+the code owner configuration of the original repository shouldn't apply for the
+fork (the fork should have different code owners, and if the fork is stored on
+another Gerrit host it's also likely that the original code owners cannot be
+resolved on that host). In this case it is possible to [configure a file
+extension](config.html#pluginCodeOwnersFileExtension) for code owner config
+files in the fork so that its code owner config files do not clash with the
+original code owner config files.
+
+## <a id="securityPitfalls">Security pitfalls
+
+While requiring code owner approvals is primarily considered as a code quality
+feature and not a security feature, many admins / projects owners are concerned
+about possibilities to bypass code owner approvals. These admins / projects
+owners should be aware that some configuration settings may make it possible to
+bypass code owner approvals, and hence using them is not recommended.
+
+### <a id="securityImplicitApprovals">Implicit approvals
+
+If [implicit approvals](#implicitApprovals) are enabled, it is important that
+code owners are aware of their implicit approval when they upload new patch sets
+for other users. E.g. if a contributor pushes a change to a wrong branch and a
+code owner helps them to get it rebased onto the correct branch, the rebased
+change has implicit approvals from the code owner, since the code owner is the
+uploader. To avoid situations like this it is recommended to not enable implicit
+approvals.
+
+### <a id="securityMergeCommits">Required code owner approvals on merge commits
+
+If any branch doesn't require code owner approvals or if the code owners in any
+branch are not trusted, it is not safe to [configure for merge commits that they
+only require code owner approvals for files that differ with the
+Auto-Merge](#mergeCommits). E.g. if there is a branch that doesn't require code
+owner approvals, with this setting the code owners check can be bypassed by:
+
+1. setting the branch that doesn't require code owner approvals to the same
+   commit as the main branch that does require code owner approvals
+2. making a change in the branch that doesn't require code owner approvals
+3. merging this change back into the main branch that does require code owner
+   approvals
+4. since it's a clean merge, all files are merged automatically and no code
+   owner approval is required
+
+### <a id="securityFallbackCodeOwners">Setting all users as fallback code owners
+
+As soon as the code owners functionality is enabled for a project / branch, all
+files in it require code owner approvals. This means if any path doesn't have
+any code owners defined, submitting changes to the path is only possible with
+
+1. a code owner override
+2. an approval from a fallback code owners (only if enabled)
+
+[Configuring all users as fallback code
+owners](config.html#pluginCodeOwnersFallbackCodeOwners) is problematic, as it
+can happen easily that code owner config files are misconfigured so that some
+paths are accidentally not covered by code owners. In this case, the affected
+paths would suddenly be open to all users, which may not be wanted. This is why
+configuring all users as fallback code owners is not recommended.
+
+---
+
+Back to [@PLUGIN@ documentation index](index.html)
+
+Part of [Gerrit Code Review](../../../Documentation/index.html)
diff --git a/resources/Documentation/config.md b/resources/Documentation/config.md
index b67693b..d546374 100644
--- a/resources/Documentation/config.md
+++ b/resources/Documentation/config.md
@@ -3,6 +3,9 @@
 The global configuration of the @PLUGIN@ plugin is stored in the `gerrit.config`
 file in the `plugin.@PLUGIN@` subsection.
 
+This page describes all available configuration parameters. For configuration
+recommendations please consult the [config guide](#config-guide.html).
+
 ## <a id="projectLevelConfigFile">
 In addition some configuration can be done on the project level in
 `@PLUGIN@.config` files that are stored in the `refs/meta/config` branches of
@@ -55,11 +58,11 @@
 
 <a id="pluginCodeOwnersFileExtension">plugin.@PLUGIN@.fileExtension</a>
 :       The file extension that should be used for code owner config files.\
-        Allows to use different owner configurations for upstream and internal
-        in the same repository. E.g. if upstream uses `OWNERS` code owner config
-        files (no file extension configured) one could set `internal` as file
-        extension internally so that internally `OWNERS.internal` files are used
-        and the existing `OWNERS` files are ignored.\
+        Allows to use a different code owner configuration in a fork. E.g. if
+        the original repository uses `OWNERS` code owner config files (no file
+        extension configured) one could set `fork` as file extension in the fork
+        so that the fork uses `OWNERS.fork` files and the existing `OWNERS`
+        files are ignored.\
         Can be overridden per project by setting
         [codeOwners.fileExtension](#codeOwnersFileExtension) in
         `@PLUGIN@.config`.\
@@ -78,6 +81,11 @@
 <a id="pluginCodeOwnersEnableImplicitApprovals">plugin.@PLUGIN@.enableImplictApprovals</a>
 :       Whether an implicit code owner approval from the last uploader is
         assumed.\
+        This setting has no effect if self approvals from the last uploader are
+        ignored because the [required label](#pluginCodeOwnersRequiredApproval)
+        is configured to [ignore self
+        approvals](../../../Documentation/config-labels.html#label_ignoreSelfApproval)
+        from the uploader.\
         If enabled, code owners need to be aware of their implicit approval when
         they upload new patch sets for other users (e.g. if a contributor pushes
         a change to a wrong branch and a code owner helps them to get it rebased
@@ -125,6 +133,20 @@
         in `@PLUGIN@.config`.\
         By default `true`.
 
+<a id="pluginCodeOwnersEnableValidationOnSubmit">plugin.@PLUGIN@.enableValidationOnSubmit</a>
+:       Policy for validating code owner config files when a change is
+        submitted. Allowed values are `true` (the code owner config file
+        validation is enabled and the submit of invalid code owner config files
+        is rejected), `false` (the code owner config file validation is
+        disabled, invalid code owner config files are not rejected) and
+        `dry_run` (code owner config files are validated, but invalid code owner
+        config files are not rejected).\
+        Disabling the submit validation is not recommended.\
+        Can be overridden per project by setting
+        [codeOwners.enableValidationOnSubmit](#codeOwnersEnableValidationOnSubmit)
+        in `@PLUGIN@.config`.\
+        By default `true`.
+
 <a id="pluginCodeOwnersAllowedEmailDomain">plugin.@PLUGIN@.allowedEmailDomain</a>
 :       Email domain that allows to assign code ownerships to emails with this
         domain.\
@@ -136,6 +158,8 @@
 <a id="pluginCodeOwnersRequiredApproval">plugin.@PLUGIN@.requiredApproval</a>
 :       Approval that is required from code owners to approve the files in a
         change.\
+        Any approval on the configured label that has a value >= the configured
+        value is considered as code owner approval.\
         The required approval must be specified in the format
         "\<label-name\>+\<label-value\>".\
         The configured label must exist for all projects for which this setting
@@ -145,18 +169,35 @@
         rules](../../../Documentation/config-labels.html#label_copyAnyScore)
         enabled so that votes are sticky across patch sets, also the code owner
         approvals will be sticky.\
+        If the definition of the configured label [ignores self
+        approvals](../../../Documentation/config-labels.html#label_ignoreSelfApproval)
+        from the uploader, any vote from the uploader is ignored for the code
+        owners check.\
         Can be overridden per project by setting
         [codeOwners.requiredApproval](#codeOwnersRequiredApproval) in
         `@PLUGIN@.config`.\
         By default "Code-Review+1".
 
 <a id="pluginCodeOwnersOverrideApproval">plugin.@PLUGIN@.overrideApproval</a>
-:       Approval that is required to override the code owners submit check.\
+:       Approval that counts as override for the code owners submit check.\
+        Any approval on the configured label that has a value >= the configured
+        value is considered as code owner override.\
         The override approval must be specified in the format
         "\<label-name\>+\<label-value\>".\
-        The configured label must exist for all projects for which this setting
+        Can be specifed multiple times to configure multiple override approvals.
+        If multiple approvals are configured, any of them is sufficient to
+        override the code owners submit check.\
+        The configured labels must exist for all projects for which this setting
         applies (all projects that have code owners enabled and for which this
         setting is not overridden).\
+        If the definition of the configured labels has [copy
+        rules](../../../Documentation/config-labels.html#label_copyAnyScore)
+        enabled so that votes are sticky across patch sets, also the code owner
+        overrides will be sticky.\
+        If the definition of a configured label [ignores self
+        approvals](../../../Documentation/config-labels.html#label_ignoreSelfApproval)
+        from the uploader, any override vote from the uploader on that label is
+        ignored for the code owners check.\
         Can be overridden per project by setting
         [codeOwners.overrideApproval](#codeOwnersOverrideApproval) in
         `@PLUGIN@.config`.\
@@ -213,6 +254,50 @@
         `@PLUGIN@.config`.\
         By default `ALL_CHANGED_FILES`.
 
+<a id="pluginCodeOwnersFallbackCodeOwners">plugin.@PLUGIN@.fallbackCodeOwners</a>
+:       Policy that controls who should own paths that have no code owners
+        defined. This policy only applies if the inheritance of parent code
+        owners hasn't been explicity disabled in a relevant code owner config
+        file and if there are no unresolved imports.\
+        \
+        Can be `NONE` or `ALL_USERS`.\
+        \
+        `NONE`:\
+        Paths for which no code owners are defined are owned by no one. This
+        means changes that touch these files can only be submitted with a code
+        owner override.\
+        \
+        `ALL_USERS`:\
+        Paths for which no code owners are defined are owned by all users. This
+        means changes to these paths can be approved by anyone. If [implicit
+        approvals](#pluginCodeOwnersEnableImplicitApprovals) are enabled, these
+        files are always automatically approved. The `ALL_USERS` option should
+        only be used with care as it means that any path that is not covered by
+        the code owner config files is automatically opened up to everyone and
+        mistakes with configuring code owners can easily happen. This is why
+        this option is intended to be only used if requiring code owner
+        approvals should not be enforced.\
+        \
+        Can be overridden per project by setting
+        [codeOwners.fallbackCodeOwners](#codeOwnersFallbackCodeOwners) in
+        `@PLUGIN@.config`.\
+        By default `NONE`.
+
+<a id="pluginCodeOwnersMaxPathsInChangeMessages">plugin.@PLUGIN@.maxPathsInChangeMessages</a>
+:       When a user votes on the [code owners
+        label](#pluginCodeOwnersRequiredApproval) the paths that are affected by
+        the vote are listed in the change message that is posted when the vote
+        is applied.\
+        This configuration parameter controls the maximum number of paths that
+        are included in change messages. This is to prevent that the change
+        messages become too big for large changes that touch many files.\
+        Setting the value to `0` disables including affected paths into change
+        messages.\
+        Can be overridden per project by setting
+        [codeOwners.maxPathsInChangeMessages](#codeOwnersMaxPathsInChangeMessages)
+        in `@PLUGIN@.config`.\
+        By default `100`.
+
 # <a id="projectConfiguration">Project configuration in @PLUGIN@.config</a>
 
 <a id="codeOwnersDisabled">codeOwners.disabled</a>
@@ -277,11 +362,11 @@
 <a id="codeOwnersFileExtension">codeOwners.fileExtension</a>
 :       The file extension that should be used for the code owner config files
         in this project.\
-        Allows to use different owner configurations for upstream and internal
-        in the same repository. E.g. if upstream uses `OWNERS` code owner config
-        files (no file extension configured) one could set `internal` as file
-        extension internally so that internally `OWNERS.internal` files are used
-        and the existing `OWNERS` files are ignored.\
+        Allows to use a different code owner configuration in a fork. E.g. if
+        the original repository uses `OWNERS` code owner config files (no file
+        extension configured) one could set `fork` as file extension in the fork
+        so that the fork uses `OWNERS.fork` files and the existing `OWNERS`
+        files are ignored.\
         Overrides the global setting
         [plugin.@PLUGIN@.fileExtension](#pluginCodeOwnersFileExtension) in
         `gerrit.config`.\
@@ -304,6 +389,11 @@
 <a id="codeOwnersEnableImplicitApprovals">codeOwners.enableImplicitApprovals</a>
 :       Whether an implicit code owner approval from the last uploader is
         assumed.\
+        This setting has no effect if self approvals from the last uploader are
+        ignored because the [required label](#codeOwnersRequiredApproval)
+        is configured to [ignore self
+        approvals](../../../Documentation/config-labels.html#label_ignoreSelfApproval)
+        from the uploader.\
         If enabled, code owners need to be aware of their implicit approval when
         they upload new patch sets for other users (e.g. if a contributor pushes
         a change to a wrong branch and a code owner helps them to get it rebased
@@ -359,9 +449,27 @@
         [plugin.@PLUGIN@.enableValidationOnCommitReceived](#pluginCodeOwnersEnableValidationOnCommitReceived)
         in `gerrit.config` is used.
 
+<a id="codeOwnersEnableValidationOnSubmit">codeOwners.enableValidationOnSubmit</a>
+:       Policy for validating code owner config files when a change is
+        submitted. Allowed values are `true` (the code owner config file
+        validation is enabled and the submit of invalid code owner config files
+        is rejected), `false` (the code owner config file validation is
+        disabled, invalid code owner config files are not rejected) and
+        `dry_run` (code owner config files are validated, but invalid code owner
+        config files are not rejected).\
+        Disabling the submit validation is not recommended.\
+        Overrides the global setting
+        [plugin.@PLUGIN@.enableValidationOnSubmit](#pluginCodeOwnersEnableValidationOnSubmit)
+        in `gerrit.config`.\
+        If not set, the global setting
+        [plugin.@PLUGIN@.enableValidationOnSubmit](#pluginCodeOwnersEnableValidationOnSubmit)
+        in `gerrit.config` is used.
+
 <a id="codeOwnersRequiredApproval">codeOwners.requiredApproval</a>
 :       Approval that is required from code owners to approve the files in a
         change.\
+        Any approval on the configured label that has a value >= the configured
+        value is considered as code owner approval.\
         The required approval must be specified in the format
         "\<label-name\>+\<label-value\>".\
         The configured label must exist for all projects for which this setting
@@ -371,6 +479,10 @@
         rules](../../../Documentation/config-labels.html#label_copyAnyScore)
         enabled so that votes are sticky across patch sets, also the code owner
         approvals will be sticky.\
+        If the definition of the configured label [ignores self
+        approvals](../../../Documentation/config-labels.html#label_ignoreSelfApproval)
+        from the uploader, any vote from the uploader is ignored for the code
+        owners check.\
         Overrides the global setting
         [plugin.@PLUGIN@.requiredApproval](#pluginCodeOwnersRequiredApproval) in
         `gerrit.config`.\
@@ -379,12 +491,25 @@
         `gerrit.config` is used.
 
 <a id="codeOwnersOverrideApproval">codeOwners.overrideApproval</a>
-:       Approval that is required to override the code owners submit check.\
+:       Approval that counts as override for the code owners submit check.\
+        Any approval on the configured label that has a value >= the configured
+        value is considered as code owner override.\
         The override approval must be specified in the format
         "\<label-name\>+\<label-value\>".\
-        The configured label must exist for all projects for which this setting
+        Can be specifed multiple times to configure multiple override approvals.
+        If multiple approvals are configured, any of them is sufficient to
+        override the code owners submit check.\
+        The configured labels must exist for all projects for which this setting
         applies (all projects that have code owners enabled and for which this
         setting is not overridden).\
+        If the definition of the configured labels has [copy
+        rules](../../../Documentation/config-labels.html#label_copyAnyScore)
+        enabled so that votes are sticky across patch sets, also the code owner
+        overrides will be sticky.\
+        If the definition of a configured label [ignores self
+        approvals](../../../Documentation/config-labels.html#label_ignoreSelfApproval)
+        from the uploader, any override vote from the uploader on that label is
+        ignored for the code owners check.\
         Overrides the global setting
         [plugin.@PLUGIN@.overrideApproval](#pluginCodeOwnersOverrideApproval) in
         `gerrit.config`.\
@@ -405,6 +530,39 @@
         [plugin.@PLUGIN@.mergeCommitStrategy](#pluginCodeOwnersMergeCommitStrategy)
         in `gerrit.config` is used.
 
+<a id="codeOwnersFallbackCodeOwners">codeOwners.fallbackCodeOwners</a>
+:       Policy that controls who should own paths that have no code owners
+        defined. This policy only applies if the inheritance of parent code
+        owners hasn't been explicity disabled in a relevant code owner config
+        file and if there are no unresolved imports.\
+        Can be `NONE` or `ALL_USERS` (see
+        [plugin.@PLUGIN@.fallbackCodeOwners](#pluginCodeOwnersFallbackCodeOwners)
+        for an explanation of these values).\
+        Overrides the global setting
+        [plugin.@PLUGIN@.fallbackCodeOwners](#pluginCodeOwnersFallbackCodeOwners)
+        in `gerrit.config`.\
+        If not set, the global setting
+        [plugin.@PLUGIN@.fallbackCodeOwners](#pluginCodeOwnersFallbackCodeOwners)
+        in `gerrit.config` is used.
+
+<a id="codeOwnersMaxPathsInChangeMessages">codeOwners.maxPathsInChangeMessages</a>
+:       When a user votes on the [code owners
+        label](#codeOwnersRequiredApproval) the paths that are affected by the
+        vote are listed in the change message that is posted when the vote is
+        applied.\
+        This configuration parameter controls the maximum number of paths that
+        are included in change messages. This is to prevent that the change
+        messages become too big for large changes that touch many files.\
+        Setting the value to `0` disables including affected paths into change
+        messages.\
+        Overrides the global setting
+        [plugin.@PLUGIN@.maxPathsInChangeMessages](#pluginCodeOwnersMaxPathsInChangeMessages)
+        in `gerrit.config`.\
+        If not set, the global setting
+        [plugin.@PLUGIN@.maxPathsInChangeMessages](#pluginCodeOwnersMaxPathsInChangeMessages)
+        in `gerrit.config` is used.\
+        By default `100`.
+
 ---
 
 Back to [@PLUGIN@ documentation index](index.html)
diff --git a/resources/Documentation/how-to-use.md b/resources/Documentation/how-to-use.md
index 8a95561..d412c2c 100644
--- a/resources/Documentation/how-to-use.md
+++ b/resources/Documentation/how-to-use.md
@@ -1,12 +1,42 @@
 # Intro
 
-The Code-Owners plugin is currently in development. We are testing code-owners on some hosts on googlesource.com right now. If you build your Gerrit from master, you can enable it by enabling the code-owners plugin and adding OWNERS info to your code base.
+The `code-owners` plugin provides support for code owners in Gerrit and is
+replacing the `find-owners` plugin.
 
-The Code-Owner plugin is an open-source plugin and maintained by the Gerrit team at Google to replace find-owners plugin.
+For projects that used code owners with the `find-owners` plugin before, the
+existing `OWNERS` files continue to work and the only major difference is that
+the `code-owners` plugin comes with a new UI for selecting code owners and
+showing the code owner status.
+
+The `code-owners` plugin is an open-source plugin and is maintained by the
+Gerrit team at Google.
+
+This document focuses on the workflows in the UI. Further information can be
+found in the [backend user guide](user-guide.html).
+
+### Enable the plugin
+
+#### As a user
+
+You don’t need to do anything as the plugin is enabled by the host admin.
+
+#### As an admin
+
+The `code-owners` plugin is only supported for the Gerrit version that is
+currently developed in master. The first Gerrit release that supports the
+`code-owners` plugin is Gerrit 3.3.0.
+
+Before installing/enabling the plugin, or enabling the code owners functionality
+for further projects, it is important that the plugin is correctly configured.
+The required configuration is described in the plugin [setup
+guide](setup-guide.html).
 
 ### Bug report / Feedback
 
-Report a bug or send feedback using this [Monorail template](https://bugs.chromium.org/p/gerrit/issues/entry?template=code-owners-plugin). You can also report a bug through the bug icon in the reply dialog next to the Suggest Owners button.
+Report a bug or send feedback using this [Monorail
+template](https://bugs.chromium.org/p/gerrit/issues/entry?template=code-owners-plugin).
+You can also report a bug through the bug icon in the reply dialog next to the
+`HIDE OWNERS` / `SUGGEST OWNERS` button.
 
 ![suggest owners from reply dialog](./suggest-owners-from-reply-dialog.png "Suggest owners")
 
@@ -14,39 +44,83 @@
 
 ### Who are code owners?
 
-A code owner is a user whose approval is required to modify files under a certain path. Who is a code owner of a path is controlled via "OWNERS'' files that are checked into the repository. For submitting a change Gerrit requires that all files that were touched in the change are approved by a code owner. Code owners usually apply their approval by voting with "Code-Review+1" on the change. Their approval is to confirm that “This change is appropriate for our system and belongs in this directory."
+A code owner is a user whose approval is required to modify files under a
+certain path. Who is a code owner of a path is controlled via `OWNERS` files
+that are checked into the repository. For submitting a change Gerrit requires
+that all files that were touched in the change are approved by a code owner.
+Code owners usually apply their approval by voting with "Code-Review+1" on the
+change. Their approval is to confirm that “This change is appropriate for our
+system and belongs in this directory."
 
 ### Why do we leverage Code Owners?
 
-Owners are gatekeepers before a CL is submitted, they enforce standards across the code base, help disseminate knowledge around their specific area of ownership, ensure their is appropriate code review coverage, and provide timely reviews. Code owners is designed as a code quality feature to ensure someone familiar with the code base reviews any changes to the codebase or a subset of the codebase they are the Owner of, by making sure the change is appropriate for the system.
+Owners are gatekeepers before a CL is submitted, they enforce standards across
+the code base, help disseminate knowledge around their specific area of
+ownership, ensure their is appropriate code review coverage, and provide timely
+reviews. Code owners is designed as a code quality feature to ensure someone
+familiar with the code base reviews any changes to the codebase or a subset of
+the codebase they are the Owner of, by making sure the change is appropriate for
+the system.
 
-## What is the code-owners plugin?
+## What is the `code-owners` plugin?
 
 ### What is the benefit?
 
-Code owners in Gerrit will be supported by a new code-owners plugin which is developed as an open-source plugin and maintained by the Gerrit team at Google.
-The code-owners plugin supports:
+Code owners in Gerrit will be supported by a new `code-owners` plugin which is
+developed as an open-source plugin and maintained by the Gerrit team at Google.
 
-- defining code owners
+The `code-owners` plugin supports:
+
+- [defining code owners](#definingCodeOwners)
 - requiring owner approvals for submitting changes
 - displaying owner information in the UI & suggesting code owners as reviewers
 - overriding owner checks
+- a [REST API](rest-api.html) to inspect code owners
 
 ### How does it work?
 
-The plugin provides suggestions of owners for the directory or files that you are modifying in your change based on a score. It also informs you at a glance about the status of code-owners for the change and the status of code-owners per file.
+The plugin provides suggestions of owners for the directory or files that you
+are modifying in your change based on a score. It also informs you at a glance
+about the status of code owners for the change and the status of code owners per
+file.
 
 #### Score
 
-The Code-owners plugin suggests a maximum of 5 closest owners based on their score. The owner score is calculated based on the distance of owners to the files.
+The `code-owners` plugin suggests a maximum of 5 closest code owners based on
+their score. The code owner score is calculated based on the distance of code
+owners to the files.
+
+## <a id="definingCodeOwners">Defining code owners
+
+If you have used code owners via the `find-owners` plugin before, your code
+owners are already defined in `OWNERS` files and you don’t need to do anything
+since the new `code-owners` plugin just reads the existing `OWNERS` files.
+
+If you haven’t used code owners before, you can now define code owners in
+`OWNERS` files which are stored in the source tree. The code owners that are
+defined in an `OWNERS` file apply to the directory that contains the `OWNERS`
+file, and all its subdirectories (except if a subdirectory contains an `OWNERS`
+file that disables the inheritance of code owners from the parent directories).
+
+The syntax of `OWNERS` file is explained in the [backend
+documentation](backend-find-owners.html#syntax) and examples can be found in the
+[cookbook](backend-find-owners-cookbook.html).
+
+The code-owners plugin does not support an editor to create and edit `OWNERS`
+files from the UI. `OWNERS` files must be created and edited manually in the
+local repository and then be pushed to the remote repository, the same way as
+any other source file.
 
 ## <a id="addCodeOwnersAsReviewers">Add owners to your change
 
-1. To add owners of the files in your change, click on Suggest owners next to the Code-Owners submit requirement.
+1. To add owners of the files in your change, click on `SUGGEST OWNERS` next to
+   the `Code-Owners` submit requirement.
 
 ![suggest owners from change page](./suggest-owners-from-change-page.png "Suggest owners from change page")
 
-2. The Reply dialog opens with the Code-Owners section expanded by default with owners suggestions. Code-Owners are suggested by groups of files which share the same file-owners.
+2. The Reply dialog opens with the code owners section expanded by default with
+   owners suggestions. Code owners are suggested by groups of files which share
+   the same code owners.
 
 ![owner suggestions](./owner-suggestions.png "owner suggestions")
 
@@ -54,36 +128,42 @@
 
 ![suggestion file groups](./suggestions-file-groups.png "suggestion file groups")
 
-4. Click user chips to select owners for each file or group of files. The selected owner is automatically added to the Reviewers section and automatically selected on other files the code-owner owns in the change (if applicable).
+4. Click user chips to select code owners for each file or group of files. The
+   selected code owner is automatically added to the reviewers section and
+   automatically selected on other files the code owner owns in the change (if
+   applicable).
 
 ![add or modify reviewers from suggestions](./add-owner-to-reviewer.png "add owner to reviewer")
 
-5. Click Send to notify the owners you selected on your change.
+5. Click `SEND` to notify the code owners you selected on your change.
 
 ## Reply dialog use cases
 
 ### Approved files
 
-Once a file has received a +1 vote by the owner, the file disappears from the file list in the reply dialog. This lets you focus on the files that are not yet assigned to an owner or are pending approval.
+Once a file has received an approval vote by the code owner, the file disappears
+from the file list in the reply dialog. This lets you focus on the files that
+are not yet assigned to a code owner or are pending approval.
 
 ### No code owners found
 
-There are 3 possible reasons for encountering a “Not found” text:
+There are 3 possible reasons for encountering a "Not found" text:
 
 ![no owners found](./no-owners-found.png "no owners found")
 
 - No owners were defined for these files.
-  Reason: This could be due to missing OWNERS defined for these files.
+  Reason: This could be due to missing `OWNERS` defined for these files.
 
 - None of the code owners of these files are visible.
-  Reason: The owners accounts are not visible to you.
+  Reason: The code owners accounts are not visible to you.
 
-- Owners defined for these files are invalid.
+- Code owners defined for these files are invalid.
   Reason: The emails cannot be resolved.
 
 For these 3 cases, we advise you to:
 
-1. Ask someone with override powers (e.g. sheriff) to grant an override vote to unblock the change submission.
+1. Ask someone with override powers (e.g. sheriff) to grant an override vote to
+   unblock the change submission.
 2. Contact the project owner to ask them to fix the code owner definitions.
 
 ### Renamed files
@@ -92,7 +172,9 @@
 
 ![renamed file in code owners](./renamed-file-in-code-owners.png "Renamed files in code owners")
 
-Renamed files (new path) will have a “Renamed” chip attached to them. A renamed file will be considered as approved only if both old path/name and new path/name are approved.
+Renamed files (new path) will have a "Renamed" chip attached to them. A renamed
+file will be considered as approved only if both old path/name and new path/name
+are approved.
 
 ### Failed to fetch file
 
@@ -103,18 +185,24 @@
 
 ### Large change
 
-In case of a large change containing a large number of files (hundreds or even thousands), it will take some time to fetch all suggested owners.
-In the reply dialog, the plugin will show the overall status of the fetching and results as soon as it has results together with the loading indicator. The loading will disappear until all files finished fetching, failed files will be grouped into a single group.
+In case of a large change containing a large number of files (hundreds or even
+thousands), it will take some time to fetch all suggested code owners. In the
+reply dialog, the plugin will show the overall status of the fetching and
+results as soon as it has results together with the loading indicator. The
+loading will disappear until all files finished fetching, failed files will be
+grouped into a single group.
 
-The fetching of suggested owners should not block the reply itself. So you still can select from suggestions even when not all files are finished and sent for reviewing.
+The fetching of suggested code owners should not block the reply itself. So you
+still can select from suggestions even when not all files are finished and sent
+for reviewing.
 
 ## Change page overview
 
-In the change page, you can get an overview of the Code-Owners statuses.
+In the change page, you can get an overview of the code owner statuses.
 
-If applicable, the Code-Owner status is displayed:
+If applicable, the code owner status is displayed:
 
-- Next to the Code-Owners submit requirement
+- Next to the `Code-Owners` submit requirement
 
 ![submit requirement](./submit-requirement.png "Submit requirement")
 
@@ -122,41 +210,48 @@
 
 ![owner status](./owner-status.png "Owner status")
 
-### Code-owner status
+### Code owner status
 
-#### Code-owner label
+#### `Code-Owners` submit requirement
 
-The Code-Owner label is providing an overview about the owners status at a glance.
+The `Code-Owners` submit requirement is providing an overview about the code
+owner status at a glance.
 
-- Missing a reviewer that can grant the code-owner approval
-- Pending code-owner approval
-- Approved by code-owner
+- Missing a reviewer that can grant the code owner approval
+- Pending code owner approval
+- Approved by a code owner
 
 **Missing code owner approval**
 
-The change is missing a reviewer that can grant the code-owner approval.
+The change is missing a reviewer that can grant the code owner approval.
 
-![missing owner](./owner-status-missing.png "Missing owner")
+![missing owner](./owner-status-missing.png "Missing code owner")
 
-**Pending code-owner approval**
+**Pending code owner approval**
 
-- The change is pending a vote from a reviewer that can grant the code-owner approval.  Owners have been added to the change but have not voted yet.
+- The change is pending a vote from a reviewer that can grant the code owner
+  approval. Code owners have been added to the change but have not voted yet.
 
 ![pending owner's approval 1](./owner-status-pending-1.png "Pending owner's approval")
 
-- A code owner has voted -1 on the change. A -1 doesn't block a file from being approved by another code owner. The status is pending because the change needs another round of review.
+- A code owner has voted -1 on the change. A -1 doesn't block a file from being
+  approved by another code owner. The status is pending because the change needs
+  another round of review.
 
 ![pending owner's approval 2](./owner-status-pending-2.png "Pending owner's approval")
 
-**Approved by code-owner**
+**Approved by code owner**
 
-Each file in your change was approved by at least one code owner. It's not required that all code owners approve a change.
+Each file in your change was approved by at least one code owner. It's not
+required that all code owners approve a change.
 
-![owner approved](./owner-status-approved.png "Owner approved")
+![owner approved](./owner-status-approved.png "Code owner approved")
 
 #### File status
 
-Additionally, the code-owners plugin provides a more detailed overview of code-owner status per file in the change with 3 statuses and you can **hover over the icon** to display a tooltip.
+Additionally, the `code-owners` plugin provides a more detailed overview of code
+owner status per file in the change with 3 statuses and you can **hover over the
+icon** to display a tooltip.
 
 **Missing code owner approval**
 
@@ -172,7 +267,9 @@
 
 **Approved by code owner**
 
-A code owner of this file has approved the change. You can also see this icon if you are a code-owner of the file as in this case the file is implicitly approved by you.
+A code owner of this file has approved the change. You can also see this icon if
+you are a code owner of the file as in this case the file is implicitly approved
+by you.
 
 ![approved owner tooltip](./tooltip-approved-owner.png "Tooltip for approved status")
 
@@ -185,24 +282,26 @@
 
 #### No label and no status
 
-When you own all the files in your change, the Code-Owners plugin will:
+When you own all the files in your change, the `code-owners` plugin will:
 
-- Not show the Code-Owner submit requirement
+- Not show the `Code-Owners` submit requirement
 - Not show the file status
 
 ### Owners-Override label
 
 #### In the reply dialog
 
-The Owners-Override label is votable by a user with certain permissions (e.r.sheriff).
-The owner-override label will show in the reply dialog and you can vote on it if you have certain permissions.
+The `Owners-Override` label is votable by a user with certain permissions (e.g.
+sheriff). The `Owner-Override` label will show in the reply dialog and you can
+vote on it if you have certain permissions.
 
 ![code owner override label in reply dialog](./code-owner-override-label-in-reply.png "Vote on owners-override label")
 
-
 #### In the change page
 
-When a user with certain permissions has voted "Owners-Override+1" and the Code-Owners submit requirement returns the status `Approved (Owners-Override)`.
+When a user with certain permissions has voted "Owners-Override+1" and the
+`Code-Owners` submit requirement returns the status `Approved
+(Owners-Override)`.
 
 ![code owner override label in change page](./code-owner-override-label-in-change.png "Owners-override label")
 
diff --git a/resources/Documentation/path-expressions.md b/resources/Documentation/path-expressions.md
index 7137d4c..bedbbfe 100644
--- a/resources/Documentation/path-expressions.md
+++ b/resources/Documentation/path-expressions.md
@@ -15,8 +15,7 @@
 
 Globs support the following wildcards:
 
-* `*`: matches any string that does not include slashes
-* `**`: matches any string, including slashes
+* `*`/`**`: matches any string, including slashes
 * `?`: matches any character
 * `[abc]`: matches one character given in the bracket
 * `[a-c]`: matches one character from the range given in the bracket
diff --git a/resources/Documentation/rest-api.md b/resources/Documentation/rest-api.md
index 7044cd8..f6cd213 100644
--- a/resources/Documentation/rest-api.md
+++ b/resources/Documentation/rest-api.md
@@ -54,10 +54,12 @@
       "label": "Code-Review",
       "value": 1
     },
-    "override_approval": {
-      "label": "Owners-Override",
-      "value": 1
-    }
+    "override_approval": [
+      {
+        "label": "Owners-Override",
+        "value": 1
+      }
+    ]
   }
 ```
 
@@ -161,10 +163,12 @@
       "label": "Code-Review",
       "value": 1
     },
-    "override_approval": {
-      "label": "Owners-Override",
-      "value": 1
-    }
+    "override_approval": [
+      {
+        "label": "Owners-Override",
+        "value": 1
+      }
+    ]
   }
 ```
 
@@ -182,8 +186,9 @@
 
 | Field Name  |          | Description |
 | ----------- | -------- | ----------- |
-| `email`     | optional | Code owner email that must appear in the returned
-code owner config files.
+| `include-non-parsable-files` | optional | Includes non-parseable code owner config files in the response. By default `false`. Cannot be used in combination with the `email` option.
+| `email`     | optional | Code owner email that must appear in the returned code owner config files.
+| `path`      | optional | Path glob that must be matched by the returned code owner config files.
 
 #### Request
 
@@ -195,7 +200,8 @@
 result also includes code owner config that use name prefixes
 ('\<prefix\>_OWNERS') or name extensions ('OWNERS_\<extension\>').
 
-Non-parseable code owner config files are omitted from the response.
+Non-parseable code owner config files are omitted from the response, unless the
+`include-non-parsable-files` option was set.
 
 #### Response
 
@@ -213,6 +219,70 @@
   ]
 ```
 
+### <a id="rename-email-in-code-owner-config-files">Rename Email In Code Owner Config Files
+_'POST /projects/[\{project-name\}](../../../Documentation/rest-api-projects.html#project-name)/branches/[\{branch-id\}](../../../Documentation/rest-api-projects.html#branch-id)/code_owners.rename/'_
+
+Renames an email in all code owner config files in the branch.
+
+The old and new email must be specified in the request body as
+[RenameEmailInput](#rename-email-input).
+
+The old and new email must both belong to the same Gerrit account.
+
+All updates are done atomically within one commit. The calling user will be the
+author of this commit.
+
+Requires that the calling user is a project owner
+([Owner](../../../Documentation/access-control.html#category_owner) permission
+on ‘refs/*’) or has
+[direct push](../../../Documentation/access-control.html#category_push)
+permissions for the branch.
+
+#### Request
+
+```
+  POST /projects/foo%2Fbar/branches/master/code_owners.rename HTTP/1.0
+```
+
+#### Response
+
+As response a [RenameEmailResultInfo](#rename-email-result-info) entity is
+returned.
+
+```
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "commit": {
+      "commit": "",
+      "parents": [
+        {
+          "commit": "1efe2c9d8f352483781e772f35dc586a69ff5646",
+          "subject": "Fix Foo Bar"
+        }
+      ],
+      "author": {
+        "name": "John Doe",
+        "email": "john.doe@example.com",
+        "date": "2020-03-30 18:08:08.000000000",
+        "tz": -420
+      },
+      "committer": {
+        "name": "Gerrit Code Review",
+        "email": "no-reply@gerritcodereview.com",
+        "date": "2020-03-30 18:08:08.000000000",
+        "tz": -420
+      },
+      "subject": "Rename email in code owner config files",
+      "message": "Rename email in code owner config files"
+    }
+  }
+```
+
+
 ### <a id="get-code-owner-config">[EXPERIMENTAL] Get Code Owner Config
 _'GET /projects/[\{project-name\}](../../../Documentation/rest-api-projects.html#project-name)/branches/[\{branch-id\}](../../../Documentation/rest-api-projects.html#branch-id)/code_owners.config/[\{path\}](#path)'_
 
@@ -277,24 +347,37 @@
 * are referenced by an email with a disallowed domain (see
   [allowedEmailDomain configuration](config.html#pluginCodeOwnersAllowedEmailDomain))
 * do not have read access to the branch
-* are service users (members of the `Service Users` group)
+* [fallback code owners](config.html#pluginCodeOwnersFallbackCodeOwners)
 
 are omitted from the result.
 
 The following request parameters can be specified:
 
-| Field Name  |          | Description |
-| ----------- | -------- | ----------- |
-| `o`         | optional | [Account option](../../../Documentation/rest-api-accounts.html#query-options) that controls which fields in the returned accounts should be populated. Can be specified multiple times. If not given, only the `_account_id` field for the account ID is populated.
-| `O`         | optional | [Account option](../../../Documentation/rest-api-accounts.html#query-options) in hex. For the explanation see `o` parameter.
+| Field Name   |          | Description |
+| ------------ | -------- | ----------- |
+| `o`          | optional | [Account option](../../../Documentation/rest-api-accounts.html#query-options) that controls which fields in the returned accounts should be populated. Can be specified multiple times. If not given, only the `_account_id` field for the account ID is populated.
+| `O`          | optional | [Account option](../../../Documentation/rest-api-accounts.html#query-options) in hex. For the explanation see `o` parameter.
 | `limit`\|`n` | optional | Limit defining how many code owners should be returned at most. By default 10.
-| `revision` | optional | Revision from which the code owner configs should be read as commit SHA1. Can be used to read historic code owners from this branch, but imports from other branches or repositories as well as default and global code owners from `refs/meta/config` are still read from the current revisions. If not specified the code owner configs are read from the HEAD revision of the branch. Not supported for getting code owners for a path in a change.
+| `seed`       | optional | Seed, as a long value, that should be used to shuffle code owners that have the same score. Can be used to make the sort order stable across several requests, e.g. to get the same set of random code owners for different file paths that have the same code owners. Important: the sort order is only stable if the requests use the same seed **and** the same limit. In addition, the sort order is not guaranteed to be stable if new accounts are created in between the requests, or if the account visibility is changed.
+| `revision`   | optional | Revision from which the code owner configs should be read as commit SHA1. Can be used to read historic code owners from this branch, but imports from other branches or repositories as well as default and global code owners from `refs/meta/config` are still read from the current revisions. If not specified the code owner configs are read from the HEAD revision of the branch. Not supported for getting code owners for a path in a change.
 
 As a response a list of [CodeOwnerInfo](#code-owner-info) entities is returned.
 The returned code owners are sorted by an internal score that expresses how good
 the code owners are considered as reviewers/approvers for the path. Code owners
 with higher scores are returned first. If code owners have the same score the
-order is random.
+order is random. If the path is owned by all users (e.g. the code ownership is
+assigned to '*') a random set of (visible) users is returned, as many as are
+needed to fill up the requested limit.
+
+The following factors are taken into account for computing the scores of the
+listed code owners:
+
+* distance of the code owner config file that defines the code owner to the
+  path for which code owners are listed (the lower the distance the better the
+  code owner)
+
+Other factors like OOO state, recent review activity or code authorship are not
+considered.
 
 #### Request
 
@@ -324,6 +407,23 @@
   ]
 ```
 
+#### <a id="batch-list-code-owners"> Batch Request
+
+There is no REST endpoint that allows to retrieve code owners for multiple
+paths/files at once with a single batch request, but callers are expected to
+send one request per path/file and do any necessary grouping of results (e.g.
+grouping of files with the same code owners) on their own.
+
+To ensure a stable sort order across requests for different paths/files it's
+possible to set a seed on the requests that should be used to shuffle code
+owners that have the same score (see `seed` request parameter above).
+
+To speed up getting code owners for multiple paths/files callers are advised to
+send batches of list code owners requests in parallel (e.g. 10) and start
+processing the results as soon as they come in (this approach is faster than
+having a batch REST endpoint, as the batch REST endpoint could only return
+results after the server has computed code owners for all paths).
+
 ## <a id="change-endpoints"> Change Endpoints
 
 ### <a id="get-code-owner-status"> Get Code Owner Status
@@ -390,16 +490,23 @@
 
 ## <a id="revision-endpoints"> Revision Endpoints
 
-### <a id="list-code-owners-for-path-in-change"> List Code Owners for path in change
+### <a id="list-code-owners-for-path-in-change"> Suggest Code Owners for path in change
 _'GET /changes/[\{change-id}](../../../Documentation/rest-api-changes.html#change-id)/revisions/[\{revison-id\}](../../../Documentation/rest-api-changes.html#revision-id)/code_owners/[\{path\}](#path)'_
 
-Lists the accounts that are code owners of a file in a change revision.
+Suggests accounts that are code owners of a file in a change revision.
 
 The code owners are computed from the owner configuration at the tip of the
 change's destination branch.
 
 This REST endpoint has the exact same request and response format as the
-[REST endpoint to list code owners for a path in a branch](#list-code-owners-for-path-in-branch).
+[REST endpoint to list code owners for a path in a branch](#list-code-owners-for-path-in-branch),
+but filters out code owners that which should be omitted from the code owner
+suggestion.
+
+The following code owners are filtered out additionally:
+
+* service users (members of the `Service Users` group)
+* the change owner (since the change owner cannot be added as reviewer)
 
 ### <a id="check-code-owner-config-files-in-revision">Check Code Owner Config Files In Revision
 _'POST /changes/[\{change-id}](../../../Documentation/rest-api-changes.html#change-id)/revisions/[\{revison-id\}](../../../Documentation/rest-api-changes.html#revision-id)/code_owners.check_config'_
@@ -540,8 +647,8 @@
 | `general`   | optional | The general code owners configuration as [GeneralInfo](#general-info) entity. Not set if `disabled` is `true`.
 | `disabled`  | optional | Whether the code owners functionality is disabled for the branch. If `true` the code owners API is disabled and submitting changes doesn't require code owner approvals. Not set if `false`.
 | `backend_id`| optional | ID of the code owner backend that is configured for the branch. Not set if `disabled` is `true`.
-| `required_approval` | optional | The approval that is required from code owners to approve the files in a change as [RequiredApprovalInfo](#required-approval-info) entity. The required approval defines which approval counts as code owner approval. Not set if `disabled` is `true`.
-| `override_approval` | optional | The approval that is required to override the code owners submit check as [RequiredApprovalInfo](#required-approval-info) entity. If unset, overriding the code owners submit check is disabled. Not set if `disabled` is `true`.
+| `required_approval` | optional | The approval that is required from code owners to approve the files in a change as [RequiredApprovalInfo](#required-approval-info) entity. The required approval defines which approval counts as code owner approval. Any approval on this label with a value >= the given value is considered as code owner approval. Not set if `disabled` is `true`.
+| `override_approval` | optional | Approvals that count as override for the code owners submit check as a list of [RequiredApprovalInfo](#required-approval-info) entities (sorted alphabetically). If multiple approvals are returned, any of them is sufficient to override the code owners submit check. All returned override approvals are guarenteed to have distinct label names. Any approval on these labels with a value >= the given values is considered as code owner override. If unset, overriding the code owners submit check is disabled. Not set if `disabled` is `true`.
 | `no_code_owners_defined` | optional | Whether the branch doesn't contain any code owner config file yet. If a branch doesn't contain any code owner config file yet, the projects owners are considered as code owners. Once a first code owner config file is added to the branch, the project owners are no longer code owners (unless code ownership is granted to them via the code owner config file). Not set if `false` or if `disabled` is `true`.
 
 ---
@@ -556,7 +663,7 @@
 | `status`   | optional | The code owner status configuration as [CodeOwnersStatusInfo](#code-owners-status-info) entity. Contains information about whether the code owners functionality is disabled for the project or for any branch.
 | `backend`  | optional | The code owner backend configuration as [BackendInfo](#backend-info) entity. Not set if `status.disabled` is `true`.
 | `required_approval` | optional | The approval that is required from code owners to approve the files in a change as [RequiredApprovalInfo](#required-approval-info) entity. The required approval defines which approval counts as code owner approval. Not set if `status.disabled` is `true`.
-| `override_approval` | optional | The approval that is required to override the code owners submit check as [RequiredApprovalInfo](#required-approval-info) entity. If unset, overriding the code owners submit check is disabled. Not set if `status.disabled` is `true`.
+| `override_approval` | optional | Approvals that count as override for the code owners submit check as a list of [RequiredApprovalInfo](#required-approval-info) entities. If multiple approvals are returned, any of them is sufficient to override the code owners submit check. All returned override approvals are guarenteed to have distinct label names. If unset, overriding the code owners submit check is disabled. Not set if `disabled` is `true`.
 
 ---
 
@@ -614,6 +721,7 @@
 | `merge_commit_strategy` || Strategy that defines for merge commits which files require code owner approvals. Can be `ALL_CHANGED_FILES` or `FILES_WITH_CONFLICT_RESOLUTION` (see [mergeCommitStrategy](config.html#pluginCodeOwnersMergeCommitStrategy) for an explanation of these values).
 | `implicit_approvals` | optional |  Whether an implicit code owner approval from the last uploader is assumed (see [enableImplicitApprovals](config.html#pluginCodeOwnersEnableImplicitApprovals) for details). When unset, `false`.
 | `override_info_url` | optional | Optional URL for a page that provides project/host-specific information about how to request a code owner override.
+|`fallback_code_owners` || Policy that controls who should own paths that have no code owners defined. Possible values are: `NONE`: Paths for which no code owners are defined are owned by no one. `ALL_USER`: Paths for which no code owners are defined are owned by all users.
 
 ### <a id="path-code-owner-status-info"> PathCodeOwnerStatusInfo
 The `PathCodeOwnerStatusInfo` entity describes the code owner status for a path
@@ -626,6 +734,27 @@
 
 ---
 
+### <a id="rename-email-input"> RenameEmailInput
+The `RenameEmailInput` entity specifies how an email should be renamed.
+
+| Field Name  |          | Description |
+| ----------- | -------- | ----------- |
+| `message`   | optional | Commit message that should be used for the commit that renames the email in the code owner config files. If not set the following default commit message is used: "Rename email in code owner config files"
+| `old_email` || The old email that should be replaced with the new email.
+| `new_email` || The new email that should be used to replace the old email.
+
+---
+
+### <a id="rename-email-result-info"> RenameEmailResultInfo
+The `RenameEmailResultInfo` entity describes the result of the rename email REST
+endpoint.
+
+| Field Name  |          | Description |
+| ----------- | -------- | ----------- |
+| `commit`    | optional | The commit that did the email rename. Not set, if no update was necessary.
+
+---
+
 ### <a id="required-approval-info"> RequiredApprovalInfo
 The `RequiredApprovalInfo` entity describes an approval that is required for an
 action.
diff --git a/resources/Documentation/setup-guide.md b/resources/Documentation/setup-guide.md
index c662be7..7449164 100644
--- a/resources/Documentation/setup-guide.md
+++ b/resources/Documentation/setup-guide.md
@@ -27,6 +27,9 @@
 * [How to update the code-owners.config file for a project](#updateCodeOwnersConfig)
 * [How to check if the code owners functionality is enabled for a project or branch](#checkIfEnabled)
 
+Recommendations about further configuration parameters can be found in the
+[config guide](config-guide.html).
+
 ### <a id="configureCodeOwnersBackend">1. Configure the code owners backend that should be used
 
 The `code-owners` plugin supports multiple [code owner backends](backends.html)
diff --git a/resources/Documentation/user-guide.md b/resources/Documentation/user-guide.md
index 6e31243..70dfaeb 100644
--- a/resources/Documentation/user-guide.md
+++ b/resources/Documentation/user-guide.md
@@ -7,6 +7,11 @@
 This user guide explains the functionality of the `@PLUGIN@` plugin. For a
 walkthrough of the UI please refer to the [intro](how-to-use.html) page.
 
+**TIP:** You may also want to check out the [presentation about code
+owners](https://docs.google.com/presentation/d/1DupBnGr3apIx-jzxi9cHzSgkI-2c1ouGu1teQ4khSfc)
+from the [Gerrit Contributor Summit
+2020](https://docs.google.com/document/d/1WauJfNxracjBK3PxuVnwNIppESGMBtZwxMYjxxeDN6M).
+
 **NOTE:** How to setup the code owners functionality is explained in the
 [setup guide](setup-guide.html).
 
@@ -92,6 +97,11 @@
 that votes are sticky across patch sets, then also the code owner approvals
 which are based on these votes will be sticky.
 
+**NOTE:** Whether code owners can approve their own changes depends of the
+definition of the required label. If the label definition has
+[ignoreSelfApproval](../../../Documentation/config-labels.html#label_ignoreSelfApproval)
+enabled, code owner approvals of the patch set uploader are ignored.
+
 ## <a id="codeOwnerOverride">Code owner override
 
 Usually some privileged users, such as sheriffs, are allowed to override the
@@ -146,8 +156,10 @@
 
 If the code owners functionality is enabled, all touched files require an
 approval from a code owner. If files are touched for which no code owners are
-defined, the change can only be submitted with a [code owner
-override](#codeOwnerOverride).
+defined, the change can only be submitted with an approval of a fallback code
+owner (if [configured](config.html#pluginCodeOwnersFallbackCodeOwners)) or with
+a [code owner override](#codeOwnerOverride). Please note that fallback code
+owners are not included in the [code owner suggestion](#codeOwnerSuggestion).
 
 If the destination branch doesn't contain any [code owner config
 file](#codeOwnerConfigFiles) at all yet and the project also doesn't have a
@@ -161,6 +173,11 @@
 
 ## <a id="renames">Renames
 
+A rename is treated as a deletion at the old path and a creation at the new
+path. This is why for files that are renamed, Gerrit requires a code owner
+approval for the old and the new path of the files (also see [code owner
+approval](#codeOwnerApproval) section).
+
 When files/folders get renamed, their code owner configuration should stay
 intact. Renaming a file/folder should normally not result in a situation where
 the code owner configuration for this file/folder no longer applies, because it
@@ -179,10 +196,8 @@
 is owned by user A, '*.txt' is owned by user B and 'config.md' is renamed to
 'config.txt'. In this case it is the responsibility of the author doing the
 rename and the current code owners to ensure that the file/folder has the proper
-code owners at the new path. This is why for files that are renamed Gerrit
-requires a code owner approval for the old and the new path of the files (also
-see [code owner approval](#codeOwnerApproval) section). Also this is the reason
-why [matching subfolders via path expressions is
+code owners at the new path. This is also the reason why [matching subfolders
+via path expressions is
 discouraged](backend-find-owners.html#doNotUsePathExpressionsForSubdirectories).
 
 ## <a id="mergeCommits">Merge commits
diff --git a/resources/Documentation/validation.md b/resources/Documentation/validation.md
index d06f5c8..746221e 100644
--- a/resources/Documentation/validation.md
+++ b/resources/Documentation/validation.md
@@ -20,6 +20,11 @@
 etc.) are severe errors and block the submission of all changes for which the
 affected configuration files are relevant.
 
+**NOTE:** It's possible to disable the validation of code owner config files on
+push and setup an [external
+validation](config-guide.html#externalValidationOfCodeOwnerConfigs) by a CI bot
+instead. In this case findings would be posted on the change.
+
 All validations are best effort to prevent invalid configurations from
 entering the repository, but not all possible issues can be prevented. Doing the
 validation is useful since it prevents most issues and also gives quick feedback
@@ -32,8 +37,10 @@
   plugin gets installed/enabled, it is possible that invalid configuration files
   already exist in the repository)
 * updates happen behind Gerrit's back (e.g. pushes that bypass Gerrit)
-* the validation is disabled in the
-  [plugin configuration](config.html#codeOwnersEnableValidationOnCommitReceived).
+* the validation is disabled via the
+  [enableValidationOnCommitReceived](config.html#codeOwnersEnableValidationOnCommitReceived)
+  or [enableValidationOnSubmit](config.html#codeOwnersEnableValidationOnSubmit)
+  config options
 
 In addition for [code owner config files](user-guide.html#codeOwnerConfigFiles)
 no validation is done when:
@@ -124,7 +131,7 @@
   [code owner override](user-guide.html#codeOwnerOverride).
 
 
-### <a id="codeOwnerConfigFileChecks">Validation checks for code owner config files
+### <a id="codeOwnersConfigFileChecks">Validation checks for code-owners.config files
 
 For the [code-owner.config](config.html#projectLevelConfigFile) in the
 `refs/meta/config` branch the following checks are performed:
@@ -145,6 +152,10 @@
   before)
 * the [codeOwners.mergeCommitStrategy](config.html#codeOwnersMergeCommitStrategy)
   configuration is valid
+* the [codeOwners.fallbackCodeOwners](config.html#codeOwnersFallbackCodeOwners)
+  configuration is valid
+* the [codeOwners.maxPathsInChangeMessages](config.html#codeOwnersMaxPathsInChangeMessages)
+  configuration is valid
 
 ---